(int) ($model['version'] ?? self::VERSION), 'postTypes' => array_values(array_filter((array) ($model['postTypes'] ?? []), 'is_array')), 'taxonomies' => array_values(array_filter((array) ($model['taxonomies'] ?? []), 'is_array')), ]; return self::$cache; } /** * Drop the in-memory cache. * * `save()` updates the cache along the way, so the normal path does not need this. What needs it is * anything bypassing save and touching the option directly — a database-wipe tool, a migration script, * wp-cli. Without clearing, everything read later in the same request is the old configuration that no * longer exists: wipe the database and import immediately afterwards, and the importer concludes "these * types already exist" and skips them — the report says success and nothing was written. */ public static function flush(): void { self::$cache = null; } public static function post_types(): array { return self::all()['postTypes']; } public static function taxonomies(): array { return self::all()['taxonomies']; } /** * The display label of one content-model entry. * * An extension entry carries no labels, by design — `sanitize_post_type()` keeps only name, * `extends` and fields for it (the reason is written out above that branch: labels, slugs, supports * and archives are registration-time parameters, and the type was registered by somebody else). * So every reader that wants a label has to ask for it here instead of subscripting `['labels']`: * doing that directly gives `Undefined array key "labels"` plus an empty string, and an empty string * is what the "is this blank?" branches read as "a type being created right now". * * For an extension entry the label belongs to whoever registered the type, so that is where it comes * from. Falling back to the identifier is correct rather than defensive: a type registered by a plugin * that is currently deactivated has no object to ask, and the identifier is the one thing that is * always there and never collides. */ public static function label_for(array $entry, string $which = 'plural'): string { $name = (string) ($entry['name'] ?? ''); if (!empty($entry['extends'])) { $obj = $name !== '' ? get_post_type_object($name) : null; if ($obj) { $label = $which === 'singular' ? (string) ($obj->labels->singular_name ?? '') : (string) ($obj->labels->name ?? ''); if ($label !== '') { return $label; } } return $name; } return (string) ($entry['labels'][$which] ?? ''); } public static function post_type(string $name): ?array { foreach (self::post_types() as $pt) { if (($pt['name'] ?? '') === $name) { return $pt; } } return null; } public static function taxonomy(string $name): ?array { foreach (self::taxonomies() as $tx) { if (($tx['name'] ?? '') === $name) { return $tx; } } return null; } /** One content type's fields (the ones built in the manager). */ public static function fields_for_post_type(string $name): array { $pt = self::post_type($name); return $pt ? array_values(array_filter((array) ($pt['fields'] ?? []), 'is_array')) : []; } public static function fields_for_taxonomy(string $name): array { $tx = self::taxonomy($name); return $tx ? array_values(array_filter((array) ($tx['fields'] ?? []), 'is_array')) : []; } public static function field(array $fields, string $key): ?array { foreach ($fields as $f) { if (($f['key'] ?? '') === $key) { return $f; } } return null; } // ------------------------------------------------------------------ Writing /** * Save the whole model. * * Stored as a JSON string rather than an array: `update_option` would serialise an array into PHP's own * format, which nothing but PHP can read — and this configuration has to be exportable, generatable by an * AI and readable by other tools, so locking it into serialize would close every one of those doors. */ public static function save(array $model): void { $clean = self::sanitize_model($model); update_option(self::OPTION, wp_json_encode($clean, JSON_UNESCAPED_UNICODE), false); self::$cache = $clean; /* * Rewrite rules have to be flushed after a content type's or permalink's slug changes, or addresses * like /bikes/ 404 — and in the way that **looks fine at the moment of saving and only shows up on the * next visit**. This only sets a flag; the real flush happens after the next init, by which time the * new types are registered. */ update_option(self::FLUSH_OPTION, 1, false); } public static function sanitize_model(array $model): array { $post_types = []; $seen_pt = []; foreach ((array) ($model['postTypes'] ?? []) as $pt) { $clean = is_array($pt) ? self::sanitize_post_type($pt, $seen_pt) : null; if ($clean) { $seen_pt[$clean['name']] = true; $post_types[] = $clean; } } $taxonomies = []; $seen_tx = []; foreach ((array) ($model['taxonomies'] ?? []) as $tx) { $clean = is_array($tx) ? self::sanitize_taxonomy($tx, $seen_tx) : null; if ($clean) { $seen_tx[$clean['name']] = true; $taxonomies[] = $clean; } } /* * A type's taxonomy list is derived, not submitted. * * The association is edited in exactly one place — "Applies to:" on the taxonomy card, which writes * `model[taxonomies][i][postTypes][]`. The post-type side (`$pt['taxonomies']`, read by * `Registrar::declare()` and handed to register_post_type) is the same fact spelled the other way round. * * Storing both as independent input gave two failures at once, and the second one is the reason this * is computed here rather than carried in a hidden field: * - The post-type card never rendered an input for it, so saving the page emptied every type's list * while the taxonomy side still claimed the link — the two disagreed and neither was flagged. * - A hidden field would have fixed the round trip and then gone stale the moment somebody changed * "Applies to:", which is the same disagreement arriving a step later. * * One editable side, one derived side. `tests/model-card-test.php` asserts the derived key is not a * card input, so re-adding one fails the gate. */ foreach ($post_types as &$pt) { if (!empty($pt['extends'])) { continue; // An extension entry registers nothing, so it declares nothing } $pt['taxonomies'] = []; foreach ($taxonomies as $tx) { if (in_array($pt['name'], (array) ($tx['postTypes'] ?? []), true)) { $pt['taxonomies'][] = $tx['name']; } } } unset($pt); return [ 'version' => self::VERSION, 'postTypes' => $post_types, 'taxonomies' => $taxonomies, ]; } /** * The identifier. * * Three things: * 1. A non-Latin name needs a stable fallback — `sanitize_key() on a CJK name` yields percent-encoding or an * empty string, and an empty string means "a type was created and nothing happened", the hardest kind * of failure to trace. * 2. A length limit, and it differs by kind — `$max`, which the caller has to state: * WordPress allows 20 characters for a post type and 32 for a taxonomy, and exceeding either * triggers _doing_it_wrong. * 3. It must not collide with a reserved name or with an existing one of the same kind. * * 20 used to be hard-coded here for all three callers, and that quietly broke taxonomy archives. * The failure needs the whole chain to be told: * * - `aumreserva_service_cat` is 22 characters, so a kit importing it got `aumreserva_service_c`. * - The guard that stops us re-registering somebody else's name (`taxonomy_exists()` over in * `ContentTypes::register()`) was then asked about the truncated name, which nobody had * registered — so it registered a second taxonomy. * - Both carried the same rewrite slug. The duplicate's rewrite rules won, and they point at a * query var that holds no terms: every category archive returned 404 while both taxonomies * showed up in the admin, each with its own list. * - It stayed invisible for as long as the names were short. `arb_service_cat` was 15 characters; * the .org prefix rename took it to 22 and the 404s started. Nothing about the rename was wrong. * * Field keys (the third caller) keep 20 deliberately — they become meta keys, where the limit is 255, * and raising it would change identifiers that existing posts already store. */ public static function make_name(string $raw, string $fallback_seed, array $taken, int $max = 20): string { $name = self::latinize($raw); /* * With no identifier given, derive one from the name. * * "Case Study" -> `case_study`: readable, guessable, and presentable in a URL. * Without deriving it, every type whose author skipped the identifier would get a hash — and that hash * would travel all the way onto front-end addresses (/ct_0c8ae280/), where nobody can read it. */ if ($name === '') { $name = self::latinize($fallback_seed); } if ($name === '' || is_numeric($name)) { // Non-Latin names arrive here. A hash of the content itself, so **the same name always produces the // same result** — with a random number or a timestamp, re-saving the configuration would change the // identifier and every existing post would be orphaned $name = 'ct_' . substr(md5($fallback_seed !== '' ? $fallback_seed : $raw), 0, 8); } $name = substr($name, 0, $max); if (in_array($name, self::reserved(), true) || isset($taken[$name])) { $suffix = '_' . substr(md5($raw . '|' . $fallback_seed), 0, 4); $name = substr($name, 0, $max - strlen($suffix)) . $suffix; } return $name; } /** * The "this type is a catalogue" configuration. * * Why it is needed: a corporate site's product is an ordinary CPT — which custom field holds the price and * where the button links only the client knows. A hard-coded convention ("the field called price is * the price") means writing code once per client, and then this theme is not a product. * So it is configuration, ticked in the admin and read by the catalogue adapter. * * Empty names (priceField and the like) are stored as empty strings rather than null: this JSON is also * what the AI layer has to generate, and a key's presence is easier to get right than its value. */ private static function sanitize_catalog(array $c): array { $kind = sanitize_key((string) ($c['priceKind'] ?? 'fixed')); if (!in_array($kind, ['fixed', 'range', 'text', 'none'], true)) { $kind = 'fixed'; } return [ 'enabled' => !empty($c['enabled']), 'priceField' => sanitize_key((string) ($c['priceField'] ?? '')), 'priceMaxField' => sanitize_key((string) ($c['priceMaxField'] ?? '')), 'priceKind' => $kind, 'currency' => sanitize_text_field((string) ($c['currency'] ?? '')), 'actionUrl' => esc_url_raw((string) ($c['actionUrl'] ?? '')), 'actionUrlField' => sanitize_key((string) ($c['actionUrlField'] ?? '')), 'actionLabel' => sanitize_text_field((string) ($c['actionLabel'] ?? '')), 'skuField' => sanitize_key((string) ($c['skuField'] ?? '')), ]; } private static function sanitize_post_type(array $pt, array $taken): ?array { /* * Extending an existing type — adding custom fields to the built-in `page` and `post`. * * `reserved()` blocks "registering a new type called post" (which genuinely would break the blog), but * "adding a few fields to a built-in type" is a different matter and was previously caught by the * same blanket rule. The consequence was concrete: a legal page wanting an "intro" field could not have * one on `page`, so it was either hard-coded into the layout (beyond the buyer's reach) or abandoned. * And the rule "every new field has to answer where the user fills it in" was exactly what that blanket * ban broke. * * An extension entry keeps only name and fields: labels, slugs, supports and archives are * registration-time parameters, and setting them for a type somebody else registered only produces * "set it and nothing happened". */ if (!empty($pt['extends'])) { $name = sanitize_key((string) ($pt['name'] ?? '')); if ($name === '' || isset($taken[$name])) { return null; } $fields = self::sanitize_fields((array) ($pt['fields'] ?? [])); if (!$fields) { return null; // An extension entry with no fields means nothing } return ['name' => $name, 'extends' => true, 'fields' => $fields]; } $singular = sanitize_text_field($pt['labels']['singular'] ?? ''); $plural = sanitize_text_field($pt['labels']['plural'] ?? $singular); if ($singular === '' && $plural === '') { return null; } $name = self::make_name((string) ($pt['name'] ?? ''), $singular . $plural, $taken); if ($name === '') { return null; } $supports = []; foreach ((array) ($pt['supports'] ?? []) as $s) { $s = sanitize_key($s); if (in_array($s, ['title', 'editor', 'excerpt', 'thumbnail', 'page-attributes', 'comments', 'author', 'custom-fields'], true)) { $supports[] = $s; } } // A content type without even a title is a row of blank lines in the admin list, and unusable if (!in_array('title', $supports, true)) { array_unshift($supports, 'title'); } $public = !empty($pt['public']); return [ 'name' => $name, 'labels' => ['singular' => $singular ?: $plural, 'plural' => $plural ?: $singular], 'public' => $public, /* * An archive page only means something for a public type. Types that are "just data" like FAQs and * downloads have no listing page of their own, and ticking it only adds a URL that 404s. */ 'hasArchive' => $public && !empty($pt['hasArchive']), 'slug' => self::sanitize_slug((string) ($pt['slug'] ?? ''), $name), 'menuIcon' => self::sanitize_dashicon((string) ($pt['menuIcon'] ?? '')), 'supports' => array_values(array_unique($supports)), 'taxonomies' => array_values(array_unique(array_filter(array_map('sanitize_key', (array) ($pt['taxonomies'] ?? []))))), 'fields' => self::sanitize_fields((array) ($pt['fields'] ?? [])), 'catalog' => self::sanitize_catalog((array) ($pt['catalog'] ?? [])), ]; } private static function sanitize_taxonomy(array $tx, array $taken): ?array { $singular = sanitize_text_field($tx['labels']['singular'] ?? ''); $plural = sanitize_text_field($tx['labels']['plural'] ?? $singular); if ($singular === '' && $plural === '') { return null; } $name = self::make_name((string) ($tx['name'] ?? ''), $singular . $plural, $taken, 32); if ($name === '') { return null; } return [ 'name' => $name, 'labels' => ['singular' => $singular ?: $plural, 'plural' => $plural ?: $singular], // Hierarchical means "like categories" (a bike belongs to a series); non-hierarchical means "like // tags". That is the whole of the "do I want categories or tags" question — WordPress's own // dichotomy. 'hierarchical' => !empty($tx['hierarchical']), 'public' => !isset($tx['public']) || !empty($tx['public']), 'slug' => self::sanitize_slug((string) ($tx['slug'] ?? ''), $name), 'postTypes' => array_values(array_unique(array_filter(array_map('sanitize_key', (array) ($tx['postTypes'] ?? []))))), 'fields' => self::sanitize_fields((array) ($tx['fields'] ?? [])), ]; } /** * Field definitions. * * The key is the meta key and also a segment of a binding expression (`post.meta.spec_range`) — Renderer's * binding regex only accepts `[a-zA-Z0-9_.-]`, so this must narrow to the same character set, or a field * can be created, bound, and be permanently empty on the front end. */ public static function sanitize_fields(array $fields, bool $allow_group = true): array { $types = Fields::types(); $out = []; $seen = []; foreach ($fields as $f) { if (!is_array($f)) { continue; } $label = sanitize_text_field($f['label'] ?? ''); $type = isset($types[$f['type'] ?? '']) ? $f['type'] : 'text'; if (!$allow_group && $type === 'group') { continue; } $key = self::make_name((string) ($f['key'] ?? ''), $label, $seen); if ($key === '' || $label === '') { continue; } $seen[$key] = true; $entry = [ 'key' => $key, 'label' => $label, 'type' => $type, 'help' => sanitize_text_field($f['help'] ?? ''), ]; if (!empty($types[$type]['hasOptions'])) { $entry['options'] = sanitize_textarea_field($f['options'] ?? ''); } if ($type === 'post_ref') { $entry['postType'] = sanitize_key($f['postType'] ?? ''); } if ($type === 'term_ref') { $entry['taxonomy'] = sanitize_key($f['taxonomy'] ?? ''); } if ($type === 'image' || $type === 'video' || $type === 'audio' || $type === 'file') { $entry['mime'] = $types[$type]['mime']; } if ($type === 'group') { // A group cannot contain another group $entry['fields'] = self::sanitize_fields((array) ($f['fields'] ?? []), false); } $out[] = $entry; } return $out; } /** * "Case Study" -> `case_study`. A non-Latin name comes back empty and is handled by the caller above. * * sanitize_title rather than sanitize_key: the latter deletes spaces outright * ('Case Study' -> 'casestudy'), while the former converts them to hyphens which then become underscores — * the shape of words. */ private static function latinize(string $raw): string { $slug = sanitize_title(remove_accents($raw)); /* * sanitize_title returns percent-encoding rather than Latin letters for non-Latin input, which has to * be blocked here. * The underscore belongs in the allow-list: sanitize_title preserves it, and a hand-typed * identifier almost always looks like `case_study`. Missing it would treat a perfectly correct * identifier as "non-Latin", discard it and substitute a hash — and that hash would travel all the way * onto front-end addresses. */ if ($slug === '' || !preg_match('/^[a-z0-9_-]+$/', $slug)) { return ''; } return (string) preg_replace('/[^a-z0-9_]/', '', str_replace('-', '_', $slug)); } /** * The URL prefix. * * With none given, the identifier is used — which at this point is either what the user typed or something * derived from an English name, and both are presentable. The name itself must not go through * sanitize_title: a non-Latin name becomes percent-encoding, and pasting an address like that into a chat * window produces a wall of gibberish. */ private static function sanitize_slug(string $slug, string $fallback): string { $slug = sanitize_title(remove_accents($slug)); return ($slug !== '' && preg_match('/^[a-z0-9_-]+$/', $slug)) ? $slug : $fallback; } private static function sanitize_dashicon(string $icon): string { $icon = sanitize_html_class($icon); return strpos($icon, 'dashicons-') === 0 ? $icon : ''; } // ------------------------------------------------------------------ Import and export /** * Export as a "content model package". * * This is where being data rather than code genuinely pays: finish ebike, export this and keep it, and * the next e-bike or motorcycle job imports it as-is. An industry template package is the same thing. * See SiteKit in the AI layer's plan — a content model package is the piece SiteKit was missing. */ public static function export(): string { return (string) wp_json_encode(self::all(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); } /** * Import. * * With `$merge = true`, a type of the same name keeps the existing definition and only new ones are * added — overwriting would change existing content's field definitions out from under it (an image field * turning into a text field would display stored attachment IDs as bare numbers), which is far worse than * "one type did not import". * * @return array{ok:bool,added:int,skipped:int,error:string} */ public static function import(string $json, bool $merge = true): array { $incoming = json_decode($json, true); if (!is_array($incoming)) { return ['ok' => false, 'added' => 0, 'skipped' => 0, 'error' => __('Not valid JSON.', 'aumframe')]; } $incoming = self::sanitize_model($incoming); if (!$incoming['postTypes'] && !$incoming['taxonomies']) { return ['ok' => false, 'added' => 0, 'skipped' => 0, 'error' => __('That file contains no content types or taxonomies.', 'aumframe')]; } if (!$merge) { self::save($incoming); return ['ok' => true, 'added' => count($incoming['postTypes']) + count($incoming['taxonomies']), 'skipped' => 0, 'error' => '']; } $current = self::all(); $added = 0; $skipped = 0; foreach (['postTypes', 'taxonomies'] as $bucket) { $have = wp_list_pluck($current[$bucket], 'name'); foreach ($incoming[$bucket] as $entry) { if (in_array($entry['name'], $have, true)) { $skipped++; continue; } $current[$bucket][] = $entry; $added++; } } self::save($current); return ['ok' => true, 'added' => $added, 'skipped' => $skipped, 'error' => '']; } }