original URL` along the way. * * A reverse lookup will not do. The first version took the id (the filename) and searched by * attachment slug — and an attachment's slug comes from its title, not its filename, so a miss meant * one image silently absent: one fewer entry in the media section, one fewer image for the * importer, one broken image on the front end, and nothing reported anywhere. That is how 12 images on * the ebike site turned into 5. * * @var array */ private static $registry = []; /** * @param array $meta name/slug/version/siteType/industry — supplied by the caller, because these * describe "what this kit is" and the site has nowhere to store them */ public static function build(array $meta = []): array { self::$registry = []; $kit = [ 'schemaVersion' => self::SCHEMA_VERSION, 'meta' => self::meta($meta), 'requires' => self::requires(), 'themeSettings' => self::theme_settings(), 'contentModel' => self::content_model(), // There is no forms section: a form itself is the `aumframe_form` CPT and is already in posts. // `SiteForms::all()` is the runtime list the editor picks from (ids and titles only), and // exporting it would export a set of auto-increment IDs that stop meaning anything on another // site. 'library' => self::library(), 'templates' => self::templates(), 'terms' => self::terms(), 'posts' => self::posts(), 'menus' => self::menus(), 'options' => self::options(), ]; // Auto-increment references become slug references. It walks the whole kit, not just the // scenes — the same shortcode also appears in post content (the ebike site's contact page does), // and handling scenes alone would carry that copy across with the old ID intact. $kit = self::deref_all($kit); // Media is collected last: it scans the content of every section before it $kit['media'] = self::media($kit); return $kit; } /* Sections */ /** * Theme settings. Only the ones belonging to this kit are taken. * * The classification comes from the theme (`aumframe/kit/theme_setting_scope`) — only the place * that defines those fields knows what each is for. The plugin does not interpret, only execute. * * An allow-list (`template`), never a deny-list. The failure modes are asymmetric: * a gap in an allow-list means the kit is missing a setting — visible, and fixable; * a gap in a deny-list means the author's private data reaches the buyer's site — invisible, and * unrecoverable. Just how invisible: `cc_analytics_script` is a script injected into every page, so the * author's GA or Pixel would travel with every kit sold and report from every buyer's site. * * Before 2026-08-28 this was `get_option(...)` taken wholesale. The target site showed no problem (its * database had been cleaned and the option held only 22 keys) while a properly configured site has 68. * What that online test exposed was the four logo keys carrying `newebike.local` into the package → * four dead images in the buyer's header; things like `cc_analytics_script` merely happened not to * be in that site's option. * * When the theme provides no classification (a plugin newer than the theme), it falls back to a * built-in conservative allow-list containing only what is unquestionably safe — better a kit missing * settings than a gamble on which key is safe. */ private static function theme_settings(): array { $settings = get_option('aumcreate_settings', []); if (!is_array($settings)) { return []; } $scope = apply_filters('aumframe/kit/theme_setting_scope', null); if (!is_array($scope) || empty($scope['template']) || !is_array($scope['template'])) { /* * The theme did not attach this filter — most likely the plugin is newer than the theme. * It must not fall back to exporting everything, which is the behaviour being fixed here. */ if (defined('WP_DEBUG') && WP_DEBUG) { error_log('[aumframe] The theme does not provide aumframe/kit/theme_setting_scope — ' . 'exporting only the built-in conservative whitelist of theme settings. Update the theme.'); } $allow = [ 'light_primary', 'light_secondary', 'light_bg', 'light_scrollbar', 'light_card_bg', 'light_border', 'light_text_muted', 'dark_primary', 'dark_secondary', 'dark_bg', 'dark_scrollbar', 'dark_card_bg', 'dark_border', 'dark_text_muted', 'custom_colors', 'theme_mode', 'show_theme_toggle', 'day_night_switch', 'container_width', 'woo_container_width', 'header_height', ]; $known = $allow; } else { $allow = $scope['template']; $known = array_merge( $scope['template'], is_array($scope['buyer'] ?? null) ? $scope['buyer'] : [], is_array($scope['site'] ?? null) ? $scope['site'] : [] ); } $out = []; $unclassified = []; foreach ($settings as $key => $value) { if (in_array($key, $allow, true)) { $out[$key] = $value; } elseif (!in_array($key, $known, true)) { $unclassified[] = $key; } } /* * Unclassified keys do not enter the package (an allow-list defaults to excluding). This only * leaves a trace — an export is a readfile-and-exit download stream with nowhere to show a warning. * When the theme adds a setting and forgets to register it, the symptom is "the kit is missing a * setting" rather than "it quietly carried one extra". */ if ($unclassified) { if (defined('WP_DEBUG') && WP_DEBUG) { error_log('[aumframe] These theme settings have no scope and were not exported: ' . implode(', ', $unclassified) . ' — to ship them with the template, register them in the theme at' . ' inc/aumframe-integration.php → aumframe/kit/theme_setting_scope.'); } } return $out; } private static function meta(array $given): array { return [ 'name' => (string) ($given['name'] ?? get_bloginfo('name')), 'slug' => sanitize_title((string) ($given['slug'] ?? get_bloginfo('name'))), 'version' => (string) ($given['version'] ?? '1.0.0'), 'siteType' => (string) ($given['siteType'] ?? 'corporate'), 'industry' => (string) ($given['industry'] ?? ''), 'locale' => get_locale(), ]; } /** * What the buyer's site needs installed to accept this package. * * Judged by what is actually used, not by what this site happens to have installed — a development * site carries a pile of debugging plugins, and listing them all in requires would make the buyer think * every one is mandatory. */ private static function requires(): array { $optional = []; if (post_type_exists('product') && class_exists('WooCommerce')) { $optional[] = 'woocommerce'; } if (post_type_exists('aumnexcart_product')) { $optional[] = 'aumnexcart'; } return [ 'theme' => 'aumcreate', 'plugins' => ['aumframe'], 'optional' => $optional, ]; } private static function content_model() { $json = \Aumframe\Model\Store::export(); $arr = json_decode((string) $json, true); return is_array($arr) ? $arr : new \stdClass(); } /** * The component library. * * The plugin's own neutral components are not exported — the buyer's first install seeds them * automatically, and exporting a copy would only produce two identically named records in their library. * Only what this site built itself is exported. */ private static function library(): array { $builtin = []; foreach ((array) glob(AUMFRAME_DIR . 'library/*.json') as $f) { $builtin[] = basename($f, '.json'); } $rows = Library::all(); $baked = Library::baked(array_column($rows, 'slug')); $out = []; foreach ($rows as $row) { $slug = (string) ($row['slug'] ?? ''); if ($slug === '' || in_array($slug, $builtin, true)) { continue; } $out[] = [ 'slug' => $slug, 'name' => (string) ($row['name'] ?? ''), 'category' => (string) ($row['category'] ?? 'general'), 'def' => self::json_field($row['def'] ?? null), 'bakedHtml' => (string) ($baked[$slug]['html'] ?? ''), 'bakedCss' => (string) ($baked[$slug]['css'] ?? ''), ]; } return $out; } /** Templates, including the site shell. The slug is the key that stays stable across sites; auto-increment * ids are never exported. */ private static function templates(): array { global $wpdb; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT slug, title, kind, rules, priority, status, scene, baked_html, baked_css FROM %i WHERE status != 'trash' ORDER BY kind ASC, priority ASC, slug ASC", Storage::table() ), ARRAY_A ) ?: []; $out = []; foreach ($rows as $r) { $out[] = [ 'slug' => (string) $r['slug'], 'title' => (string) $r['title'], 'kind' => (string) $r['kind'], 'rules' => self::rules($r['rules']), 'priority' => (int) $r['priority'], 'status' => (string) $r['status'], 'scene' => self::json_field($r['scene']), 'bakedHtml' => (string) $r['baked_html'], 'bakedCss' => (string) $r['baked_css'], ]; } return $out; } /** * Replace auto-increment IDs in routing rules with slugs. * * The third instance of this shape in the project (the first two were image URLs and form shortcode * IDs): `{"kind":"singular","post_type":"page","post_ids":[21]}` on another site, where 21 is a * different page. * * And its failure mode is the nastiest: some of them happen to line up. The ebike site's about page * is 24 and the target site's happened to be 24 too, so "About" looked perfectly imported while * "Contact" (21 vs 22) silently pointed elsewhere. One package where some pages work and some do not is * far harder to diagnose than one where none do. * * An empty array must be preserved verbatim: Router reads "post_ids present but empty" as "the * author chose to bind to a specific page and has not picked one", which is a different meaning from * "the key is absent" (= all pages). Losing that distinction lets an unfinished template take over an * entire site. */ private static function rules($raw) { $rules = self::json_field($raw); if (!is_array($rules)) { return $rules; } if (array_key_exists('post_ids', $rules)) { $slugs = []; foreach ((array) $rules['post_ids'] as $id) { $p = get_post((int) $id); if ($p) { $slugs[] = $p->post_name; } } unset($rules['post_ids']); $rules['postSlugs'] = $slugs; } if (array_key_exists('term_ids', $rules)) { $slugs = []; foreach ((array) $rules['term_ids'] as $id) { $t = get_term((int) $id); if ($t && !is_wp_error($t)) { $slugs[] = ['taxonomy' => $t->taxonomy, 'slug' => $t->slug]; } } unset($rules['term_ids']); $rules['termSlugs'] = $slugs; } return $rules; } /** * Replace hard-coded auto-increment ID references in a scene with slug references. * * The same class of problem as media URLs, and more insidious: the `[aumframe_form id="20"]` on a contact * page is a number the designer wrote by picking a form in the editor. On another site, 20 either * does not exist or is something else — an empty shortcode on the front end, or a different form * rendered. Nothing is reported. * * Forms are currently the only case (the only ID-carrying use of the shortcode primitive). Any future * capability that "picks a piece of content from within a layout" has to add a line here, or the * package breaks the moment it changes sites. */ private static function deref_all(array $kit): array { $json = (string) wp_json_encode($kit, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); // Handle the copy hidden inside base64 first (the shortcode in the baked output), then the // plain text. Both are required: scenes hold plain text and the baked output holds it encoded, and // missing the latter shows up as "it looks right in the editor and the front end is empty". $json = Refs::map_markers($json, fn($code) => self::deref_text($code)); $json = self::deref_text($json); $arr = json_decode($json, true); return is_array($arr) ? $arr : $kit; } /** Auto-increment references in plain text become slug references. */ private static function deref_text(string $text): string { $text = preg_replace_callback( '/\[aumframe_form\s+id=\\\\?["\']?(\d+)\\\\?["\']?\s*\]/u', function ($m) { $p = get_post((int) $m[1]); return ($p && $p->post_name !== '') ? str_replace($m[1], '@post:' . $p->post_name, $m[0]) : $m[0]; }, $text ) ?? $text; /* * Auto-increment IDs in link bindings become slug references. * * **This pairs with the corresponding lines in `Renderer::resolve_raw()`; either side alone is * broken.** Missing this side is the nastier symptom: the package is perfectly fine **on the * author's own site** (the IDs are that site's), and on a buyer's site those IDs point at * different content — not a 404 but a silent misdirection, where "About us" in the header * might open some news article. * * `link.archive.` is not handled here: a content type's name is defined by the kit's * contentModel and is already stable across sites, so no conversion is needed. * * When it cannot be resolved it is left as-is (consistent with form references above): better to * export a link pointing at a local ID (which the importer leaves as a placeholder when it cannot * resolve it) than to swallow it. */ // Term archives. This must come before the rule below — otherwise `link.term.12` has its tail // eaten by that rule's `\d+` and becomes `link.term.@post:some-article`. $text = preg_replace_callback( '/\blink\.term\.(\d+)\b/u', function ($m) { $t = get_term((int) $m[1]); /* * The `term.` prefix must be kept. Writing `link.@term:…` looks tidier, but that * copy re-imports as `link.` and is parsed as a post ID — silently pointing at * different content. A round trip has to return byte for byte to the original. */ return ($t && !is_wp_error($t) && $t->slug !== '') ? 'link.term.@term:' . $t->taxonomy . ':' . $t->slug : $m[0]; }, $text ) ?? $text; // Pages and posts return preg_replace_callback( '/\blink\.(\d+)\b/u', function ($m) { $p = get_post((int) $m[1]); return ($p && $p->post_name !== '') ? 'link.@post:' . $p->post_name : $m[0]; }, $text ) ?? $text; } /** Which content types to export: visible in the admin and not housekeeping. */ private static function post_types(): array { $types = get_post_types(['show_ui' => true], 'names'); return array_values(array_diff($types, self::SKIP_TYPES)); } private static function taxonomies(): array { $out = []; foreach (self::post_types() as $type) { foreach (get_object_taxonomies($type, 'names') as $tax) { if (!in_array($tax, self::SKIP_TAX, true)) { $out[$tax] = true; } } } return array_keys($out); } private static function terms(): array { $terms = get_terms([ 'taxonomy' => self::taxonomies(), 'hide_empty' => false, ]); if (is_wp_error($terms)) { return []; } $out = []; foreach ($terms as $t) { $parent = $t->parent ? get_term($t->parent) : null; $out[] = [ 'taxonomy' => $t->taxonomy, 'slug' => $t->slug, 'name' => $t->name, 'description' => $t->description, // Parentage is expressed by slug; ids change from site to site 'parent' => ($parent && !is_wp_error($parent)) ? $parent->slug : '', 'meta' => self::term_meta((int) $t->term_id, (string) $t->taxonomy), ]; } return $out; } private static function term_meta(int $term_id, string $taxonomy = ''): array { $out = []; foreach ((array) get_term_meta($term_id) as $key => $vals) { /* * Same rule as post_meta(): everything minus the deny-list. This used to skip every key starting * with `_`, so a term's `_aumviso_seo_description` (AumViso reads term meta for taxonomy * archives) was dropped on export — the practice-area pages of a law-firm kit lost their meta * descriptions on the round trip, with nothing reported. Posts never had that rule. */ if (in_array($key, self::SKIP_META, true)) { continue; } $out[$key] = self::field_value($taxonomy, (string) $key, maybe_unserialize($vals[0] ?? '')); } return $out; } private static function posts(): array { $q = new \WP_Query([ 'post_type' => self::post_types(), 'post_status' => ['publish', 'draft', 'pending', 'private'], 'posts_per_page' => -1, 'orderby' => 'ID', 'order' => 'ASC', // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams -- deliberate: an export has to be a faithful snapshot, so `posts_*` filters from other plugins must not rewrite what gets written into the kit. 'suppress_filters' => true, 'no_found_rows' => true, ]); $out = []; foreach ($q->posts as $p) { $thumb = get_post_thumbnail_id($p); $out[] = [ 'type' => $p->post_type, 'slug' => $p->post_name, 'title' => $p->post_title, 'content' => $p->post_content, 'excerpt' => $p->post_excerpt, 'status' => $p->post_status, // Dates must be exported. Without them `wp_insert_post` uses the current time — an // entire news list becomes "all posted today", the ordering changes with it, and since the // homepage takes only the newest few, the homepage shows a different set of posts. // A difference like that is invisible to anyone counting status codes rather than looking // at the content. 'date' => $p->post_date, 'dateGmt' => $p->post_date_gmt, 'menuOrder' => (int) $p->menu_order, 'parent' => $p->post_parent ? (get_post($p->post_parent)->post_name ?? '') : '', 'thumbnail' => $thumb ? self::register((int) $thumb) : '', 'meta' => self::post_meta((int) $p->ID), 'terms' => self::post_terms($p), ]; /* * Lets others add the parts of this post the engine does not understand — symmetrical with * the import side's `aumframe/kit/post_imported`, and both must exist. * * Having only the import side has a very concrete consequence: the theme can install a * kit's variable products and can no longer export them. The buyer presses export in * Appearance → Theme Settings → Import/Export and six bikes come out as simple products with no * price and no variations — while the export report says ok and carries not one warning. * (Found while rebuilding ebike on 2026-08-22: 6 bikes exported, 0 with a product section.) */ $out[count($out) - 1] = apply_filters( 'aumframe/kit/export_post', $out[count($out) - 1], $p ); } wp_reset_postdata(); return $out; } /** * A post's custom fields. * * Everything minus a deny-list, rather than an allow-list. The reasoning: fields can arrive by four * routes — the content-model manager, a site mu-plugin, ACF, Woo or NexCart — so an allow-list is * certain to miss one, and a miss shows up as "filled in the admin, empty on the front end", the class * of problem this project has hit five times. * * The cost is possibly carrying some plugin housekeeping fields (Woo's lookup tables and the like). * They are harmless on import (they get regenerated) and only make the package slightly larger. Extend * the deny-list if one ever gets in the way. */ private static function post_meta(int $post_id): array { $type = (string) get_post_type($post_id); $out = []; foreach ((array) get_post_meta($post_id) as $key => $vals) { if (in_array($key, self::SKIP_META, true)) { continue; } $val = maybe_unserialize($vals[0] ?? ''); $out[$key] = self::field_value($type, (string) $key, $val); } return $out; } private static function post_terms(\WP_Post $p): array { $out = []; foreach (get_object_taxonomies($p->post_type, 'names') as $tax) { if (in_array($tax, self::SKIP_TAX, true)) { continue; } $slugs = wp_get_object_terms($p->ID, $tax, ['fields' => 'slugs']); if (!is_wp_error($slugs) && $slugs) { $out[$tax] = array_values($slugs); } } return $out; } /** * Replace attachment IDs inside field values with media ids. * * An image field in the content model stores an attachment ID (`142`), which on another site points * at a different image or none at all. Without the replacement the front end shows a broken image, * looking like the editor is at fault. * * Only the case "the whole value is one attachment ID" is handled — an ID embedded inside a structure * cannot be recognised generically, and those rely on the importer's own content model to interpret. */ private static function media_ref($val) { if (is_array($val)) { return array_map([self::class, 'media_ref'], $val); } if (is_numeric($val) && (int) $val > 0 && get_post_type((int) $val) === 'attachment') { $ref = self::register((int) $val); if ($ref !== '') { return $ref; } } return $val; } private static function menus(): array { $out = []; foreach ((array) wp_get_nav_menus() as $menu) { $items = wp_get_nav_menu_items($menu->term_id) ?: []; $rows = []; foreach ($items as $it) { $rows[] = [ 'title' => $it->title, 'type' => $it->type, // post_type | taxonomy | custom 'object' => $it->object, // Objects are referenced by slug and mapped back to ids on import 'objectSlug' => self::menu_object_slug($it), 'url' => $it->type === 'custom' ? $it->url : '', 'order' => (int) $it->menu_order, 'parent' => (int) $it->menu_item_parent, // A position relative to this package, remapped on import 'id' => (int) $it->ID, ]; } $out[] = [ 'slug' => $menu->slug, 'name' => $menu->name, 'locations' => array_keys(array_filter(get_nav_menu_locations(), fn($id) => (int) $id === (int) $menu->term_id)), 'items' => $rows, ]; } return $out; } private static function menu_object_slug(\WP_Post $item): string { if ($item->type === 'post_type') { $p = get_post((int) $item->object_id); return $p ? $p->post_name : ''; } if ($item->type === 'taxonomy') { $t = get_term((int) $item->object_id); return ($t && !is_wp_error($t)) ? $t->slug : ''; } return ''; } /** * Export only the site options that shape what the site looks like; the buyer's own things (site * name, timezone) are left alone. * * The options that shape URLs must be included. A kit's routing rules match real addresses, and * the address `/bikes/xafari/` comes from `woocommerce_permalinks`. Without it the target site uses the * plugin's default (`/product/xafari/`), and not one archive or single template matches — the * symptom being a perfectly fine homepage with product pages 404ing or redirecting, which looks like * "the templates did not import". * * This is not "orchestrating a third-party plugin": Woo's behaviour is untouched, only its address * structure travels along. Another plugin wanting to add its own goes through the * `aumframe/kit_options` filter without changing this. */ private static function options(): array { $front = (int) get_option('page_on_front'); $posts = (int) get_option('page_for_posts'); $out = [ /* * The site name and tagline are exported and not imported by default (the `siteIdentity` * switch in `Importer`). * * The two scenarios want different things: delivering on someone's behalf, the site should look * exactly like the demo; selling to a buyer, overwriting their site name is an intrusion — * their site may already be live. The exporter does not make that decision for the importer and * leaves the choice on the import side. */ 'blogname' => (string) get_option('blogname'), 'blogdescription' => (string) get_option('blogdescription'), 'show_on_front' => (string) get_option('show_on_front'), 'page_on_front' => $front ? (get_post($front)->post_name ?? '') : '', 'page_for_posts' => $posts ? (get_post($posts)->post_name ?? '') : '', ]; // Address structure foreach (['permalink_structure', 'category_base', 'tag_base'] as $key) { $out[$key] = (string) get_option($key); } if (get_option('woocommerce_permalinks')) { $out['woocommerce_permalinks'] = get_option('woocommerce_permalinks'); } /* * Plugin settings that hold a page ID. * * `woocommerce_shop_page_id` determines the product archive's address (Woo uses the shop page's URI * as `has_archive`). Without carrying it across, the target site uses its own blank shop page — * `/bikes/` becomes a seventeen-word empty page while the homepage and the detail pages are fine, * looking like "only the archive template failed to import". * * Expressed by slug and mapped back to an ID on import. Another plugin adding its own goes through * the same filter. */ $refs = []; foreach (['shop', 'cart', 'checkout', 'myaccount', 'terms'] as $k) { $id = (int) get_option("woocommerce_{$k}_page_id"); $p = $id ? get_post($id) : null; if ($p && $p->post_type === 'page' && $p->post_status !== 'trash') { $refs["woocommerce_{$k}_page_id"] = $p->post_name; } } if ($refs) { $out['pageRefs'] = $refs; } return (array) apply_filters('aumframe/kit_options', $out); } /** * Find every piece of media used across the whole kit. * * It scans the JSON of the fully assembled kit rather than searching section by section — an image * URL appears in scenes, in custom fields, inside customCss's `url(...)` and in theme settings, and * hunting structure by structure is certain to miss one. Whatever is missed is a hot-linked image. */ private static function media(array $kit): array { // UNESCAPED_SLASHES is required. The default encoding writes URLs as `http:\/\/…\/uploads\/…`, // and those backslashes cut the URL in half during scanning — the symptom being **not one image // found**, rather than an error. The first run did exactly that: an empty media section, looking // like "this site has no images". $blob = (string) wp_json_encode($kit, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $urls = Media::scan($blob); $out = []; $seen = []; foreach ($urls as $url) { $rec = Media::record($url); if (isset($seen[$rec['id']])) { continue; } $seen[$rec['id']] = true; $out[] = $rec; } // Featured images and fields already converted to `@media:id` have no URL left in the JSON, so // they are filled back in from the registry foreach (self::$registry as $id => $url) { if (isset($seen[$id])) { continue; } $seen[$id] = true; $out[] = Media::record($url); } return $out; } /** * The content model's field type table: `.` and `..` → type name. * * Why it is needed: a repeater is a JSON string in the database, and `media_ref()` only recognises * "the whole value is one attachment ID" plus arrays — it cannot see inside that string. So every image * and file field inside a group was exported with the source site's raw attachment ID, and the * files never entered the package at all. * * That gap fails extremely quietly: a buyer imports our kit, makes some changes, exports it again, and * receives a package missing dozens of files (96 → 43 on this kit), with the ids inside the groups * pointing at different things on the next site. Nothing is reported. */ private static function field_types(): array { static $map = null; if ($map !== null) { return $map; } $map = []; $model = json_decode((string) \Aumframe\Model\Store::export(), true); foreach (['postTypes', 'taxonomies'] as $bucket) { foreach ((array) ($model[$bucket] ?? []) as $entry) { $owner = (string) ($entry['name'] ?? ''); foreach ((array) ($entry['fields'] ?? []) as $f) { $key = (string) ($f['key'] ?? ''); if ($owner === '' || $key === '') { continue; } $map["{$owner}.{$key}"] = (string) ($f['type'] ?? 'text'); foreach ((array) ($f['fields'] ?? []) as $sub) { $sk = (string) ($sub['key'] ?? ''); if ($sk !== '') { $map["{$owner}.{$key}.{$sk}"] = (string) ($sub['type'] ?? 'text'); } } } } } return $map; } /** Field types that reference attachments or content — values of these point at something else on another * site. */ private static function ref_type(string $type): string { if (in_array($type, ['image', 'file', 'video', 'audio'], true)) { return 'media'; } return $type === 'post_ref' ? 'post' : ''; } /** * Translate one field's value according to the content model. * * - repeater: decode the JSON, translate row by row and subfield by subfield according to type, and * put the result into the kit as a list of rows (`KIT-FORMAT.md`: a kit writes a repeater as a * list of rows, and the importer translates it back to JSON) * - image / file: attachment ID → `@media:id` * - `post_ref`: content ID → `@post:slug` (an auto-increment ID is different content on another site) * - everything else: unchanged, but still passed through the old `media_ref` (older sites' data can * contain bare attachment IDs) */ private static function field_value(string $owner, string $key, $val) { $types = self::field_types(); $type = $types["{$owner}.{$key}"] ?? ''; if ($type === 'group') { $rows = is_array($val) ? $val : json_decode((string) $val, true); if (!is_array($rows)) { return self::media_ref($val); } $out = []; foreach ($rows as $row) { if (!is_array($row)) { continue; } $line = []; foreach ($row as $sk => $sv) { $line[$sk] = self::ref_value($types["{$owner}.{$key}.{$sk}"] ?? '', $sv); } $out[] = $line; } return $out; } return self::ref_value($type, $val); } /** Translate one value into a cross-site reference according to its type. */ private static function ref_value(string $type, $val) { $kind = self::ref_type($type); if ($kind === 'post' && is_numeric($val) && (int) $val > 0) { $p = get_post((int) $val); // When the post cannot be found, leave it empty rather than exporting a bare ID that points at // somebody else's content on another site return $p ? '@post:' . $p->post_name : ''; } if ($kind === 'media' && is_numeric($val) && (int) $val > 0) { return self::register((int) $val); } return self::media_ref($val); } /** Register an attachment in the media table and return an `@media:id` reference (an empty string when the * URL cannot be obtained). */ private static function register(int $attachment_id): string { $url = (string) wp_get_attachment_url($attachment_id); if ($url === '') { return ''; } $url = Media::normalize($url); $id = Media::id_for($url); self::$registry[$id] = $url; return '@media:' . $id; } /** The database stores a JSON string; decode it into the kit rather than leaving "a string inside the * JSON". */ private static function json_field($raw) { if (is_array($raw)) { return $raw; } $arr = json_decode((string) $raw, true); return is_array($arr) ? $arr : new \stdClass(); } }