Developer Reference
Editing Existing Templates
Audience: Site Builder.
PageCraft's bundled section templates aren't carved in stone — every section the picker shows can be edited, renamed, recategorised, or replaced from the CP. This page covers the basic CRUD flow plus the regeneration step that keeps the picker in sync with the database.
The Sections manager
CP path: Add-Ons → PageCraft → Sections.
The list shows every row in exp_pagecraft_sections for the current site, with thumbnail, name, category, description, and Edit / Delete actions. Toolbar buttons across the top:
- Regenerate File — see "Regenerate File button" below
- Export Sections — downloads a JSON file (the input format for Sections Import)
- Import Sections — uploads a JSON file (replaces or appends)
- Import Pack (.zip) — see Importing Custom Templates
- Ingest Template (.zip) — see Importing Custom Templates
- Add Section — opens the section editor for a new row
Section editor fields
Click Edit on any row, or click Add Section. The edit screen exposes:
- Name — shown under the thumbnail in the picker
- Description — shown only inside the Sections manager (free-form note for your own reference)
- Category — the picker grouping the section appears under (Grid Builder, PageCraft, or any custom category you've added under PageCraft → Categories)
- Thumbnail — see "Thumbnail formats" below
- HTML body — the actual section markup (the source of what gets dropped into the canvas)
- Sort order — picker order within the category
Saving the form re-runs Services/SectionFileWriter::write() automatically, so the picker reflects your change immediately.
Thumbnail formats
The Thumbnail field accepts four shapes:
- EE file reference — {file:42:url} resolves through the EE file manager
- Portable file reference — {filedir_5}my-thumb.png (legacy, still supported)
- Absolute URL — https://cdn.example.com/thumbs/hero.png
- Plain filename — hero.png resolves to themes/user/pagecraft/pagecraft-sections/preview/hero.png
The plain-filename path is the most ergonomic for SVG previews you commit to the repo alongside the section. The bundled Grid Builder thumbnails use this pattern (gb-1col.svg, gb-2col.svg, etc.).
Services/SectionFileWriter::buildThumbnailPath() decides which shape it has and returns the right absolute URL when generating the picker JS.
Regenerate File button
The picker reads from themes/user/pagecraft/pagecraft-sections/pagecraft-sections.js. That file is regenerated by SectionFileWriter after any section/category save. Two situations the button helps with:
- Permission-related failures. If the file isn't writable by the PHP user, the auto-write fails silently. The Regenerate button surfaces the exact error inline ("file is not writable by the PHP user — fix permissions on …").
- Sections that show in the manager but not in the canvas picker. The DB and the JS file have drifted. Click the button to force a clean rewrite.
The writer is non-destructive: if the DB has zero categories and zero sections (e.g. an empty fresh install) it refuses to overwrite a non-empty existing file, so you can't accidentally clobber the bundled defaults.
Save-as-Section from the canvas
Inside the editor, every section toolbar has a 💾 Save as Section button. Click it, name the new section, pick a category. The flow:
- Clones the rendered HTML out of the canvas
- Strips ContentBox editor UI (toolbars, drag handles, click hooks) and the data-cb-original-content snapshot via Actions/SaveSection.php
- Inserts a new row into exp_pagecraft_sections
- Triggers SectionFileWriter::write() so the new section appears in the picker on next page load
Reference
- Schema: Page_craft_upd::createSectionsTable() and createSectionCategoriesTable() in upd.page_craft.php
- Generation pipeline: Services/SectionFileWriter.php
- Save-as-Section action: Actions/SaveSection.php
- Sections list page: mcp.page_craft.php::sections()
- Section editor: mcp.page_craft.php::section_edit($id)
Importing Custom Templates
Audience: Site Builder.
PageCraft offers three import paths, each suited to a different source format. They live side-by-side as buttons on Add-Ons → PageCraft → Sections. This page covers when to use which.
At a glance
| Source | Tool | Action class |
|---|---|---|
| JSON export from another PageCraft install | Import Sections | Actions/SectionsImport.php |
Structured .zip with a manifest |
Import Pack (.zip) |
Actions/PackImport.php |
.zip of raw HTML pages from a template marketplace |
Ingest Template (.zip) |
Actions/TemplateIngest.php |
Sections Import
For moving sections between PageCraft installs. Use Export Sections on the source install to download pagecraft-sections-export.json, then Import Sections on the destination to replace or append.
The JSON shape (excerpt):
{
"categories": [
{ "id": 28, "name": "Grid Builder", "sort_order": 0 }
],
"sections": [
{
"id": 14,
"name": "1 Column",
"description": "",
"thumbnail": "gb-1col.svg",
"html": "<div class=\"is-section…",
"category_id": 28,
"sort_order": 0
}
]
}
Categories are matched by name on import — if a target site already has a "Grid Builder" category, the imported sections attach to it instead of creating a duplicate.
Pack Import
For distributing self-contained section packs (your own or third-party). Input is a .zip file containing:
mypack/
├── pagecraft.json ## manifest
├── content.js ## optional: ContentBox content snippets
├── content.css ## optional: pack-wide styles
├── preview/ ## thumbnail images
└── images/ ## any image assets the sections reference
The manifest format:
{
"name": "My Template Pack",
"slug": "my-template-pack",
"version": "1.0.0",
"description": "Custom hero + feature sections.",
"author": "Your Name",
"categories": [
{ "name": "Heroes" },
{ "name": "Features" }
],
"sections": [
{
"name": "Hero - Centered",
"category": "Heroes",
"thumbnail": "preview/hero-centered.png",
"html": "<div class=\"is-section\">…</div>",
"description": "Centered hero with CTA pair."
}
]
}
PackImport extracts to themes/user/pagecraft/imported/<slug>/, registers the pack with PageCraft's pack indexer, and inserts category + section rows into the DB. Thumbnails are stored as absolute URLs (resolved against the imported pack's location) so they survive future regenerations of pagecraft-sections.js.
A starter manifest ships at themes/user/pagecraft/pagecraft-pack-manifest-example.json — copy it and adapt.
Template Ingest
For converting a raw HTML template marketplace download — a zip of standalone HTML pages (index.html, about.html, services.html) plus the usual css/, js/, and images/ asset folders — into PageCraft sections automatically.
Workflow
- Click Ingest Template (.zip), upload your archive.
- PageCraft extracts to themes/user/pagecraft/imported/
<slug>/. - Each .html page becomes a category named after the file (about.html → "About"). Sections are derived from the page's headings —
<h1>/<h2>/<h3>boundaries split the page into individually reusable section rows. - Asset URLs (
<link href>,<script src>,<img src>) are rewritten to point at the extracted location. - SVG placeholder thumbnails are generated for every section (real screenshots can be added later via the Sections editor).
- CSS / JS files are registered with the canvas so the imported sections render correctly inside the editor as well as on the front-end.
Compared to PackImport, this is a "best-effort" ingest — you'll usually want to do a follow-up pass through Sections to clean up names, replace placeholder thumbnails, and trim any sections you don't actually need.
Choosing the right path
- Moving content between PageCraft installs → Sections Import
- Distributing a polished pack with categories + thumbnails already curated → Pack Import
- Bringing in a raw HTML template you bought on a marketplace → Template Ingest
Match the Grid Framework to your import
Imported templates are built on a specific CSS framework — most ThemeForest / marketplace templates use Bootstrap; some use Tailwind. After importing, set Add-Ons → PageCraft → Global Settings → Grid Framework (or the per-field override) to match, so the editor drives the template's own col-* / w-*/12 columns natively — selection, move, and resize all operate on the real grid classes. See Grid Framework for the full behaviour, including how it converts native sections and why mixing frameworks on one page should be avoided.
Reference
- Actions/SectionsImport.php — JSON ingest
- Actions/PackImport.php — manifest-driven .zip ingest
- Actions/TemplateIngest.php — raw-HTML .zip ingest
- Services/PackIndexer.php — pack registry/discovery used by all three paths
The Class Marker System
Audience: Site Builder.
When you author a custom section by hand (or edit an existing one through PageCraft → Sections → Edit), you're writing plain HTML that gets stored in the database. To make that HTML editable inside ContentBox — text inline-editable, images droppable, columns sortable — the markup needs ContentBox-specific class names like is-subblock, edit, is-container, is-builder. Asking section authors to memorise those is an ergonomics problem.
PageCraft solves it with a small set of marker classes. You add the marker; PageCraft rewrites the marker into the verbose ContentBox classes when it generates pagecraft-sections.js. Your stored HTML stays clean and portable; the editor scaffolding is added at generation time.
The four markers
pc-edit
Marks any element as a text or image editing zone. The class is left in place and is-subblock edit is appended to the same element.
<!-- Author writes -->
<h2 class="pc-edit">Section Heading</h2>
<!-- pagecraft-sections.js receives -->
<h2 class="pc-edit is-subblock edit">Section Heading</h2>
Use on every heading, paragraph, and standalone span the editor should be able to click into.
pc-img
Marks an image wrapper. The class stays on the wrapper, but is-subblock edit is injected onto the <img> element inside — not on the wrapper itself. That's a deliberate ContentBox quirk: image editing handles attach to the image element, not its parent.
<!-- Author writes -->
<div class="pc-img"><img src="hero.jpg" alt=""></div>
<!-- Generated -->
<div class="pc-img"><img class="is-subblock edit" src="hero.jpg" alt=""></div>
pc-container
Marks a layout container. PageCraft appends is-container v2 is-content-1200 is-builder so ContentBox treats the element as a content boundary and a drop zone for nested rows/columns. Use this on the wrapper around a row of columns inside an is-section.
<!-- Author writes -->
<div class="pc-container">
<div class="row">…</div>
</div>
<!-- Generated -->
<div class="pc-container is-container v2 is-content-1200 is-builder">
<div class="row">…</div>
</div>
pc-section-template-embed
Marker for the bundled Dynamic Content section. It's styling-only — it scopes the placeholder's CSS so the dashed-border placeholder visual doesn't leak into other sections. No class rewriting happens; the marker is purely a CSS hook.
Auto-injections
In addition to the explicit markers, PageCraft auto-fills two pieces of ContentBox structure if they're missing on the section's root element:
- If the root doesn't have is-section, it gets is-section is-box is-section-auto added.
- If the root doesn't already start with an
<div class="is-overlay">``</div>first child, one is inserted. (ContentBox uses the overlay for drag/select hit testing.)
Authors can ignore both — write a plain <div> as your section root and PageCraft fills in the boilerplate.
Why this exists
Two practical reasons:
- Portability. A section's HTML in the DB is the same shape you'd find in any static HTML template — copy/pasteable into other systems, easy to lint, easy to read at a glance.
- Forwards compatibility. ContentBox's class names have changed across major versions (is-subblock was renamed at one point). Centralising the rewrite means a future ContentBox upgrade only needs SectionFileWriter updated, not every stored section.
Live examples
Open themes/user/pagecraft/developer-reference.html in a browser. It contains nine annotated section examples — two-column layouts, unequal columns, hero with background image, locked footer, multi-row sections, plugin embeds — each showing the marker classes in their author form alongside the generated ContentBox classes.
Reference
- Rewrite logic: Services/SectionFileWriter.php — pc-edit (lines 81–85), pc-img (lines 88–100), pc-container (lines 102–111), auto-injections (lines 113–154)
- Annotated examples: themes/user/pagecraft/developer-reference.html
Template Tags Reference
Audience: Site Builder.
PageCraft exposes two tag families:
- Field tag — {your_field_name} and its modifiers. Use inside {exp:channel:entries} to output the field's stored data.
- Module tags — {exp:page_craft:…} for sitewide concerns (resolved HTML, locked global header / footer).
Field tag
The field tag works like any other custom field tag. Its type= parameter selects which slice of the stored JSON you want.
{exp:channel:entries channel="pages" url_title="{segment_3}"}
<style>{my_field type="css"}</style>
{my_field type="html"}
<style>{my_field type="sectioncss"}</style>
{/exp:channel:entries}
Parameters:
- type="html" — the canvas HTML, with dynamic content already resolved (template embeds rendered, structure-driven nav menus injected). Default.
- type="css" — the page-level main stylesheet
- type="sectioncss" — the accumulated per-section CSS
- runtime="yes" — also enqueue the front-end runtime JS/CSS (for sections that include ContentBox plugins like sliders, accordions, FAQ blocks). Defaults to no so simple text-only pages don't pull the runtime.
Module tags
{exp:page_craft:render}
The all-in-one front-end tag. Returns a complete render of a specific entry's PageCraft field: an inline <style> block (main + section CSS), the resolved HTML wrapped in <div class="is-wrapper">, and — unless disabled — the enqueued runtime JS/CSS.
{exp:channel:entries channel="pages" url_title="{segment_2}" limit="1"}
{exp:page_craft:render entry_id="{entry_id}" field="page_builder" runtime="yes"}
{/exp:channel:entries}
Parameters:
- entry_id="{entry_id}" — required. The entry to render. Inside {exp:channel:entries} use the {entry_id} variable; the tag also accepts a literal id.
- field="page_builder" — required. The PageCraft field's short name. This must match the field exactly — a wrong name produces
<!-- PageCraft: no data found for entry N, field X -->and renders nothing. - runtime="yes" (default) / no — enqueue the ContentBox runtime so plugin sections (sliders, accordions, nav menus, FAQ) initialise on the front end.
- wrapper="yes" (default) / no — wrap the output in
<div class="is-wrapper">. Leave this on. The runtime's prepareContent() selects .is-wrapper unconditionally; if no .is-wrapper exists on a page that boots the runtime, init throws Cannot read properties of null and all PageCraft content on the page fails to render. - class="..." — extra classes added to the wrapper div.
Diagnostics: render never fails silently. If output is missing, View Source for an HTML comment:
| Comment in source | Cause |
|---|---|
entry_id and field parameters are required |
entry_id resolved to 0 (parse order), or the field is blank. |
no data found for entry N, field X |
The entry matched, but the field short name is wrong or the entry has no saved data. |
| (no comment at all) | {exp:channel:entries} matched zero entries, so render never ran. |
render vs html: use render when you want the full package (styles + wrapper + runtime) for one entry. Use {exp:page_craft:html} (below) when you want just the resolved HTML and you're managing CSS/runtime yourself.
{exp:page_craft:html}
Renders the current entry's PageCraft field as fully resolved HTML. Equivalent to {my_field type="html"} but discoverable from any template without knowing the field's name. Useful in layouts that render different channels.
{exp:page_craft:main_css}
Outputs the page-level main stylesheet for the current entry.
{exp:page_craft:section_css}
Outputs the accumulated section CSS.
{exp:page_craft:global_header}
Renders the locked global header for the current site (see Global Header & Footer).
Parameters:
- wrapper="yes" (default) / no — wrap in a
<header>element - class="..." — extra classes on the wrapper
- runtime="yes" — enqueue the front-end runtime alongside
- Returns an empty string if no header has been locked.
{exp:page_craft:global_footer}
Same as global_header but for the footer slot. Same parameters.
Canvas Assets
Custom CSS and JS that should ride alongside the editor canvas (and optionally the front-end render). Set per field or globally.
- Custom CSS — additional stylesheet that loads inside the canvas iframe. Useful when your section markup uses utility classes (Tailwind, custom design tokens) the canvas doesn't otherwise have access to.
- Custom JS — additional script that loads inside the canvas iframe. Common case: third-party plugin code you want active so editors see exactly how the front-end will look.
- Both are injected into the editor frame. The runtime="yes" parameter on the field tag (or the {exp:page_craft:html} tag) controls whether the same assets enqueue on the front-end.
Security: the addon strips outer <script> / <style> tags and HTML comment wrappers before persisting, so editors can paste asset blocks copied from another tool without the wrapping tags accidentally breaking ContentBox's iframe init. See Actions/CustomCss.php and Actions/CustomJs.php if you want to audit the strip rules.
When to use field tag vs. module tag
- Field tag ({my_field}) — preferred when you're already inside {exp:channel:entries} and you know the field name. Slightly more efficient because it avoids the extra channel lookup the module tag does.
- Module tag ({exp:page_craft:html}) — preferred for shared layouts that render different entries via different channels. Works without knowing the field name as long as the channel only has one PageCraft field.
Reference
- Field tag: ft.page_craft.php::replace_tag()
- Module tags: mod.page_craft.php — html(), main_css(), section_css(), global_header(), global_footer()
- Canvas Assets: Actions/CustomCss.php, Actions/CustomJs.php
Template Examples
Audience: Site Builder.
Copy-paste starting points for wiring PageCraft entries into front-end templates. They progress from the simplest possible render to a full multi-page site with global chrome.
Before you start — two things that cause 90% of “nothing renders”:
- Use your real field short name. Every
field="…"below is written asfield="page_builder". Replace it with your PageCraft field's short name (Develop → Channels → Fields). A wrong name renders nothing and emits<!-- PageCraft: no data found … -->.- Keep the asset placeholders.
{pagecraft_css}/{pagecraft_styles}go in<head>;{pagecraft_js}/{pagecraft_scripts}go just before</body>. Without them, plugin sections (sliders, nav menus, accordions) won't load their CSS/JS.
If a page comes up blank, View Source and check for an HTML comment — see the diagnostics table in Template Tags Reference.
1. Out of the box — the minimal render
The smallest template that renders a PageCraft entry. No routing, no chrome. Good for confirming the pipeline works before adding anything else.
global/_wrapper (layout)
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>My Site</title>
{pagecraft_css}
{pagecraft_styles}
</head>
<body>
{layout:contents}
{pagecraft_js}
{pagecraft_scripts}
</body>
</html>
site/page (the template that renders an entry)
{layout="global/_wrapper"}
{exp:channel:entries channel="default_page" limit="1" status="open"}
{exp:page_craft:render entry_id="{entry_id}" field="page_builder" runtime="yes"}
{/exp:channel:entries}
This grabs the most recent open entry and renders it. render supplies the inline CSS, the .is-wrapper div, and the runtime — so the layout only needs the four asset placeholders.
2. Out of the box — URL-routed pages
One template that serves any page by its url_title from the last URL segment. This is the typical "flat pages" setup.
site/page
{layout="global/_wrapper"}
{exp:channel:entries channel="default_page" url_title="{segment_1}" limit="1" require_entry="yes"}
{exp:page_craft:render entry_id="{entry_id}" field="page_builder" runtime="yes"}
{/exp:channel:entries}
- url_title="{segment_1}" — matches /about → entry with url_title about. Use {segment_2} if the template lives one group deep, or {last_segment} for nested/Structure URLs.
- require_entry="yes" — a clean miss instead of a half-rendered page when the URL matches nothing.
Home page note: an empty URL means {segment_1} is blank, which matches no entry. Handle home with a dedicated condition — see example 4.
3. Mixed — out of the box render + custom EE markup around it
PageCraft renders the page body, but you hand-author the surrounding template: your own <h1>, breadcrumbs, sidebar, other channel fields, an entries loop of related posts, etc. Mix freely — render just drops resolved HTML into wherever you place it.
site/page
{layout="global/_wrapper"}
{exp:channel:entries channel="default_page" url_title="{segment_1}" limit="1" require_entry="yes"}
<nav class="breadcrumbs">
<a href="{path='home'}">Home</a> › <span>{title}</span>
</nav>
<article class="page-body">
{!-- PageCraft-built body --}
{exp:page_craft:render entry_id="{entry_id}" field="page_builder" runtime="yes"}
</article>
{!-- a normal custom field, rendered by hand --}
{if custom_summary}<aside class="summary">{custom_summary}</aside>{/if}
<section class="related">
<h2>Related pages</h2>
{related_entries:related_pages}
<a href="{title_permalink='site/page'}">{title}</a>
{/related_entries:related_pages}
</section>
{/exp:channel:entries}
The key idea: render is just one tag among many inside the entries loop. Everything outside it is ordinary EE templating.
3b. Mixed — split CSS / HTML / runtime manually
When you need the page CSS in <head> (cleaner than render's inline <style>) but the HTML lower down, use the granular tags instead of render. Use the field tag (fastest, you're already in the loop) or the equivalent module tags.
{layout="global/_wrapper"}
{exp:channel:entries channel="default_page" url_title="{segment_1}" limit="1" require_entry="yes"}
{!-- field-tag form: page_builder is YOUR field's short name --}
{if no_results}{redirect="404"}{/if}
{!-- main + section CSS pushed to the <head> via a layout variable --}
{layout:set name="page_styles"}
<style>{page_builder type="css"}</style>
<style>{page_builder type="sectioncss"}</style>
{/layout:set}
<h1>{title}</h1>
{page_builder type="html" runtime="yes"}
{/exp:channel:entries}
…and in the layout <head>, output {layout:contents}-set styles:
{if page_styles}{page_styles}{/if}
{pagecraft_css}
{pagecraft_styles}
type="html" defaults to runtime="no". Add runtime="yes" whenever the page uses plugin sections, or the sliders/menus won't initialise.
4. All custom — full site with global header & footer
A complete production layout: fixed global header, segment-based routing, a dedicated home case, and a global footer. This is the pattern to grow into once the basics render.
global/_wrapper (layout)
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>{if segment_1}{segment_1}{if:else}Home{/if} · My Site</title>
<style>
.site-header{position:fixed;top:0;left:0;width:100%;z-index:9999}
.site-header-spacer{height:70px}
</style>
{pagecraft_css}
{pagecraft_styles}
</head>
<body>
{!-- Global header on every page. Keep wrapper="yes" (the default) so the
runtime always finds a .is-wrapper element and prepareContent() can't
crash. --}
<div class="site-header">{exp:page_craft:global_header}</div>
<div class="site-header-spacer"></div>
{layout:contents}
{exp:page_craft:global_footer}
{pagecraft_js}
{pagecraft_scripts}
</body>
</html>
site/page (router with a home case)
{layout="global/_wrapper"}
{if segment_1 == "" OR segment_1 == "home"}
{!-- Home: newest open entry --}
{exp:channel:entries channel="default_page" url_title="home" limit="1" require_entry="yes"}
{exp:page_craft:render entry_id="{entry_id}" field="page_builder" runtime="yes"}
{/exp:channel:entries}
{if:else}
{!-- Inner page: matched by URL --}
{exp:channel:entries channel="default_page" url_title="{segment_1}" limit="1" require_entry="yes"}
{exp:page_craft:render entry_id="{entry_id}" field="page_builder" runtime="yes"}
{/exp:channel:entries}
{/if}
Why global_header keeps its wrapper
global_header / global_footer default to wrapper="yes", emitting a <div class="is-wrapper">. Do not set wrapper="no" on chrome that contains plugin sections (a nav menu, social links, etc.). The runtime's prepareContent() runs document.querySelector(".is-wrapper").classList.add(...) with no null guard — if the header boots the runtime but has no .is-wrapper, init throws and the entire page fails to render, main content included. The fixed-position styling lives on the outer .site-header div, so the inner .is-wrapper doesn't affect layout.
5. All custom — multiple PageCraft fields on one entry
If a channel has more than one PageCraft field (e.g. a page_header builder and a page_body builder), target each by name. render is per-field, so call it once per field.
{layout="global/_wrapper"}
{exp:channel:entries channel="landing" url_title="{segment_1}" limit="1" require_entry="yes"}
<header class="lp-hero">
{exp:page_craft:render entry_id="{entry_id}" field="page_header" runtime="yes"}
</header>
<main class="lp-body">
{exp:page_craft:render entry_id="{entry_id}" field="page_body" runtime="yes"}
</main>
{/exp:channel:entries}
The runtime and CSS enqueue is de-duplicated across calls, so multiple render tags on one page won't double-load assets.
Quick reference
| You want… | Use |
|---|---|
| Whole page, one tag, simplest | {exp:page_craft:render entry_id="{entry_id}" field="…"} |
| HTML only, manage CSS yourself | {your_field type="html"} or {exp:page_craft:html} |
Page CSS in <head> |
{your_field type="css"} + {your_field type="sectioncss"} |
| Sitewide header / footer | {exp:page_craft:global_header} / {exp:page_craft:global_footer} |
See Template Tags Reference for every parameter.
For live HTML examples of section authoring, open the annotated developer reference.