Pages
Pages are file-based templates that sync to the WordPress database as posts. When you update a template file, Blockstudio detects the change and updates the corresponding page.
Pages can be defined as a single page.json folder, as standalone Markdown, or as a collection with a pages.json manifest. Collections are useful for documentation, knowledge bases, landing page libraries, or any set of nested pages that should be generated from files.
page.json
For a JSON-defined page, add a page.json configuration file:
{
"name": "about",
"title": "About Us",
"slug": "about-us",
"postType": "page",
"postStatus": "publish",
"templateLock": "all"
}Properties
| Property | Type | Default | Description |
|---|---|---|---|
name | string | required | Unique identifier for the page |
title | string | Auto from name | The page title |
slug | string | Same as name | URL slug for the page |
path | string | "." | Logical path inside a collection |
order | integer | 0 | Menu order for the synced post |
postType | string | "page" | WordPress post type |
postStatus | string | "draft" | Status for new posts (publish, draft, pending, private) |
postId | integer | - | Pin the page to a specific WordPress post ID |
blockEditingMode | string | - | Block editing mode: "default", "contentOnly", or "disabled" |
templateLock | string|false | "all" | Lock mode: "all", "insert", "contentOnly", or false |
templateFor | string|null | null | Set as default template for a post type |
sync | boolean | true | Whether to sync content when template changes |
Across page.json, frontmatter, and loader entries, recognized keys include blockEditingMode, collection, content, contentType, defaults, file, html, markdown, meta, name, order, path, postId, postStatus, postType, postTypeArgs, slug, template, templateFor, templateLock, and title. Any key not listed above is stored under the page's meta array.
Content Sources
A page definition decides which WordPress post should exist. The content source decides which block markup is written to that post.
When a page folder has a page.json, Blockstudio looks for a sibling source file in this order:
| File | Use |
|---|---|
index.php | PHP or HTML-like block markup |
index.blade.php | Blade template content |
index.twig | Twig template content |
index.md | Markdown content |
page.json can also provide inline markdown or html instead of a sibling file:
{
"name": "intro",
"title": "Intro",
"markdown": "# Intro\n\nThis page is generated from inline markdown."
}{
"name": "notice",
"title": "Notice",
"html": "<h1>Notice</h1><p>This page is generated from inline HTML.</p>"
}Collections add two more source modes:
- Markdown files discovered directly from the collection tree.
- Loader entries returned from
loader.php.
Loader entries can use markdown, html, or content with contentType. They can also point at a local file or template. File and template sources infer their type from the extension unless contentType is set explicitly.
Supported contentType values are php, blade, twig, markdown, and html. Markdown is converted to HTML first, then parsed into native WordPress blocks. PHP, Blade, Twig, and HTML sources go through the normal Blockstudio page parser.
Markdown Pages
Use Markdown when prose should live in files but still become editable WordPress block content after sync. Markdown source is converted to block markup during sync. The synced post stores blocks, not the raw Markdown body.
Markdown can be used in three ways:
| Source | Frontmatter required? | Notes |
|---|---|---|
Standalone pages/example/index.md | Yes | Frontmatter provides the page identity. |
page.json plus index.md | No | page.json defines the page and Markdown provides content. |
| Markdown inside a collection | No | The collection infers name and path from the file location. |
A standalone index.md without frontmatter is skipped because Blockstudio has no page identity to sync. Collection Markdown does not need frontmatter because the collection manifest and file path provide that identity.
---
name: about
title: About
slug: about
postStatus: publish
---
# About
This Markdown content is synced as native WordPress blocks.You can also pair page.json with index.md. In that case, page.json defines the page and index.md provides the content. Frontmatter in index.md overrides matching page.json values.
{
"name": "docs-reference",
"title": "Reference",
"path": "reference",
"sync": true
}---
title: API Reference
order: 20
---
# Reference
This page uses `page.json` with Markdown content.For collection Markdown, the file path becomes the logical page path unless frontmatter overrides it:
| File | Default path |
|---|---|
pages/docs/index.md | . |
pages/docs/getting-started.md | getting-started |
pages/docs/guide/install/index.md | guide/install |
pages/docs/guide/install.md | guide/install |
Unknown frontmatter keys are preserved in the page's meta array. This lets you attach data such as section or description and read it from the PHP API.
Markdown is parsed as GitHub Flavored Markdown. Raw HTML is allowed, unsafe links are stripped, and headings without an explicit ID receive a stable one. Nested YAML frontmatter requires the bundled YAML parser.
Raw Markdown
Markdown file sources can expose their original source Markdown for external tools and documentation consumers. Inline Markdown does not have a source file, so it syncs to blocks but does not produce a raw .md response.
Request the page with a .md suffix, such as https://example.com/docs/reference.md. Or send an Accept: text/markdown header to the normal page URL:
curl -H "Accept: text/markdown" https://example.com/docs/reference/Frontmatter is removed from the response. Successful raw Markdown responses send Content-Type: text/markdown, X-Robots-Tag: noindex, and a canonical Link header back to the HTML page. Missing .md routes return a real 404.
Collection Markdown keeps the same URL shape for raw Markdown:
| Page URL | Raw Markdown URL |
|---|---|
/docs/ | /docs.md |
/docs/getting-started/ | /docs/getting-started.md |
/docs/guide/install/ | /docs/guide/install.md |
Disable raw Markdown responses with the blockstudio/pages/serve_markdown filter:
add_filter( 'blockstudio/pages/serve_markdown', '__return_false' );Page Collections
A collection is a folder with a pages.json manifest. It groups many page sources under one collection slug, applies defaults, supports nested paths, and can register a dedicated post type automatically.
pages/
docs/
pages.json
layout.php
index.md
guide/
install/
index.md
reference/
page.json
index.md
loader.php{
"collection": "docs",
"title": "Documentation",
"postType": "bs_docs",
"postTypeArgs": {
"label": "Docs",
"rewrite": {
"slug": "docs"
},
"supports": ["title", "editor", "page-attributes"]
},
"defaults": {
"postStatus": "publish",
"templateLock": "contentOnly"
}
}| Property | Type | Description |
|---|---|---|
collection / slug / name | string | Collection slug. collection is the clearest form and becomes the public URL base. |
title | string | Human-readable collection title. |
postType | string | Post type for generated pages. Defaults to page. If it does not exist, Blockstudio registers it. |
postTypeArgs | object | Arguments passed to register_post_type() when Blockstudio registers the collection post type. |
defaults | object | Default page properties applied to every page in the collection. |
meta | object | Custom collection metadata. Unknown manifest keys are also stored in meta. |
Collection page paths are logical paths. A page with path: "guide/install" is synced as a child of guide, and guide is synced as a child of the collection root when those parent pages exist.
If a hierarchical collection has a nested page but an intermediate parent source does not exist, Blockstudio generates an empty container page for that path, which keeps the WordPress hierarchy complete. Non-hierarchical post types (those without page-attributes parent support) do not generate empty containers. They route by the stored collection path, so nested paths such as guide/install and setup/install can coexist even though both end in install.
Collection Post Types and URLs
Set postType on the collection manifest to sync every page in the collection to that post type. You do not need to repeat the same value inside defaults. Individual pages can still override postType in frontmatter or page.json.
When the post type does not exist, Blockstudio registers it with show_in_rest: true, a page-like editor setup, and the collection slug as the public URL base. postTypeArgs can override the registration arguments. Collection post type rewrite rules are flushed automatically when the registered collection post types or rewrite args change, so you do not need to resave WordPress permalinks.
Collection URLs use the collection slug plus each page's logical path:
| Source path | Public URL |
|---|---|
path: "." | /docs/ |
path: "getting-started" | /docs/getting-started/ |
path: "guide/install" | /docs/guide/install/ |
This URL model works for both hierarchical and non-hierarchical collection post types. Blockstudio also redirects doubled URLs such as /docs/docs/ or /docs/docs/getting-started/ to the canonical URL.
Collection pages expose raw Markdown at the same URLs (see Raw Markdown).
If you change a collection from postType: "page" to a custom post type, Blockstudio migrates already synced collection posts in place when their stored page identity matches. That preserves post IDs, metadata, and existing content while avoiding duplicate old-typed posts. Stale collection posts are cleaned up across all post statuses according to the blockstudio/pages/orphan_action filter.
Collection Loaders
Collections can include a trusted local loader.php. Use it when collection pages come from another local source, such as an external docs folder, a package, or a generated manifest.
The loader runs during page discovery and sync. It should be deterministic and local. Avoid fetching remote content from loader.php, because admin requests can trigger page sync.
<?php
return array(
'meta' => array(
'source' => 'loader',
),
'paths' => array(
__DIR__ . '/external-docs',
array(
'path' => __DIR__ . '/cli-docs',
'prefix' => 'cli',
'meta' => array(
'doc_set' => 'cli',
),
),
),
'pages' => array(
array(
'name' => 'docs-api',
'title' => 'API',
'path' => 'api',
'content' => "# API\n\nGenerated markdown content.",
'contentType' => 'markdown',
),
array(
'name' => 'docs-template',
'title' => 'Template',
'path' => 'template',
'file' => __DIR__ . '/template.md',
),
),
);A loader can return an array with pages, meta, and paths keys:
| Key | Description |
|---|---|
pages | A list of page arrays. Each page supports the same recognized keys as page.json and Markdown frontmatter. |
meta | Metadata merged into every loader-generated page's meta array. |
paths | Extra local directories to discover as part of this collection. Entries can be strings or path objects. |
As a shorthand, the loader may return a bare list of page arrays.
Loader page entries can provide content in these ways:
| Shape | Description |
|---|---|
markdown | Inline Markdown content. |
html | Inline HTML content. |
content + contentType | Generic inline content. contentType must resolve to markdown or html. |
file | Local file source. Type is inferred from the extension unless contentType is set. |
template | Alias for file. |
Loader file references can point to PHP, Blade, Twig, Markdown, or HTML sources. Relative paths resolve from the collection root. Absolute paths and paths outside the collection root are allowed only when the blockstudio/pages/allow_external_loader_path filter permits them.
Loader pages default to generated: true. When a generated loader page disappears from loader output, Blockstudio treats the synced post as an orphan on the next sync, cleaned up according to the blockstudio/pages/orphan_action filter.
Directories listed in paths are discovered like normal collection folders. They can contain Markdown files or page.json folders. If those paths are outside the collection root, the same external path filter applies.
Use a string entry when the external directory should keep its own logical paths. Use an object entry when the directory should be mounted under a collection path prefix:
'paths' => array(
array(
'path' => __DIR__ . '/cli-docs',
'prefix' => 'cli',
'meta' => array(
'doc_set' => 'cli',
),
),
),With that configuration, cli-docs/index.md becomes path: "cli" and cli-docs/getting-started.md becomes path: "cli/getting-started" in the collection. Prefixes also make generated page names collision-safe, so multiple mounted directories can each contain index.md or getting-started.md. Set preserveNames to true on the path object if explicit name values from page.json or Markdown frontmatter should be kept instead.
Path object properties:
| Property | Type | Description |
|---|---|---|
path | string | Local directory to discover. Relative paths resolve from the collection root. |
prefix | string | Optional logical path prefix inside the collection. |
preserveNames | boolean | Keep explicit page names instead of generating names from the prefixed path. Defaults to false when prefix is set. |
meta | object | Metadata merged into every page discovered from this path. Page-level metadata wins on duplicate keys. |
Layouts
A layout.php file wraps the frontend rendering of a file-based page. It does not replace the synced post_content stored in WordPress, and it does not wrap the editor canvas. The layout only runs on singular frontend requests.
For collections, put layout.php next to pages.json to wrap every frontend page in that collection:
<?php $current = blockstudio_current_page(); ?>
<main class="docs-layout">
<aside>
<?php foreach (blockstudio_page_tree('docs') as $item) : ?>
<?php $is_current = ($item['post_id'] ?? null) === ($current['post_id'] ?? null); ?>
<a
href="<?php echo esc_url($item['permalink'] ?? '#'); ?>"
<?php echo $is_current ? 'aria-current="page"' : ''; ?>
>
<?php echo esc_html($item['title']); ?>
</a>
<?php endforeach; ?>
</aside>
<article>
<?php echo blockstudio_page_content(); ?>
</article>
</main>blockstudio_page_content() prints the page body that WordPress would normally render through the_content. If a layout does not output blockstudio_page_content(), the page content is intentionally hidden.
Layouts can use the page helper functions:
blockstudio_current_page();
blockstudio_page_collection('docs');
blockstudio_pages('docs');
blockstudio_page_tree('docs');
blockstudio_page_children('docs-guide', 'docs');
blockstudio_page_content();Nested non-collection pages can also inherit the nearest ancestor layout.php inside the pages tree. For collection pages, the collection root layout is stored on each synced post and watched as part of the page fingerprint, so layout changes are picked up on the next sync.
Block Editing Mode
The blockEditingMode property controls how blocks can be edited in the editor. Set a page-level default in page.json and override per-element in the template:
{
"name": "about",
"title": "About Us",
"templateLock": "all",
"blockEditingMode": "disabled"
}<h1 blockEditingMode="contentOnly">Editable Title</h1>
<p>This paragraph inherits "disabled" from the page default.</p>
<block name="core/buttons" blockEditingMode="default">
<block name="core/button" url="/contact">Fully editable button</block>
</block>| Value | Description |
|---|---|
"default" | Full editing. Blocks can be selected, moved, and edited |
"contentOnly" | Text editing only. Block text is editable but settings and structure are locked |
"disabled" | No editing. Blocks are completely non-interactive |
Per-element overrides are set as HTML attributes on any element (<h1>, <p>, <div>, etc.) or on <block> elements. Ancestor container blocks of overridden elements are automatically set to contentOnly so their children remain accessible. This cascade walks up from the overridden element to the page root, so you only need to set blockEditingMode on the elements you want to make editable.
Template Lock
The templateLock property controls how users can edit the page in the editor:
| Value | Add/Remove | Move | Edit Content |
|---|---|---|---|
"all" | No | No | No |
"insert" | No | Yes | Yes |
"contentOnly" | No | No | Yes |
false | Yes | Yes | Yes |
When a templateLock is set, Blockstudio disables the ability to unlock blocks via the editor UI.
Sync Behavior
Pages are synced when you visit the WordPress admin. Blockstudio only updates WordPress when the relevant page source files or settings change.
To prevent a page from being overwritten after manual edits, set "sync": false in page.json.
For collection pages, Blockstudio watches the manifest, loader, page source, layout file, metadata, and related source paths. When any of those inputs change, the generated WordPress pages are synced again.
When a collection source is removed, Blockstudio removes the generated page so old collection pages do not linger after the source file is gone. By default, orphaned collection pages are moved to trash. Use blockstudio/pages/orphan_action to return trash, delete, or keep.
On frontend requests, Blockstudio hydrates the page registry from already synced published posts. Draft and private pages are available in admin and sync contexts, but frontend helpers only hydrate published pages.
Post ID Pinning
By default, WordPress auto-assigns post IDs. If a page is deleted and re-created, the ID changes, breaking external references like menus or hardcoded links.
Use postId to pin a page to a specific ID:
{
"name": "about",
"title": "About Us",
"postId": 42
}When creating the page, Blockstudio passes the ID to WordPress via import_id. If the ID is available, WordPress uses it exactly. If the ID is already taken by an unrelated post, WordPress silently auto-assigns a new ID.
When looking up existing pages, Blockstudio checks the pinned ID first, before checking meta or slug. This means if a page is deleted and re-synced, it reclaims the same ID.
Keyed Block Merging
By default, when a template file changes, Blockstudio replaces the entire post content with the new template. This means any edits made in the WordPress editor are lost.
Add a key attribute to blocks to preserve user edits across template syncs. When a template has keyed blocks, Blockstudio merges template changes with the existing post content instead of replacing it.
Adding Keys
Use the key attribute on any HTML element or <block> element:
<h1 key="title">Default Title</h1>
<p key="intro">Default intro text.</p>
<div key="features">
<p>First feature.</p>
<p>Second feature.</p>
</div>
<block name="core/cover" key="hero" url="https://example.com/bg.jpg">
<h2>Hero Title</h2>
<p>Hero description.</p>
</block>Keys must be globally unique across the entire template. A key protects the entire block and all of its content. You don't need to key individual children inside a keyed parent. Keyed blocks can be moved to any position or nesting level between template updates and their user content will still be preserved.
How Merging Works
When a template file changes and the page re-syncs:
| Block | Behavior |
|---|---|
| Keyed block | User's content is preserved (innerHTML, innerContent, innerBlocks). Template's attributes are applied. |
| Unkeyed block | Replaced entirely with the template version (same as without keys) |
Examples
User edits are preserved in keyed blocks:
- Template defines
<p key="intro">Default intro.</p> - User edits the paragraph in the editor to "Custom intro text."
- Developer updates the template, adds a new block, changes layout
- On sync: the paragraph keeps "Custom intro text." while the new layout is applied
Attributes from the template still apply:
- Template has a cover block with
key="hero"and aurlattribute - Developer changes the cover's
urlattribute to a new background image - On sync: the new background image is applied, but user's content edits inside the cover are preserved
Edge Cases
| Scenario | Behavior |
|---|---|
| New keyed block added to template | Appears with the template's default content |
| Keyed block removed from template | Deleted from the post |
| Block type changes (same key) | Template wins entirely, no content merge |
| Duplicate keys | Second occurrence is treated as unkeyed |
| No keys in template | Full replacement (same behavior as without keys) |
| Force sync | Full replacement, keys are ignored |
| Locked post | No sync at all |
Combining with Editing Controls
Keys, templateLock, and blockEditingMode combine to give you fine-grained control over what clients can and can't do. Here are common workflow patterns.
Fully locked page
The developer controls everything. The page is a static layout that clients can't touch at all. Useful for legal pages, terms of service, or any page that should only change through code.
{
"name": "terms",
"title": "Terms of Service",
"templateLock": "all"
}<h1>Terms of Service</h1>
<p>Last updated: January 2025</p>
<p>These terms govern your use of our service...</p>No keys needed. The template is the single source of truth. Every sync overwrites the page entirely.
Locked layout with editable content
The most common pattern. Lock the page structure so clients can't add, remove, or rearrange blocks, but let them edit text in specific places. Keys ensure their edits survive template updates.
{
"name": "landing",
"title": "Landing Page",
"templateLock": "all",
"blockEditingMode": "disabled"
}<block name="core/cover" key="hero" url="https://example.com/bg.jpg">
<h1 blockEditingMode="contentOnly">Edit This Title</h1>
<p blockEditingMode="contentOnly">Edit this description.</p>
</block>
<div key="features">
<h2>Features</h2>
<p blockEditingMode="contentOnly">Editable feature intro.</p>
</div>
<block name="core/buttons">
<block name="core/button" url="/contact">Contact Us</block>
</block>The client can edit the hero title, description, and feature intro. Nothing else. The button text, URL, and cover background image are developer-controlled. When the developer updates the template (e.g., adds a second button), keyed sections keep the client's text.
Evolving template with preserved content
For pages that grow over time. The developer adds new sections to the template, and each sync adds the new content without touching what the client has already customized.
<h1 key="title">Welcome</h1>
<p key="intro">Our introduction.</p>
<div key="services">
<h2>Our Services</h2>
<p>We offer great things.</p>
</div><h1 key="title">Welcome</h1>
<p key="intro">Our introduction.</p>
<div key="services">
<h2>Our Services</h2>
<p>We offer great things.</p>
</div>
<div key="testimonials">
<h2>Testimonials</h2>
<p>What our clients say.</p>
</div>
<block name="core/buttons">
<block name="core/button" url="/contact">Get in Touch</block>
</block>The new testimonials section and button appear on sync. The client's edits to the title, intro, and services are untouched.
One-time scaffold
Create a page with a starting structure, then hand it off entirely. Set sync to false so the template is only used for the initial creation. After that, the client owns the page completely.
{
"name": "blog",
"title": "Blog",
"sync": false
}<h1>Our Blog</h1>
<p>Welcome to our blog. Start writing!</p>The page is created once. After that, the client can add, remove, and rearrange blocks freely. Template changes are ignored.
Block Bindings
An alternative to keyed merging is using WordPress core's Block Bindings API to connect blocks to post meta. This is not a Blockstudio feature, it is a native WordPress API. Blockstudio simply passes the metadata attribute through to the generated block markup. The content lives in post meta instead of block markup, so template syncs can freely overwrite the markup because the editor reads from meta, not from innerHTML.
Pass the metadata attribute on any HTML element or <block> element:
<h1
metadata='{"bindings":{"content":{"source":"core/post-meta","args":{"key":"hero_title"}}}}'
>
Default Title
</h1>
<p
metadata='{"bindings":{"content":{"source":"core/post-meta","args":{"key":"hero_description"}}}}'
>
Default description text.
</p>
<img
metadata='{"bindings":{"url":{"source":"core/post-meta","args":{"key":"hero_image"}},"alt":{"source":"core/post-meta","args":{"key":"hero_image_alt"}}}}'
/>The meta keys must be registered with show_in_rest enabled:
register_post_meta( 'page', 'hero_title', array(
'show_in_rest' => true,
'single' => true,
'type' => 'string',
'default' => 'Default Title',
) );Keys vs. bindings
| Keys | Bindings | |
|---|---|---|
| Content stored in | Block markup (post_content) | Post meta (wp_postmeta) |
| Survives template sync | Yes (merged by key) | Yes (data lives in meta) |
Queryable via WP_Query | No | Yes (meta_query) |
| Available in REST API | No | Yes |
| Requires registration | No | Yes (register_post_meta) |
| User can move blocks | No (template re-imposes structure) | No (template re-imposes structure) |
Both approaches can be used in the same template. Use keys for sections where the block structure matters (e.g., a group with multiple children). Use bindings for individual fields where you also want the data accessible outside the block editor.
Post Type Templates
Use templateFor to set a page as the default template for a post type:
{
"name": "product-template",
"title": "Product Template",
"templateFor": "product",
"templateLock": "insert"
}Any new posts of that type will start with the defined block structure.
Custom Paths
By default, Blockstudio scans get_template_directory() . '/pages'. Add additional paths with the filter:
add_filter( 'blockstudio/pages/paths', function( $paths ) {
$paths[] = get_stylesheet_directory() . '/custom-pages';
$paths[] = MY_PLUGIN_DIR . '/pages';
return $paths;
} );Filters and Actions
Page discovery, sync, and collection routing expose these extension points:
| Hook | Type | Description |
|---|---|---|
blockstudio/pages/paths | Filter | Add directories that Blockstudio scans for page sources. |
blockstudio/pages/collection_post_type_args | Filter | Adjust register_post_type() args for a collection CPT. |
blockstudio/pages/template_candidates | Filter | Adjust template filenames considered for a page source. |
blockstudio/pages/serve_markdown | Filter | Enable or disable raw Markdown responses. |
blockstudio/pages/orphan_action | Filter | Return trash, delete, or keep for orphan cleanup. |
blockstudio/pages/docs_allowed_html | Filter | Adjust allowed HTML for generated Markdown docs content. |
blockstudio/pages/allow_external_loader_path | Filter | Allow loader paths outside the collection root. |
blockstudio/pages/create_post_data | Filter | Modify post data before a synced page is created. |
blockstudio/pages/update_post_data | Filter | Modify post data before a synced page is updated. |
blockstudio/pages/post_created | Action | Runs after a synced page post is created. |
blockstudio/pages/post_updated | Action | Runs after a synced page post is updated. |
blockstudio/pages/synced | Action | Runs after the page registry has finished syncing. |
PHP API
// Get all registered pages.
$pages = Blockstudio\Pages::pages();
// Get pages from one collection.
$docs = Blockstudio\Pages::pages('docs');
// Get a nested page tree.
$tree = Blockstudio\Pages::tree('docs');
// Get direct children for a page.
$children = Blockstudio\Pages::children('docs-guide', 'docs');
// Get collection metadata.
$collection = Blockstudio\Pages::collection('docs');
// Get a specific page.
$page = Blockstudio\Pages::get_page('about');
// Get the WordPress post ID for a page.
$postId = Blockstudio\Pages::get_post_id('about');
// Force sync a page (ignores modification time).
Blockstudio\Pages::force_sync('about');
// Force sync all pages.
Blockstudio\Pages::force_sync_all();
// Lock a page to prevent automatic updates.
Blockstudio\Pages::lock('about');
// Unlock a page.
Blockstudio\Pages::unlock('about');Global helper functions are available for templates and layouts:
blockstudio_pages('docs');
blockstudio_page_tree('docs');
blockstudio_page_children('docs-guide', 'docs');
blockstudio_page_collection('docs');
blockstudio_page_content();
blockstudio_current_page();Page arrays returned by Blockstudio\Pages::pages(), Blockstudio\Pages::tree(), Blockstudio\Pages::children(), and the global helpers include a permalink key after sync. There is no separate Pages::permalink() method.