'
. esc_html__('The designer failed to load. Please reload the page.', 'aumframe')
. '
';
}
private static function enqueue(int $tpl_id, string $title, ?array $row = null): void
{
// Use WordPress's own media library rather than building an upload interface — the user's
// existing media, whatever image-optimisation plugin they installed and whatever CDN offload
// they configured all live in it, and starting over throws all of it away
wp_enqueue_media();
// A bundle, not ESM — one file, one URL, and a single `?ver=` invalidates all of it, with no
// bare submodules left to serve from cache (compare the trap in project docs 6.2.3)
wp_enqueue_script(
'aumframe-editor',
AUMFRAME_URL . 'assets/editor.js',
// wp-i18n: the editor interface is translated through WordPress's own runtime
// (`window.wp.i18n`); @wordpress/i18n is not bundled — bundling it creates a second, empty
// translation registry, so the interface stays English forever and nothing is reported.
// See packages/editor/src/i18n.js.
['wp-i18n'],
aumframe_asset_ver('assets/editor.js'),
true
);
// Feed languages/aumframe--aumframe-editor.json to wp.i18n.
// The third argument must be an absolute path: WP looks there for a JSON file named by
// md5(relative path).
wp_set_script_translations('aumframe-editor', 'aumframe', AUMFRAME_DIR . 'languages');
/*
* The only point at which another plugin can add to the editor.
*
* This screen takes the request over on `admin_init` at priority 999: it enqueues,
* prints its own shell and exits. It never reaches `admin_enqueue_scripts`, so an
* extension hooked there would never run on this screen, and the symptom is a script
* that is absent from the page with nothing logged.
*
* The hook has to fire after `aumframe-editor` is registered, so an extension can
* declare it as a dependency and rely on the execution order, and before
* `render_shell()` prints.
*/
do_action('aumframe/editor_enqueue');
wp_enqueue_style(
'aumframe-editor',
AUMFRAME_URL . 'assets/editor.css',
[],
aumframe_asset_ver('assets/editor.css')
);
$linkedPost = self::linked_post($row);
wp_add_inline_script(
'aumframe-editor',
'window.AUMFRAME_EDITOR = ' . wp_json_encode([
'tplId' => $tpl_id,
'title' => $title,
/*
* What saving here will change, in words.
*
* The buyer got here from one page's "Edit with Aumframe" and reasonably believes they are
* editing that page. One template routinely renders several — Cart, Checkout and My account
* shared one on a real site — so a save silently restyles pages they never opened. The
* editor says so before the first edit rather than after the complaint.
*/
'scope' => $row ? [
'shared' => Scope::is_shared($row),
'text' => Scope::describe($row),
] : null,
'nonce' => wp_create_nonce('wp_rest'),
'sceneUrl' => esc_url_raw(rest_url(Rest::NS . '/templates/' . $tpl_id)),
'saveUrl' => esc_url_raw(rest_url(Rest::NS . '/templates')),
'restBase' => esc_url_raw(rest_url(Rest::NS)),
/*
* Back returns to where they came from, not always to the template list.
*
* The buyer arrived by pressing "edit with Aumframe" in the Pages list, and pressing back
* would drop them into an "Aumframe templates" list they have never seen — a screen that
* means nothing to them (they have no concept of a "template"; they are editing **that
* page**). A design bound to content returns to that content type's list, and only an
* unbound one returns to the template list.
*/
'backUrl' => $linkedPost
? admin_url('edit.php?post_type=' . $linkedPost->post_type)
: admin_url('admin.php?page=aumframe'),
/*
* Which WP post this design is bound to.
*
* Without it, the editor can only ever "save", never "publish" — saving writes the
* template row, while the page `PageLink::handle_new()` creates is a `draft`. The buyer
* finishes designing, saves, sees nothing on the front end, and has no idea they need to
* go back to the Pages list and press publish: the flow simply breaks here.
* (Confirmed by testing on 2026-08-28.)
*/
'linkedPost' => $linkedPost ? [
'id' => $linkedPost->ID,
'title' => $linkedPost->post_title,
'slug' => $linkedPost->post_name,
'status' => $linkedPost->post_status,
'type' => $linkedPost->post_type,
'typeLabel' => ($o = get_post_type_object($linkedPost->post_type))
? $o->labels->singular_name : $linkedPost->post_type,
] : null,
// The front-end address: without it there is no way to "look at the real thing", which
// is the last check before publishing
'previewUrl' => esc_url_raw(self::preview_url($row)),
// The site palette the theme declares. swatches feeds the colour picker's dropdown and
// css is injected into the canvas iframe — the iframe has none of the theme's CSS, and
// without injecting it every colour on the canvas resolves to nothing.
'siteTokens' => SiteTokens::swatches(),
'siteCss' => SiteTokens::css(),
/*
* The theme's own inline CSS — what it prints into wp_head rather than links (the
* aumcreate theme's `:root{--container-width…}` block). The canvas already loads the
* front end's linked stylesheets (canvas_stylesheets) but had no way to get inline
* ones, so `var(--container-width)` in a kit's customCss resolved to nothing on the
* canvas and the whole declaration dropped (a full-bleed track sat 40px further left
* than on the front end — wysiwyg-audit 2026-09-13). Goes in before the links, in
* the front end's order. Themes append through this filter.
*/
'themeCss' => (string) apply_filters('aumframe/canvas_theme_css', ''),
// The canvas has to use the front end's appearance mode, or the front end is dark while
// the editor is light
'siteAppearance' => SiteTokens::appearance(),
// Which content types, taxonomies and terms actually exist on this site — the query
// panel's dropdowns run on it, and without it the user can only type `product_cat` from
// memory
'siteData' => SiteData::all(),
// Which forms the site has — the shortcode node's dropdown runs on it, so nobody has to
// go back to the admin to copy an ID
'siteForms' => SiteForms::all(),
// The component library, sent in one go — insertion is synchronous and cannot stop
// halfway to make a request
'library' => Library::all(),
'shell' => self::shell_for_canvas($row),
'stylesheets' => self::canvas_stylesheets(),
// The front end's runtime entry, for the canvas's live preview — same file, same version
'runtimeBoot' => add_query_arg('ver', aumframe_module_ver(['assets/runtime-boot.js', 'packages/runtime/src']), AUMFRAME_URL . 'assets/runtime-boot.js'),
// core has no URL here — it is already bundled into editor.js
]) . ';',
'before'
);
}
/**
* The site shell for the canvas: the two halves plus their CSS, or null when editing the shell
* itself or when the site has no shell.
*/
private static function shell_for_canvas(?array $row): ?array
{
if (!$row || ($row['kind'] ?? '') === 'shell') {
return null;
}
$shell = Router::shell();
if (!$shell) {
return null;
}
/*
* Render the shell against the same sample the canvas fills the page with (Rest::render_preview
* derives it from the template's rules). The admin request has no queried object, so until
* 2026-09-13 the header's "current item" branch never fired on the canvas: five plain nav links
* where the front end shows four plus the current one, the whole row 60–90px off
* (wysiwyg-audit, every page template). Same swap-and-restore as the preview endpoint.
*/
$rules = $row['rules'] ?? [];
if (is_string($rules)) {
$rules = json_decode($rules, true);
}
$sample = Rest::sample_query_args(is_array($rules) ? $rules : []);
$parts = Rest::with_sample_query($sample['args'], static fn () => Renderer::shell_parts());
if (!$parts) {
return null;
}
$css = (string) ($shell['baked_css'] ?? '');
if (preg_match_all('/