]*?)?/uploads/[^"\'\s\\\\<>]*#i'; /** @var array old URL => new URL */ private static $urlMap = []; /** @var array media id => new attachment ID */ private static $mediaMap = []; /** @var array "type:slug" => new post ID */ private static $postMap = []; /** @var string[] */ private static $warnings = []; /** * A field type table like `"machine.specs" => "group"`, built from the kit's own contentModel. * * Why it is needed: a repeater is stored in the database as a JSON string, not a PHP array * (`Aumframe\Model\Fields::resolve_group` is `json_decode($stored)`). Whoever writes a kit naturally writes * a list of rows — which is correct; the format should not leak storage details. So the translation * happens here: the model says it is a group, so it is encoded as JSON before writing. * * Without this layer, the meta is serialised by PHP, reads back as the string "Array", and the repeater * becomes an empty list — no error, and the spec table disappears entirely. * * @var array */ private static $fieldTypes = []; /** * @param string $json the kit's raw JSON text * @param array $opts mediaDir: the directory holding the media files (filename = media id); * siteIdentity: also overwrite the site name and tagline (for delivering on * someone's behalf; leave off when selling to a buyer); * dryRun: validate without writing * @return array the report */ public static function run(string $json, array $opts = []): array { self::$urlMap = self::$mediaMap = self::$postMap = []; self::$warnings = []; // No `true` — see the class comment; `{}` would become `[]` $kit = json_decode($json); if (!is_object($kit)) { return self::fail(__('That file is not a valid template kit.', 'aumframe')); } if ((int) ($kit->schemaVersion ?? 0) > Exporter::SCHEMA_VERSION) { return self::fail(sprintf( /* translators: 1: schemaVersion the kit file declares, 2: highest schemaVersion this plugin understands. */ __('This kit is schemaVersion %1$d, but this plugin only understands up to %2$d. Update Aumframe first.', 'aumframe'), (int) $kit->schemaVersion, Exporter::SCHEMA_VERSION )); } /* * An older package is upgraded to the current structure before anything else. * * Only "the package is newer than the engine" was guarded before (prompting an upgrade); the other * direction was empty — a schemaVersion 1 package meeting a v2 importer would be **read silently as * v2**: no error, no refusal, just a few sections quietly misread. And a package sold to a buyer sits * on their site unchanged forever, which makes that direction the genuinely dangerous one in the long * run. */ $kit = self::migrate($kit, (int) ($kit->schemaVersion ?? 1)); self::check_requires( $kit->requires ?? null, (string) ($kit->meta->slug ?? ''), (string) ($kit->meta->name ?? ''), (string) ($kit->meta->version ?? '') ); if (!empty($opts['dryRun'])) { return ['ok' => true, 'dryRun' => true, 'warnings' => self::$warnings]; } self::index_field_types($kit->contentModel ?? null); if (!empty($opts['componentsOnly'])) { $out = [ 'ok' => true, 'mode' => 'components-only', 'library' => self::library($kit->library ?? [], self::breakpoints_of($kit->templates ?? [])), 'warnings' => self::$warnings, ]; return $out; } $report = [ 'contentModel' => self::content_model($kit->contentModel ?? null), 'media' => self::media($kit->media ?? [], $opts), 'terms' => self::terms($kit->terms ?? []), 'posts' => self::posts($kit->posts ?? []), ]; $report['refs'] = self::rewrite_post_refs($kit->posts ?? []) + self::rewrite_term_refs($kit->terms ?? []); $report['library'] = self::library($kit->library ?? [], self::breakpoints_of($kit->templates ?? [])); $report['templates'] = self::templates($kit->templates ?? []); /* * A node whose identifier the compiler refuses gets no rules, and the page carries no * sign of it. Say so here, where there is somewhere to say it. */ $skipped = \Aumframe\Css\Compiler::skipped(); if ($skipped > 0) { self::warn(sprintf( /* translators: %d: number of nodes that were skipped. */ _n( '%d node was skipped because its identifier is not valid, so it has no styles.', '%d nodes were skipped because their identifiers are not valid, so they have no styles.', $skipped, 'aumframe' ), $skipped )); } $report['menus'] = self::menus($kit->menus ?? []); self::theme_settings($kit->themeSettings ?? null); self::options($kit->options ?? null, !empty($opts['siteIdentity'])); /* * Plugin settings — this plugin recognises no third-party plugin and simply passes this section * through for whoever recognises it to claim (structurally the same as * `aumframe/kit/post_imported`). * * Why it is needed: before this, a kit could not carry plugin settings at all and the `options` * section knew only a fixed handful. The consequences were concrete, and two of them turned up within * one day on 2026-08-22: * - NexCart attributes can only be bound after registering "filterable attributes" in its settings — * which the buyer will not do, so the spec table came out blank after import (later reworked so * the spec table does not depend on that setting) * - NexCart's taxonomy URL prefix defaults to `nexcart-category`, so **the plugin's name leaks into * the buyer's URLs** and no template can change it * Both are "set it once by hand on our own target site and it is invisible; the buyer installs and it * is wrong". * * Whoever claims it must do their own allow-listing and sanitising — this section is data from * a package, not trusted input. The plugin interprets none of it. */ if (isset($kit->pluginSettings) && is_object($kit->pluginSettings)) { do_action('aumframe/kit/plugin_settings', $kit->pluginSettings); } /* * A flush at this moment does nothing. * * A CPT's or taxonomy's rewrite slug is fixed when it registers on `init`, and * `woocommerce_permalinks` was only just written — what is registered in this request is still the * old slug, so flushing now would only rewrite the old rules. So a flag is set and **the flush happens * after the next init**. */ update_option('aumframe_kit_needs_flush', 1); $report['foreignUploads'] = self::audit_foreign_uploads(); $report['strayComponents'] = self::audit_stray_components($kit->library ?? []); return ['ok' => true] + $report + ['warnings' => self::$warnings]; } /* Prerequisites */ private static function check_requires($req, string $kitSlug = '', string $kitName = '', string $kitVersion = ''): void { if (!is_object($req)) { return; } if (!empty($req->theme) && get_option('stylesheet') !== $req->theme) { /* translators: 1: theme slug the kit asks for, 2: slug of the theme currently active. */ self::warn(sprintf(__('This kit requires the “%1$s” theme — the active one is “%2$s”.', 'aumframe'), $req->theme, get_option('stylesheet'))); } /* * `requires->plugins` is the required list, and it was never read. * * Only `optional` was ever walked, so a kit that says it needs a plugin imported in silence * without it. Measured 2026-09-05: the tindermoor shop kit names `aum-nexcart` in * `requires.plugins`; imported with that plugin switched off, the report said nothing about it * and instead carried 24 separate “taxonomy does not exist” warnings, one per category — * the symptom repeated 24 times and the cause stated nowhere. The buyer's shop had no * categories and no way to find out why. * * One warning naming the plugin is worth more than twenty-four naming its consequences. */ $installed = static function ($slug) { /* * Both spellings of the NexCart slug are accepted. The plugin was renamed * `aum-nexcart` → `aumnexcart` on 2026-09-02, before release, but kits built before that * date name the old one — dropping it would make those kits report a missing plugin that * is in fact installed, about a rename their author never saw. */ $aliases = ['aum-nexcart' => 'aumnexcart', 'aumnexcart' => 'aum-nexcart']; $names = array_filter([$slug, $aliases[$slug] ?? null]); /* * Three places a plugin can be switched on, and only the first was looked at. * * `active_plugins` is this one site's list. A network-activated plugin is not in it — * it lives in the network-wide `active_sitewide_plugins` — and a must-use plugin is in * neither. So the check has to ask all three, and the first two are facts while the third * is a stand-in. * * Found 2026-10-12, and the way it surfaced is worth keeping: the demo platform * deliberately activates NexCart and Reserva per site, and its own comment gives the * reason as "network activation makes this check report the plugin as missing". That reason * was right about the outcome and wrong about the scope — network activation only broke the * two plugins missing from the class map below. The map listed 3 of the 5 plugins kits * actually require (`aumviso` and `aumreserva` were absent), so for those two there was no * second chance. Reading the network list fixes all five at once and stops the map from * being the thing that decides. */ $lists = [(array) get_option('active_plugins', [])]; if (is_multisite()) { $lists[] = array_keys((array) get_site_option('active_sitewide_plugins', [])); } foreach ($lists as $list) { foreach ($list as $file) { foreach ($names as $name) { if (strpos((string) $file, $name . '/') === 0) { return true; } } } } /* * A must-use plugin is in neither list, so fall back to a class the plugin defines. * * This map is hand-maintained and therefore incomplete by default — it covers every * plugin that today's kits name in `requires.plugins`, and `tests/kit-requires-test.php` * fails if a kit ever names one that is missing here. Adding a plugin to a kit without * adding it here used to be silent: the kit reported "this plugin is not installed" on a * site where it was installed as a must-use plugin. */ $classes = [ 'woocommerce' => 'WooCommerce', 'aumnexcart' => 'AumNexCart_Meta', 'aum-nexcart' => 'AumNexCart_Meta', 'aumframe' => '\\Aumframe\\Plugin', 'aumviso' => 'AumViso', 'aumreserva' => 'AumReserva_Availability', ]; return isset($classes[$slug]) && class_exists($classes[$slug]); }; update_option('aumframe_kit_requires', [ 'kit' => $kitSlug, 'name' => (string) ($kitName ?? ''), 'version' => (string) ($kitVersion ?? ''), 'theme' => (string) ($req->theme ?? ''), 'plugins' => array_values(array_map('strval', (array) ($req->plugins ?? []))), 'optional' => array_values(array_map('strval', (array) ($req->optional ?? []))), 'at' => time(), ], false); foreach ((array) ($req->plugins ?? []) as $slug) { if (!$installed($slug)) { self::warn(sprintf( /* translators: %s: plugin slug the kit needs. */ __('This kit requires the “%s” plugin, which is not active here. Its content types, taxonomies and fields do not exist, so anything belonging to them is skipped — activate it and import again.', 'aumframe'), $slug )); } } foreach ((array) ($req->optional ?? []) as $slug) { if (!$installed($slug)) { /* translators: %s: name of a plugin the kit needs that is not installed on this site. */ self::warn(sprintf(__('This kit uses “%s”, which is not installed here — that content will import but will not display properly.', 'aumframe'), $slug)); } } } /** * The content model. Re-registered immediately after import, or neither the CPTs nor the taxonomies * exist within this request. */ private static function content_model($model): int { if (!is_object($model) && !is_array($model)) { return 0; } /* * `import(..., merge: true)` skips existing types rather than updating them. * * That is right on the buyer's side (a model they have edited must not be overwritten by a package), * and it has a consequence that is very hard to spot: **iterating on a kit, adding a field to an * existing type and reinstalling, the report says success and `contentModel=0` looks like "already * there" while the field never went in** — after which that section of the page binds to nothing, and * you check the binding, the layout and the query, and never suspect "the content model did not * update". * * The behaviour stays (overwriting someone's model is more dangerous), but it has to be loud: * reported only when the definitions genuinely differ, silent for a plain "already there". */ $before = \Aumframe\Model\Store::all(); $res = \Aumframe\Model\Store::import((string) wp_json_encode($model), true); if (empty($res['ok'])) { /* translators: %s: the reason the content-model import failed. */ self::warn(sprintf(__('Content model import failed: %s', 'aumframe'), $res['error'] ?? __('unknown reason', 'aumframe'))); return 0; } if ($before !== null) { self::warn_stale_types($model, $before); } \Aumframe\ContentTypes::register(); \Aumframe\Model\Registrar::register_all_fields(); return (int) ($res['added'] ?? 0); } /** * Complain when a type definition in the package differs from the one already on the site. * * Only the types this package brings are compared — extra types on the site are the buyer's own and * none of our business. */ private static function warn_stale_types($model, array $before): void { foreach (['postTypes' => __('Content type', 'aumframe'), 'taxonomies' => __('Taxonomy', 'aumframe')] as $bucket => $label) { $existing = []; foreach ((array) ($before[$bucket] ?? []) as $e) { if (!empty($e['name'])) { $existing[$e['name']] = $e; } } foreach ((array) ($model->$bucket ?? []) as $incoming) { $name = (string) ($incoming->name ?? ''); if ($name === '' || !isset($existing[$name])) { continue; // Newly added, and already in } /* * Both sides must pass through the store's sanitisation before comparison. * * The site stores the sanitised shape (defaults filled in, field order normalised), and * comparing against the kit's raw form makes every type "differ" — a completely * unmodified package would emit four warnings, and after crying wolf three times nobody reads * the real one. */ $clean = \Aumframe\Model\Store::sanitize_model([$bucket => [json_decode(wp_json_encode($incoming), true)]]); $a = wp_json_encode($clean[$bucket][0] ?? null); $b = wp_json_encode($existing[$name]); if ($a === $b) { continue; // Identical; nothing to say } self::warn( sprintf( /* translators: 1: what kind of thing it is (post type, taxonomy…), 2: its name. */ __('%1$s “%2$s” already exists here, so the definition in this kit was not applied — importing merges, it never overwrites. If you changed its fields, either import into an empty site or edit them in the admin.', 'aumframe'), $label, $name ) ); } } } /** Build the field type table from the kit's contentModel ("." => ""). */ private static function index_field_types($model): void { self::$fieldTypes = []; if (!is_object($model)) { return; } foreach ((array) ($model->postTypes ?? []) as $pt) { foreach ((array) ($pt->fields ?? []) as $f) { if (!empty($f->key) && !empty($f->type)) { self::$fieldTypes[($pt->name ?? '') . '.' . $f->key] = (string) $f->type; } } } foreach ((array) ($model->taxonomies ?? []) as $tx) { foreach ((array) ($tx->fields ?? []) as $f) { if (!empty($f->key) && !empty($f->type)) { self::$fieldTypes[($tx->name ?? '') . '.' . $f->key] = (string) $f->type; } } } } /** * What form a field value takes when written to meta. * * A repeater needs a JSON string; everything else goes in unchanged. **Do not use a heuristic like "encode * it if it is an array"** — array fields from other sources (ACF, a site mu-plugin) are meant to be * serialised by WordPress, and a blanket rule would break them. Go by what the model says, and nothing * else. */ private static function meta_value(string $owner, string $key, $val) { $type = self::$fieldTypes[$owner . '.' . $key] ?? ''; if ($type === 'group' && (is_array($val) || is_object($val))) { return wp_json_encode($val, JSON_UNESCAPED_UNICODE); } return $val; } /* Media */ /** * Write the files and build the two mapping tables. * * Where a file comes from, in order: `mediaDir/.` → the kit's `file` → downloading `url`. * A package that is sold uses the first two (the images are inside the zip); downloading is only a * convenience during our own development. */ private static function media($list, array $opts): int { require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; $n = 0; foreach ((array) $list as $m) { $id = (string) ($m->id ?? ''); if ($id === '') { continue; } $src = self::locate_media($m, $opts); if ($src === '') { /* translators: %s: id of the media item whose file is missing. */ self::warn(sprintf(__('Media “%s”: file missing, skipped — anything referencing it keeps the old address.', 'aumframe'), $id)); continue; } $att = self::sideload($src, $id, (string) ($m->alt ?? '')); if (!$att) { /* translators: %s: id of the media item that could not be saved. */ self::warn(sprintf(__('Media “%s” could not be saved.', 'aumframe'), $id)); continue; } self::$mediaMap[$id] = $att; if (!empty($m->url)) { self::$urlMap[(string) $m->url] = (string) wp_get_attachment_url($att); } $n++; } return $n; } private static function locate_media($m, array $opts): string { $id = (string) ($m->id ?? ''); if (!empty($opts['mediaDir'])) { foreach ((array) glob(rtrim($opts['mediaDir'], '/') . '/' . $id . '.*') as $hit) { return $hit; } } if (!empty($m->file) && !empty($opts['baseDir'])) { $p = rtrim($opts['baseDir'], '/') . '/' . ltrim((string) $m->file, '/'); if (is_readable($p)) { return $p; } } if (!empty($m->url)) { $tmp = download_url((string) $m->url); if (!is_wp_error($tmp)) { return $tmp; } /* translators: 1: id of the media item, 2: the download error. */ self::warn(sprintf(__('Media “%1$s” could not be downloaded: %2$s', 'aumframe'), $id, $tmp->get_error_message())); } return ''; } /** Idempotent: an attachment with the same name is reused rather than re-imported each time. */ private static function sideload(string $path, string $id, string $alt): int { $existing = get_posts([ 'post_type' => 'attachment', 'name' => $id, 'posts_per_page' => 1, 'post_status' => 'inherit', 'fields' => 'ids', ]); if ($existing) { return (int) $existing[0]; } $tmp = wp_tempnam($path); if (!$tmp || !@copy($path, $tmp)) { return 0; } $file = ['name' => $id . '.' . pathinfo($path, PATHINFO_EXTENSION), 'tmp_name' => $tmp]; $att = media_handle_sideload($file, 0, null, ['post_name' => $id]); if (is_wp_error($att)) { wp_delete_file($tmp); return 0; } if ($alt !== '') { update_post_meta((int) $att, '_wp_attachment_image_alt', $alt); } return (int) $att; } /* Terms */ /** Parent terms first — otherwise children cannot attach, everything ends up as siblings, and nothing is * reported. */ private static function terms($list): int { $rows = array_values((array) $list); usort($rows, fn($a, $b) => (($a->parent ?? '') === '' ? 0 : 1) <=> (($b->parent ?? '') === '' ? 0 : 1)); $n = 0; foreach ($rows as $t) { $tax = (string) ($t->taxonomy ?? ''); $slug = (string) ($t->slug ?? ''); if ($tax === '' || $slug === '' || !taxonomy_exists($tax)) { if ($tax !== '' && !taxonomy_exists($tax)) { /* translators: 1: taxonomy name that does not exist here, 2: slug of the term that was skipped. */ self::warn(sprintf(__('Taxonomy “%1$s” does not exist — “%2$s” was skipped.', 'aumframe'), $tax, $slug)); } continue; } $parent = 0; if (!empty($t->parent)) { $p = get_term_by('slug', (string) $t->parent, $tax); $parent = $p ? (int) $p->term_id : 0; } $existing = get_term_by('slug', $slug, $tax); if ($existing) { wp_update_term((int) $existing->term_id, $tax, [ 'name' => (string) ($t->name ?? $slug), 'description' => (string) ($t->description ?? ''), 'parent' => $parent, ]); $term_id = (int) $existing->term_id; } else { $res = wp_insert_term((string) ($t->name ?? $slug), $tax, [ 'slug' => $slug, 'description' => (string) ($t->description ?? ''), 'parent' => $parent, ]); if (is_wp_error($res)) { /* translators: 1: taxonomy name, 2: term slug, 3: the error WordPress returned. */ self::warn(sprintf(__('Could not create the term “%1$s/%2$s”: %3$s', 'aumframe'), $tax, $slug, $res->get_error_message())); continue; } $term_id = (int) $res['term_id']; } foreach ((array) ($t->meta ?? []) as $key => $val) { /* * Term fields pointing at content (`post_ref` on a taxonomy — "head of this practice area") * are deferred, exactly like post fields: terms are imported before any post exists, so * resolving `@post:` here would warn "no matching content" and leave the literal in the field. * `rewrite_term_refs()` fills them in after posts(). */ if (self::has_post_ref($val)) { continue; } $clean = self::meta_value($tax, (string) $key, self::resolve_value($val)); update_term_meta($term_id, (string) $key, wp_slash($clean)); } $n++; } return $n; } /** * `@post:slug` references inside term fields — second pass, after every post exists. * * 2026-09-12: a law-firm kit puts "head of practice" (`post_ref` → person) on the `expertise` * taxonomy. Terms import first, so on the first pass the person is not there yet; without this pass the * field silently held `@post:anna-marchbank` — filled in the admin, empty on the front end, and correct * or not depending on import order. */ private static function rewrite_term_refs($list): int { $n = 0; foreach ((array) $list as $t) { $tax = (string) ($t->taxonomy ?? ''); $slug = (string) ($t->slug ?? ''); if ($tax === '' || $slug === '' || !taxonomy_exists($tax)) { continue; } $term = get_term_by('slug', $slug, $tax); if (!$term) { continue; } foreach ((array) ($t->meta ?? []) as $key => $val) { if (!self::has_post_ref($val)) { continue; } $clean = self::meta_value($tax, (string) $key, self::resolve_value($val)); update_term_meta((int) $term->term_id, (string) $key, wp_slash($clean)); $n++; } } return $n; } /* Posts */ private static function posts($list): int { $rows = array_values((array) $list); // Parent pages first, or the hierarchy cannot attach usort($rows, fn($a, $b) => (($a->parent ?? '') === '' ? 0 : 1) <=> (($b->parent ?? '') === '' ? 0 : 1)); /* * Posts without a date get distinct, descending dates in kit order. * * Everything imported in one run used to land on the same second, and an archive ordered by * date then has nothing to order by: MySQL returns tied rows in whatever order the plan gives, * so the FAQ list, the glossary and the project grid came out in a different order on the * editor canvas than on the front end — and in a different order from one visit to the next * (wysiwyg-audit 2026-09-13, fulcrum / mach2). The kit lists items in the order the author * meant them to appear; the first is stamped newest (now), each next one a minute earlier. */ $base = current_time('timestamp'); $tick = 0; $n = 0; foreach ($rows as $p) { $type = (string) ($p->type ?? ''); $slug = (string) ($p->slug ?? ''); if ($type === '' || $slug === '') { continue; } $parent = 0; if (!empty($p->parent)) { $parent = self::$postMap[$type . ':' . $p->parent] ?? 0; } $data = [ 'post_type' => $type, 'post_name' => $slug, 'post_title' => (string) ($p->title ?? ''), 'post_content' => (string) ($p->content ?? ''), 'post_excerpt' => (string) ($p->excerpt ?? ''), 'post_status' => (string) ($p->status ?? 'publish'), 'menu_order' => (int) ($p->menuOrder ?? 0), 'post_parent' => $parent, ]; if (!empty($p->date)) { $data['post_date'] = (string) $p->date; $data['post_date_gmt'] = (string) ($p->dateGmt ?? ''); // Without this, WordPress treats post_date as a "draft placeholder time" and overwrites it $data['edit_date'] = true; } $existing = self::find_post($type, $slug); if ($existing) { $data['ID'] = $existing; $id = wp_update_post(wp_slash($data), true); } else { if (empty($p->date)) { // New rows only — a re-import must not move the dates of what the buyer already has /* * `gmdate()` not `date()`: WordPress sets PHP's default timezone to UTC * (wp-settings.php), so the two are identical here — a rename, not a change. * * An earlier version of this comment claimed `post_date` therefore carries a * UTC clock and that imported dates land off by the site offset. **That was * wrong, and a comment asserting a defect that is not there is worse than no * comment: it sends the next reader to "fix" correct code.** `$base` comes from * `current_time('timestamp')`, which is already shifted by the site offset, so * formatting it with `gmdate()` yields the site-local wall clock — exactly * what `post_date` is supposed to hold, and what `get_gmt_from_date()` on the * next line expects to read. * * Measured 2026-10-06 against a live site on three zones: * UTC gmdate($base) = site wall clock = UTC wall clock * Pacific/Auckland gmdate($base) = 2026-10-07 04:46:57 = site wall clock * (UTC wall clock was 2026-10-06 15:46:57) * America/New_York gmdate($base) = 2026-10-06 11:46:59 = site wall clock * `tools/time-basis-check.mjs` knows about this: a `date()`/`gmdate()` whose * timestamp comes from `current_time()` is already on the site's basis. */ $data['post_date'] = gmdate('Y-m-d H:i:s', $base - 60 * $tick++); $data['post_date_gmt'] = get_gmt_from_date($data['post_date']); $data['edit_date'] = true; } $id = wp_insert_post(wp_slash($data), true); } if (is_wp_error($id)) { /* translators: 1: post type, 2: slug of the item, 3: the error WordPress returned. */ self::warn(sprintf(__('Could not save “%1$s/%2$s”: %3$s', 'aumframe'), $type, $slug, $id->get_error_message())); continue; } $id = (int) $id; self::$postMap[$type . ':' . $slug] = $id; // post meta must be wp_slash'd — add/update_post_meta unslashes once. // Without it, backslashes and quotes are eaten, and fields storing JSON suffer first. foreach ((array) ($p->meta ?? []) as $key => $val) { /* * Fields pointing at other content (`post_ref`, whose value is `@post:slug`) are **deferred * entirely to a second pass**. * * `$postMap` is filled as the import proceeds: when a project references a colour, that * colour may not be created yet. Resolving here would find nothing — leaving the literal * `@post:vena-carrara` in the field, a section of the front end that never resolves, and only * one easily overlooked warning in the report. * * Not writing and not resolving here is precisely so that no false warning is produced — * enough false warnings and nobody reads the real ones. */ if (self::has_post_ref($val)) { continue; } $clean = self::meta_value($type, (string) $key, self::resolve_value($val)); update_post_meta($id, (string) $key, wp_slash($clean)); } if (!empty($p->thumbnail)) { // Do not use ltrim to strip the prefix — it removes by character set, so `@media:` would // also eat a leading a/e/i/m/d from the id that follows $att = self::$mediaMap[self::media_id_of((string) $p->thumbnail)] ?? 0; if ($att) { set_post_thumbnail($id, $att); } } foreach ((array) ($p->terms ?? []) as $tax => $slugs) { if (taxonomy_exists((string) $tax)) { wp_set_object_terms($id, array_map('strval', (array) $slugs), (string) $tax, false); } } /* * Hand off the parts of this post the engine does not understand. * * Why a hook rather than recognising Woo here: this importer is generic — post, meta and * terms, which hold for any commerce engine. Something like a variable product exists only in * Woo: it needs a `product_type` term, `pa_*` attribute taxonomies, `_product_attributes` and a * set of `product_variation` child posts. Writing those in would weld the plugin to one commerce * engine and make the `catalog` adapter layer (in the theme, already with Woo, NexCart and CPT * implementations) pointless. * * So: the plugin passes `$p` through untouched and whoever recognises it handles it. The theme's * Woo adapter hooks exactly this. * * Origin: rebuilding ebike (2026-08-22). Before it, a kit could not express a variable product at * all — with no error, six bikes imported as simple products with no size, no colour, and an add * to cart button that bounced back to the same page. */ do_action('aumframe/kit/post_imported', $id, $p, $type); $n++; } return $n; } /** * A post by slug, any type: this run's map first (a kit's own posts), then the database * (content already on the site — a templates-only re-import, or a form the buyer created). * Not `post_type => 'any'`: that skips types with `exclude_from_search` — the form type among * them — and `[aumframe_form id="@post:careers-enquiry"]` stayed literal (2026-09-13). */ private static function post_by_slug(string $slug): int { foreach (self::$postMap as $key => $id) { if (substr($key, strpos($key, ':') + 1) === $slug) { return (int) $id; } } /* WordPress's own bookkeeping types carry slugs too (a menu item titled "Studio" is `studio`) — * a reference can never mean one of those, so keep them out of the any-type lookup. */ $types = array_diff(array_values(get_post_types([], 'names')), self::INTERNAL_TYPES); $hit = get_posts([ 'post_type' => $types, 'name' => $slug, 'posts_per_page' => 1, 'post_status' => 'any', 'fields' => 'ids', ]); return $hit ? (int) $hit[0] : 0; } /** Post types that are WordPress plumbing, never content a kit could point at. */ private const INTERNAL_TYPES = [ 'nav_menu_item', 'revision', 'attachment', 'custom_css', 'customize_changeset', 'oembed_cache', 'user_request', 'wp_block', 'wp_template', 'wp_template_part', 'wp_global_styles', 'wp_navigation', 'wp_font_family', 'wp_font_face', 'product_variation', 'shop_order', 'shop_order_placehold', ]; private static function find_post(string $type, string $slug): int { $hit = get_posts([ 'post_type' => $type, 'name' => $slug, 'posts_per_page' => 1, 'post_status' => 'any', 'fields' => 'ids', ]); return $hit ? (int) $hit[0] : 0; } /** * Upgrade a kit with an older structure to the current `SCHEMA_VERSION`. * * # Rules for adding a migration * * 1. Bump `Exporter::SCHEMA_VERSION` by one * 2. Add `case : $kit = self::migrate__to_($kit);` below * 3. Do not write `break` — the cases must fall through: a v1 package has to run 1→2 and then * 2→3. With a break, a v1 package is upgraded to v2 and then used as if it were v3. * 4. `tools/compat-test.mjs` checks that "every fixture version below the current one has a matching * migration", so forgetting turns it red. * * # Why migrations live in the importer rather than "just export a new package" * * A package that has been sold is in the buyer's hands and beyond our reach. Reinstalling it a year * later, they still use that old package. * * @param object $kit the decoded kit (an object, not an array — see the class comment) * @param int $from the schemaVersion the package claims */ private static function migrate(object $kit, int $from): object { if ($from >= Exporter::SCHEMA_VERSION) { return $kit; } switch ($from) { /* * Add a step here when the structure changes, ordered oldest to newest, with no break. * For example: * case 1: * $kit = self::migrate_1_to_2($kit); * // deliberately no break — carry on into 2→3 * case 2: * $kit = self::migrate_2_to_3($kit); */ default: /* * No matching migration. Say so rather than carrying on regardless — silently reading an * old package as if it were the new version is exactly what this machinery exists to prevent. */ self::warn(sprintf( /* translators: 1: schemaVersion of the kit, 2: schemaVersion of this plugin. */ __('This kit is schemaVersion %1$d and the plugin is %2$d, with no migration in between — a few sections may be read with the wrong structure. Ask the template author for an updated kit.', 'aumframe'), $from, Exporter::SCHEMA_VERSION )); } $kit->schemaVersion = Exporter::SCHEMA_VERSION; return $kit; } /** * `@post:slug` references inside post content. * * This has to wait until every post is created — the `[aumframe_form id="@post:xxx"]` in a contact page's * content may point at a form that comes after it. */ private static function rewrite_post_refs($list): int { $n = 0; foreach ((array) $list as $p) { $type = (string) ($p->type ?? ''); $content = (string) ($p->content ?? ''); $id = self::$postMap[$type . ':' . ($p->slug ?? '')] ?? 0; if (!$id) { continue; } if (strpos($content, '@post:') !== false) { wp_update_post(wp_slash(['ID' => $id, 'post_content' => self::resolve_refs($content)])); $n++; } /* * `@post:` references inside fields — the first pass deliberately skipped them (see posts()). * * Relational fields like "which colours this project used" (including `post_ref` inside a * repeater) can only resolve correctly once all content exists. Without this pass, the field holds * the literal `@post:vena-carrara`: it looks filled in the admin while that section of the front * end is empty — and it works or not depending on the order, which is the hardest kind of * problem to trace. */ foreach ((array) ($p->meta ?? []) as $key => $val) { if (!self::has_post_ref($val)) { continue; } $clean = self::meta_value($type, (string) $key, self::resolve_value($val)); update_post_meta($id, (string) $key, wp_slash($clean)); $n++; } } return $n; } /** Does this value (possibly a nested structure like a repeater) contain a `@post:` reference? */ private static function has_post_ref($val): bool { if (is_string($val)) { return strpos($val, '@post:') !== false; } if (is_array($val) || is_object($val)) { foreach ((array) $val as $v) { if (self::has_post_ref($v)) { return true; } } } return false; } /* Component library and templates */ /** * The breakpoints a kit's components were authored against. * * A component definition does not carry them, and they decide which media queries its * stylesheet gets, so they are read from a template in the same kit. A kit with no usable * template falls back to the single base breakpoint. * * @return array */ private static function breakpoints_of($templates): array { foreach ((array) $templates as $t) { $scene = json_decode(self::json_text($t->scene ?? null), true); if (is_array($scene) && !empty($scene['breakpoints'])) { return \Aumframe\Css\Scene::breakpoints($scene['breakpoints']); } } return \Aumframe\Css\Scene::DEFAULT_BREAKPOINTS; } private static function library($list, array $breakpoints): int { $n = 0; foreach ((array) $list as $c) { if (empty($c->slug)) { continue; } /* * A master the buyer edited in the editor (`userEdited` in its def) is theirs now: a kit * update must not silently put our version back. Skipped with a warning naming it, so the * buyer can detach or re-import knowingly. */ $current = Library::find((string) $c->slug); if ($current && !empty(json_decode((string) $current['def'], true)['userEdited'])) { /* translators: %s: slug of the component that was edited on this site. */ self::warn(sprintf(__('Component “%s” was edited on this site, so the kit’s version was not applied.', 'aumframe'), (string) $c->slug)); continue; } Library::save([ 'slug' => (string) $c->slug, 'name' => (string) ($c->name ?? $c->slug), 'category' => (string) ($c->category ?? 'general'), // Pass a JSON string rather than an array, so `{}` stays an object. Refs are resolved like a // template's scene: since the library is the editor's source of truth (1.7.65), a // `[aumframe_form id="@post:…"]` left in the def would reach the canvas — and the page — unresolved. 'def' => self::resolve_refs(self::json_text($c->def ?? null)), 'baked_html' => self::resolve_component_payloads(self::resolve_refs((string) ($c->bakedHtml ?? ''))), /* * The stylesheet is generated from the definition rather than read out of the * kit, so an imported file cannot contribute CSS of its own. Reference * rewriting has already been applied to the definition, so the addresses the * compiler emits are this site's. */ 'baked_css' => \Aumframe\Baked::css_from_component( json_decode(self::resolve_refs(self::json_text($c->def ?? null)), true), $breakpoints ), ]); $n++; } return $n; } /** * Templates. * * The baked output is taken as-is; PHP never re-bakes (nor could it: the compiler is client-side * only). That output contains the old site's image addresses too, so it goes through the same reference * rewriting as the scenes. */ private static function templates($list): int { $n = 0; foreach ((array) $list as $t) { if (empty($t->slug)) { continue; } $scene_json = self::resolve_refs(self::json_text($t->scene ?? null)); Storage::save([ 'slug' => (string) $t->slug, 'title' => (string) ($t->title ?? $t->slug), 'kind' => (string) ($t->kind ?? 'page'), 'rules' => self::json_text(self::rules($t->rules ?? null)), 'priority' => (int) ($t->priority ?? 10), 'status' => (string) ($t->status ?? 'publish'), 'scene' => $scene_json, 'baked_html' => self::resolve_component_payloads(self::resolve_refs((string) ($t->bakedHtml ?? ''))), /* Generated from the scene, for the reason given in library(). */ 'baked_css' => \Aumframe\Baked::css_from_scene(json_decode($scene_json, true)), ]); $n++; } return $n; } /** Turn slugs in the routing rules back into this site's auto-increment IDs. An empty array is preserved * verbatim (see Exporter::rules). */ private static function rules($rules) { if (!is_object($rules)) { return $rules; } if (isset($rules->postSlugs)) { $ids = []; foreach ((array) $rules->postSlugs as $slug) { /* * The rule names its post type — look there first. `post_by_slug()` is any-type, and on a * templates-only re-import (no posts in the run, so no map) it fell through to the database, * where the menu item "Studio" (nav_menu_item, slug `studio`, created after the page) * outranked the page: `/studio/` silently dropped to the singular fallback (2026-09-14, #8). */ $id = !empty($rules->post_type) ? self::find_post((string) $rules->post_type, (string) $slug) : 0; if (!$id) { $id = self::post_by_slug((string) $slug); } if ($id) { $ids[] = $id; } } if (count($ids) !== count((array) $rules->postSlugs)) { self::warn(__('Some routing rules point to pages that were not found — those templates may never be used.', 'aumframe')); } unset($rules->postSlugs); $rules->post_ids = $ids; } if (isset($rules->termSlugs)) { $ids = []; foreach ((array) $rules->termSlugs as $ref) { $t = get_term_by('slug', (string) ($ref->slug ?? ''), (string) ($ref->taxonomy ?? '')); if ($t) { $ids[] = (int) $t->term_id; } } unset($rules->termSlugs); $rules->term_ids = $ids; } return $rules; } /* Menus and site options */ private static function menus($list): int { $n = 0; foreach ((array) $list as $m) { $slug = (string) ($m->slug ?? ''); if ($slug === '') { continue; } $menu = wp_get_nav_menu_object($slug); $menu_id = $menu ? (int) $menu->term_id : (int) wp_create_nav_menu((string) ($m->name ?? $slug)); if (!$menu_id || is_wp_error($menu_id)) { continue; } // Cleared before reinstalling — otherwise the menu items double with every import foreach ((array) wp_get_nav_menu_items($menu_id) as $old) { wp_delete_post((int) $old->ID, true); } $idMap = []; foreach ((array) ($m->items ?? []) as $it) { $args = [ 'menu-item-title' => (string) ($it->title ?? ''), 'menu-item-status' => 'publish', 'menu-item-position'=> (int) ($it->order ?? 0), 'menu-item-parent-id' => $idMap[(int) ($it->parent ?? 0)] ?? 0, ]; $type = (string) ($it->type ?? 'custom'); if ($type === 'custom') { $args['menu-item-type'] = 'custom'; $args['menu-item-url'] = (string) ($it->url ?? ''); } elseif ($type === 'post_type') { $oid = self::$postMap[($it->object ?? '') . ':' . ($it->objectSlug ?? '')] ?? 0; /* translators: %s: title of the menu item whose target is missing. */ if (!$oid) { self::warn(sprintf(__('Menu item “%s” points to content that was not found — skipped.', 'aumframe'), $args['menu-item-title'])); continue; } $args['menu-item-type'] = 'post_type'; $args['menu-item-object'] = (string) $it->object; $args['menu-item-object-id'] = $oid; } elseif ($type === 'post_type_archive') { /* * A content type's archive (`/finishes/`, `/products/`, `/bikes/`). * * Without this branch such an item could only be `custom` — i.e. a hard-coded path, * which is the one thing kits are not allowed to ship: the buyer changes the type's * rewrite slug in the content model and every nav item pointing at it 404s, silently. * Core has supported this menu item type all along; the importer simply never mapped it. * * The type has to exist and have an archive. A kit naming a type the target site * does not register (the buyer did not install that engine) skips the item with a * warning rather than leaving a link to nowhere in the site header. */ $pt = (string) ($it->object ?? ''); $obj = $pt !== '' ? get_post_type_object($pt) : null; if (!$obj || !$obj->has_archive) { self::warn(sprintf( /* translators: %s: menu item title */ __('Menu item “%s” points to a content type archive that does not exist here — skipped.', 'aumframe'), $args['menu-item-title'] )); continue; } $args['menu-item-type'] = 'post_type_archive'; $args['menu-item-object'] = $pt; } else { $t = get_term_by('slug', (string) ($it->objectSlug ?? ''), (string) ($it->object ?? '')); /* translators: %s: title of the menu item whose term is missing. */ if (!$t) { self::warn(sprintf(__('Menu item “%s” points to a term that was not found — skipped.', 'aumframe'), $args['menu-item-title'])); continue; } $args['menu-item-type'] = 'taxonomy'; $args['menu-item-object'] = (string) $it->object; $args['menu-item-object-id'] = (int) $t->term_id; } $new = wp_update_nav_menu_item($menu_id, 0, $args); if (!is_wp_error($new)) { $idMap[(int) ($it->id ?? 0)] = (int) $new; } } if (!empty($m->locations)) { $loc = get_nav_menu_locations(); foreach ((array) $m->locations as $l) { $loc[(string) $l] = $menu_id; } set_theme_mod('nav_menu_locations', $loc); } $n++; } return $n; } /** * Theme settings — the kit decides, and overwrites what the site has. * * "Only fill in missing keys, so the buyer's configuration is not disturbed" was considered and is * overthinking it: the buyer pressed "import kit" precisely to get that kit's appearance. Half the old * palette left behind produces a site that is neither one thing nor the other, which is far worse than * overwriting. (Anyone genuinely worried can export their own package before importing.) * * No `wp_slash` here. Unlike post meta, `update_option` does not unslash, so slashing would store * the backslashes literally. The two APIs behave differently, and an earlier project hit that trap. */ private static function theme_settings($settings): void { if (!is_object($settings) && !is_array($settings)) { return; } $json = self::resolve_refs(self::json_text($settings)); $arr = json_decode($json, true); if (is_array($arr)) { update_option('aumcreate_settings', array_merge((array) get_option('aumcreate_settings', []), $arr)); } } private static function options($opts, bool $siteIdentity = false): void { if (!is_object($opts)) { return; } // The site name and tagline are touched only when explicitly requested — the buyer's site may // already be live if ($siteIdentity) { foreach (['blogname', 'blogdescription'] as $key) { if (isset($opts->$key)) { update_option($key, (string) $opts->$key); } } } /* * These two must warn when the page cannot be found. * * It used to be a bare `if ($id) { … }`: with `page_for_posts: 'news'` in the kit and no such page in * posts, the import report was entirely green while `/news/` 404'd on the site — and `/news/` is a * link in the header and the footer, meaning a dead link on every single page. The neighbouring * `pageRefs` already warns for the same situation, so staying silent here was inconsistent. * (Found while rebuilding ebike on 2026-08-22, and only by check-links.) */ if (!empty($opts->page_on_front)) { $id = self::$postMap['page:' . $opts->page_on_front] ?? 0; if ($id) { update_option('show_on_front', (string) ($opts->show_on_front ?: 'page')); update_option('page_on_front', $id); } else { /* translators: %s: slug the front-page setting points at. */ self::warn(sprintf(__('The front-page setting points to “%s”, which is not in this kit — the front page was left unset.', 'aumframe'), $opts->page_on_front)); } } if (!empty($opts->page_for_posts)) { $id = self::$postMap['page:' . $opts->page_for_posts] ?? 0; if ($id) { update_option('page_for_posts', $id); } else { /* translators: %s: slug the posts-page setting points at. */ self::warn(sprintf(__('The posts-page setting points to “%s”, which is not in this kit — the blog archive will 404.', 'aumframe'), $opts->page_for_posts)); } } // Address structure. No wp_slash (update_option does not unslash). foreach (['permalink_structure', 'category_base', 'tag_base'] as $key) { if (isset($opts->$key)) { update_option($key, (string) $opts->$key); } } if (isset($opts->woocommerce_permalinks)) { update_option('woocommerce_permalinks', json_decode(wp_json_encode($opts->woocommerce_permalinks), true)); } // Plugin settings holding a page ID: mapped back from slug to this site's ID foreach ((array) ($opts->pageRefs ?? []) as $option => $slug) { $id = self::$postMap['page:' . $slug] ?? 0; if ($id) { update_option((string) $option, $id); } else { /* translators: 1: name of the option, 2: slug of the page it points at. */ self::warn(sprintf(__('Setting “%1$s” points to the page “%2$s”, which was not found.', 'aumframe'), $option, $slug)); } } } /* Reference rewriting */ /** * Rewrite all three kinds of cross-site reference in a block of text at once: * old-site image URLs, `@media:id` and `@post:slug`. * * Done on text rather than on structures — a reference can appear in a scene, in baked output, in post * content, inside customCss's `url(...)` or in theme settings, and hunting structure by structure is * certain to miss one. */ /** * @param string $html * @return string */ private static function resolve_component_payloads(string $html): string { if (strpos($html, '/', static function ($m) { $json = base64_decode($m[2], true); if ($json === false || $json === '') { return $m[0]; } $fixed = self::resolve_refs($json); if ($fixed === $json) { return $m[0]; } return ''; }, $html ); } private static function resolve_refs(string $text): string { if ($text === '') { return $text; } // Shortcodes in baked output are base64, which the plain-text rules cannot see — so they are // rewritten inside first. Skipping this step shows up as the scene's ID being correct while the front // end renders a form that does not exist (blank, with no error). $text = Refs::map_markers($text, fn($code) => self::resolve_plain($code)); return self::resolve_plain($text); } /** The three kinds of reference in plain text: old image URLs, `@media:id` and `@post:slug`. */ private static function resolve_plain(string $text): string { foreach (self::$urlMap as $old => $new) { $text = str_replace($old, $new, $text); // Slashes are escaped inside JSON, so that form has to be rewritten too $text = str_replace(str_replace('/', '\\/', $old), str_replace('/', '\\/', $new), $text); } /* * Two kinds of media reference, deliberately separate: * * - `@mediaurl:` → an image address. An `Image` node's `props.src` in a layout is a URL * string, and putting an attachment ID there is a broken image. * - `@media:` → an attachment ID. The content model's image and file fields store exactly * that. * * Only the latter existed at first, so "images in scenes" and "images in fields" could only be right * one at a time. Guessing which to produce from context does not work — both can appear in one block * of text. * * mediaurl must be replaced first: `@media:` is a prefix of `@mediaurl:`, and the wrong order * chews `@mediaurl:x` down to `url:x`. */ $text = preg_replace_callback('/@mediaurl:([a-z0-9\-]+)/i', function ($m) { $att = self::$mediaMap[$m[1]] ?? 0; if (!$att) { /* translators: %s: the media id used in an @mediaurl: reference. */ self::warn(sprintf(__('Reference @mediaurl:%s has no matching media.', 'aumframe'), $m[1])); return $m[0]; } return (string) wp_get_attachment_url($att); }, $text) ?? $text; $text = preg_replace_callback('/@media:([a-z0-9\-]+)/i', function ($m) { $att = self::$mediaMap[$m[1]] ?? 0; return $att ? (string) $att : $m[0]; }, $text) ?? $text; /* * `@term::` → this site's term_id, used by the `link.term.` binding. * * It queries the database rather than a mapping table: terms are imported before posts, so by the * time this runs they exist. And a term may already exist on the buyer's site (import is a merge), in * which case the mapping table would not contain it at all. * * When it cannot be resolved it is left as-is with a warning — consistent with `@post:`: leaving an * unresolvable expression means the front end keeps the bake-time placeholder address, which beats * emitting an empty href. */ $text = preg_replace_callback('/@term:([a-z0-9_-]+):([^\s"\'\\\\\]#]+)/i', function ($m) { $t = get_term_by('slug', $m[2], $m[1]); if ($t && !is_wp_error($t)) { return (string) $t->term_id; } /* translators: 1: taxonomy, 2: term slug, both from an @term: reference. */ self::warn(sprintf(__('Reference @term:%1$s:%2$s has no matching term — left as is.', 'aumframe'), $m[1], $m[2])); return $m[0]; }, $text) ?? $text; // The `#` in the character class was added later: link bindings support an anchor suffix // (`link.@post:specify#downloads`), and without excluding it the slug is looked up as // `specify#downloads`, matches nothing, and the only symptom is "that link did not bind". $text = preg_replace_callback('/@post:([^\s"\'\\\\\]#]+)/', function ($m) { // Try both the raw and the decoded form. A slug generated from a non-Latin title is // percent-encoded in the database (`%e8%af%a2…`), so comparing only against the decoded string // matches nothing — and the only symptom is "the form disappeared", with no error. $candidates = array_unique([$m[1], rawurldecode($m[1])]); /* * A slug shared by two types in one kit (a page `contact` and a form `contact`, template #13) * makes `@post:contact` ambiguous — the first entry wins and `[aumframe_form id=…]` ends up pointing at * the page: the form renders empty on the front end, nothing is reported. Say so. (`lint-kit` * refuses such a kit at build time; this is the buyer-site side of the same guard.) */ $hits = []; foreach (self::$postMap as $key => $id) { $slug = substr($key, strpos($key, ':') + 1); if (in_array($slug, $candidates, true)) { $hits[$key] = (int) $id; } } if ($hits) { if (count($hits) > 1) { /* translators: 1: slug from an @post: reference, 2: comma-separated ids it matched. */ self::warn(sprintf(__('Reference @post:%1$s is ambiguous — it matches %2$s; the first was used.', 'aumframe'), $m[1], implode(', ', array_keys($hits)))); } return (string) reset($hits); } /* * Not in this run's map: ask the database, as `@term:` always has. A kit imported * with its posts stripped (templates-only — the "send a layout fix to a site that already * has its content" path, 2026-09-13) has every `link.@post:about` in the shell pointing at * content that exists on the site but was never mapped, and the only symptom was eight * "no matching content" warnings and a header full of bake-time placeholder links. */ foreach ($candidates as $slug) { $id = self::post_by_slug($slug); if ($id) { return (string) $id; } } /* translators: %s: slug from an @post: reference that matched nothing. */ self::warn(sprintf(__('Reference @post:%s has no matching content — left as is.', 'aumframe'), $m[1])); return $m[0]; }, $text) ?? $text; return $text; } /** Replace `@media:id` inside field values with the new attachment ID (the content model's image fields * store an ID). */ private static function resolve_value($val) { if (is_array($val) || is_object($val)) { $out = []; foreach ((array) $val as $k => $v) { $out[$k] = self::resolve_value($v); } return $out; } if (is_string($val)) { $id = self::media_id_of($val); if ($id !== '' && isset(self::$mediaMap[$id])) { return (string) self::$mediaMap[$id]; } return self::resolve_refs($val); } return $val; } private static function media_id_of(string $ref): string { return strpos($ref, '@media:') === 0 ? substr($ref, strlen('@media:')) : ''; } /* Miscellaneous */ /** Structure → JSON text. Objects stay objects (never round-trip through an associative array). */ private static function json_text($v): string { if (is_string($v)) { return $v; } return $v === null ? '{}' : (string) wp_json_encode($v, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } /** * @return array{scanned:int,foreign:int,hosts:array,samples:array} */ /** * @return array{scanned:int,collapsed:int,slugs:string[],instances:int} */ public static function audit_collapsed_instances(): array { global $wpdb; $table = \Aumframe\Storage::table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- A table owned by this plugin; the table name is a class constant and carries no external input. A one-off audit query, with no cache layer. $rows = $wpdb->get_results("SELECT slug, scene, baked_html FROM {$table}", ARRAY_A); $scanned = 0; $instances = 0; $slugs = []; foreach ((array) $rows as $row) { $scanned++; $scene = json_decode((string) ($row['scene'] ?? ''), true); $html = (string) ($row['baked_html'] ?? ''); if (!is_array($scene) || !isset($scene['nodes']) || !is_array($scene['nodes'])) { continue; } $hit = 0; foreach ($scene['nodes'] as $node) { if (!is_array($node) || ($node['type'] ?? '') !== 'Instance') { continue; } $ov = $node['props']['overrides'] ?? null; if (!is_array($ov) || $ov === []) { continue; // No per-node override: the placeholder is correct for it. } $cid = (string) ($node['props']['componentId'] ?? ''); if ($cid === '') { continue; } if (strpos($html, '