@contentrain/types 1.45.0 → 1.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +64 -5
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +56 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/canonical.ts","../src/migration.ts","../src/execution.ts","../src/approval.ts","../src/migrate-claim.ts","../src/project-plan.ts","../src/provider.ts","../src/media.ts","../src/index.ts"],"sourcesContent":["// ─── Canonical Serialization ───\n//\n// Deterministic JSON: the same data must produce the same bytes on every\n// machine, every run. Stable git diffs depend on it, and so does `plan_hash`\n// in `execution.ts` — which is why these two functions live in their own\n// module rather than in `index.ts`. `execution.ts` needs them and `index.ts`\n// re-exports `execution.ts`; keeping them here is what stops that from being\n// an import cycle.\n\n/** Recursively sort object keys — respects optional fieldOrder for top-level keys */\nexport function sortKeys(obj: unknown, fieldOrder?: readonly string[]): unknown {\n if (obj === null || obj === undefined) return undefined\n if (Array.isArray(obj)) return obj.map(item => sortKeys(item, fieldOrder))\n if (typeof obj !== 'object') return obj\n\n const record = obj as Record<string, unknown>\n const sorted: Record<string, unknown> = {}\n\n const keys = fieldOrder\n ? [...new Set([...fieldOrder, ...Object.keys(record).toSorted()])]\n : Object.keys(record).toSorted()\n\n for (const key of keys) {\n if (!(key in record)) continue\n const val = record[key]\n if (val === null || val === undefined) continue\n sorted[key] = sortKeys(val)\n }\n\n return sorted\n}\n\n/** Canonical JSON serialization — deterministic output for stable git diffs */\nexport function canonicalStringify(data: unknown, fieldOrder?: readonly string[]): string {\n const sorted = sortKeys(data, fieldOrder)\n return `${JSON.stringify(sorted, null, 2)}\\n`\n}\n","// ─── Migration Contracts ───\n//\n// Shared, MIT-licensed shapes for the WordPress → static-site migration pipeline.\n// Producers: the WordPress Bridge plugin (GPL, separate repo), REST/WXR importers.\n// Consumers: the migration engine, Studio, and the open Astro emitter.\n//\n// Every shape here is plain JSON — snake_case keys, no class instances, no\n// functions — because these documents cross process, repository, and license\n// boundaries. A GPL plugin writes RawIR; a proprietary service reads it; an MIT\n// emitter consumes ProjectIR. The contract is the only thing they share, so it\n// lives in the one package all of them may depend on.\n\n/**\n * Version stamped into every migration document (`version` field on each root).\n * The four contracts evolve together with this package; a reader that sees a\n * higher version than it knows should refuse rather than guess.\n */\nexport const MIGRATION_CONTRACT_VERSION = 1\n\n// ─── Source access ───\n\n/**\n * How the WordPress site's data was reached, ordered from least to most\n * complete. Field coverage rises with each rung — measured in August 2026 at\n * roughly 34% for public REST, 57% with an Application Password and 82% from a\n * WXR export; the Bridge plugin reads WordPress from inside and is meant to\n * reach every field — which is why provenance is recorded on every RawIR:\n * two documents for the same site are only comparable if their rungs are.\n */\nexport type SourceAccessKind = 'rest_public' | 'rest_auth' | 'wxr' | 'bridge'\n\n/** The ladder in ascending completeness order. */\nexport const SOURCE_ACCESS_LADDER = [\n 'rest_public',\n 'rest_auth',\n 'wxr',\n 'bridge',\n] as const satisfies readonly SourceAccessKind[]\n\n// ─── RawIR ───\n//\n// Source-faithful extraction of a WordPress site: what the source said, before\n// any interpretation. Unresolved references are kept and *marked* (`resolved`\n// flags) instead of dropped — deciding what a broken reference means is the\n// consumer's job, not the extractor's.\n\nexport interface RawProvenance {\n kind: SourceAccessKind\n /** ISO 8601 UTC time the extraction ran. */\n fetched_at?: string\n /** Name/version of the producing tool (e.g. \"wordpress-bridge/1.0\"). */\n tool?: string\n}\n\nexport interface RawSite {\n url: string\n title?: string\n description?: string\n /** WXR distinguishes the WP install URL from the public site URL. */\n base_site_url?: string\n base_blog_url?: string\n /** Site language as reported (e.g. \"en-US\"); multilingual detail lives in `language_pairs`. */\n language?: string | null\n /** Generator string (WordPress version) when the source exposed it. */\n generator?: string | null\n /** WXR export timestamp, when the source was a WXR file. */\n export_date?: string | null\n wxr_version?: string | null\n /** The site's time zone (`timezone_string`, IANA, e.g. `Europe/Istanbul`), when it names one. */\n timezone?: string | null\n /** The site's fixed UTC offset in hours (`gmt_offset`), for a site set to an offset rather than a zone. */\n gmt_offset?: number | null\n}\n\nexport interface RawAuthor {\n id: number | null\n login: string\n email?: string | null\n display_name: string\n first_name?: string | null\n last_name?: string | null\n}\n\nexport interface RawTerm {\n id: number | null\n taxonomy: string\n slug: string\n name: string\n /** Parent term slug within the same taxonomy, when hierarchical. */\n parent?: string | null\n /** Whether `parent` names a term present in this document. */\n parent_resolved?: boolean | null\n description?: string\n}\n\n/** A post's reference to a term — kept even when the term itself is absent. */\nexport interface RawTermRef {\n taxonomy: string\n slug: string\n name: string\n /** Whether the referenced term exists in this document's `terms`. */\n resolved: boolean\n}\n\n/** An ACF field value paired with its field-definition key (`field_…`). */\nexport interface RawAcfValue {\n value: unknown\n /** ACF field key (`field_…`), where the source carries it (WXR meta, Bridge). REST does not. */\n field_key?: string\n /**\n * ACF field type as the source states it (`text`, `repeater`, `relationship`, …): SCF's REST\n * `<name>_source.type`, a Bridge field schema. Absent = the reader infers the type from the value.\n */\n type?: string\n /** Field label as the source states it. */\n label?: string\n}\n\n/**\n * One ACF Options Page (bridge rung): site-wide fields that belong to no post.\n * Values are the default language's; a translation plugin's per-language copy\n * is not read. Secret fields are left out by the exporter, as on a post.\n */\nexport interface RawAcfOptionsPage {\n /** The page's menu slug, sanitized (`site-settings`). */\n slug: string\n /** The page title as the admin menu shows it. */\n title: string\n /** ACF storage id the values are read from — `options` unless the page sets its own. */\n post_id: string\n /** Field name → value, keyed like `RawPost.acf`. */\n fields: Record<string, RawAcfValue>\n}\n\nexport interface RawPost {\n id: number\n /** WordPress post type — \"post\", \"page\", or a custom post type slug. */\n type: string\n /** WordPress status verbatim (\"publish\", \"draft\", \"future\", \"pending\", …). */\n status: string\n slug: string\n title: string\n /** Public permalink, when known. Permalink *structure* is a site setting; the link is the fact. */\n link?: string | null\n guid?: string | null\n /** Author login; resolve against `RawIR.authors`. */\n author: string | null\n /** ISO 8601 UTC, null when the source carried no usable date. */\n date: string | null\n modified: string | null\n /** Rendered/exported HTML body, untransformed. */\n content: string\n excerpt: string\n parent?: number | null\n menu_order?: number\n sticky?: boolean\n /**\n * Set when the post is password-protected. Importers write the marker\n * `'[protected]'`, never the password itself; treat any non-empty value as\n * \"protected\".\n */\n password?: string | null\n comment_status?: string | null\n ping_status?: string | null\n terms: RawTermRef[]\n /**\n * Post meta, PHP-serialized values already decoded where possible.\n * Core WP keys (underscore-prefixed) are included: which keys matter is a\n * downstream decision.\n */\n meta: Record<string, unknown>\n /** Meta keys whose values were PHP-serialized in the source (decoded above). */\n serialized_keys?: string[]\n /** ACF fields recovered by pairing `foo` with `_foo = \"field_…\"`. */\n acf?: Record<string, RawAcfValue>\n /**\n * Language of this post as the multilingual plugin reports it — Polylang's\n * `lang` slug (`tr`), WPML's `wpml_current_locale` (`tr_TR`), or the WXR\n * `language` taxonomy term. Absent on monolingual sites. Which posts are\n * translations of each other is `RawIR.language_pairs`, not this field.\n */\n lang?: string | null\n}\n\nexport interface RawAttachment {\n id: number\n title: string\n slug: string\n /** Original file URL. Downloading/re-hosting is a consumer concern. */\n url: string | null\n alt?: string\n caption?: string\n description?: string\n /** Relative upload path (`_wp_attached_file`), when present. */\n file?: string | null\n /** `_wp_attachment_metadata` verbatim (sizes, EXIF, …). */\n image_meta?: unknown\n mime?: string | null\n parent?: number | null\n parent_resolved?: boolean | null\n /**\n * The attachment's own page (`/post-name/image-name/`, `/?attachment_id=12`), which WordPress serves for every\n * upload and search engines may have indexed. Equal to `url` where the site sends attachment pages to the file.\n */\n link?: string | null\n author?: string | null\n date?: string | null\n status?: string\n meta?: Record<string, unknown>\n}\n\n/** Where a menu item points. `resolved` says whether the target exists in this document. */\nexport type RawMenuTarget =\n | { kind: 'url'; url: string; resolved: true }\n | { kind: 'post'; post_type: string; id: number | null; slug: string | null; resolved: boolean }\n | { kind: 'term'; taxonomy: string; id: number | null; slug: string | null; resolved: boolean }\n | { kind: 'archive'; post_type: string; resolved: true }\n | { kind: 'unknown'; resolved: false }\n\nexport interface RawMenuItem {\n /** `nav_menu_item` post id. Negative for an item of a block navigation, which has no id of its own. */\n id: number\n title: string\n order?: number\n /** Parent menu-item id for nested menus. */\n parent?: number | null\n parent_unresolved?: boolean\n url?: string | null\n target: RawMenuTarget\n /** `target` attribute for the rendered link (e.g. \"_blank\"). */\n target_attr?: string | null\n classes?: string[]\n description?: string\n status?: string\n}\n\nexport interface RawMenu {\n /** Menu term id; for a block theme's navigation, the `wp_navigation` post id. */\n id: number | null\n slug: string\n name: string\n items: RawMenuItem[]\n /**\n * Where the site shows it: the theme locations a classic menu is assigned to (`primary`, `footer`,\n * `menu-1`, …), or the template-part areas (`header`, `footer`) whose navigation block shows a\n * block theme's navigation. Absent when the producer does not know.\n */\n locations?: string[]\n}\n\nexport interface RawComment {\n id: number\n /** Post id this comment belongs to. */\n post: number\n post_type?: string\n parent?: number | null\n parent_resolved?: boolean | null\n author: string\n email?: string | null\n url?: string | null\n /**\n * ISO 8601 UTC. Producers MUST normalize to UTC — comment import fidelity\n * (\"zero record and parent loss\") includes `created_at`, and a site-local\n * date silently corrupts it. When normalization is impossible, ship the\n * source's GMT column in `date_gmt` and leave `date` null.\n */\n date: string | null\n date_gmt?: string | null\n content: string\n /**\n * WordPress approval flag. Fixed vocabulary: `'1'` (approved), `'0'`\n * (pending), `'spam'`, `'trash'` — consumers map these; unknown strings\n * pass through for forward compatibility, never dropped.\n */\n approved?: '1' | '0' | 'spam' | 'trash' | (string & {})\n type?: string\n user_id?: number | null\n meta?: Record<string, unknown>\n}\n\n/**\n * How a redirect's `from` is compared with a request path. Only `url` without\n * `regex` is a plain one-to-one mapping; the others are patterns, and turning\n * one into a literal `from` produces the wrong redirect.\n */\nexport type RawRedirectMatch = 'url' | 'regex' | 'start' | 'contains' | 'end'\n\n/**\n * How a rule treats the request's query string (the Redirection plugin's own three modes):\n * `exact`: the query must match `from`'s; `ignore`: any query matches, and it is not carried to `to`;\n * `pass`: any query matches, and it is carried to `to`.\n */\nexport type RawRedirectQuery = 'exact' | 'ignore' | 'pass'\n\n/** A redirect rule the live site serves (e.g. from the Redirection plugin — visible from the authenticated rung up). */\nexport interface RawRedirect {\n from: string\n /** Where the rule leads; empty for a rule that answers \"gone\" (410, 451), which leads nowhere. */\n to: string\n /** The answer's code: a 3xx move, or 410/451 for an address the site says is gone. Absent means 301. */\n status?: number\n /** Which plugin/table produced the rule. */\n source?: string\n /** Stable id within the export, `<source>:<source-specific id>`. */\n id?: string\n /** How `from` matches; absent means `url`. */\n match?: RawRedirectMatch\n /** `from` is a regular expression in the source's own dialect. */\n regex?: boolean\n /** The source's own module/group serving it, when it has one (e.g. Redirection's \"Apache\"). */\n served_by?: string\n /** An adjustment the extractor made, stated rather than hidden (e.g. a non-redirect status read as 301). */\n status_note?: string\n /** How the request's query string is matched and carried; absent: the source did not say. */\n query?: RawRedirectQuery\n /** `from` matches without regard to letter case. */\n case_insensitive?: boolean\n /** `/a` and `/a/` are one address for this rule (`ignore`) or two (`exact`); absent: the source did not say. */\n trailing_slash?: 'exact' | 'ignore'\n}\n\n/**\n * A rule a source holds that the site does not serve as a plain redirect, with\n * the reason. Every rule lands in `RawIR.redirects` or here, so the two\n * together account for the source's whole table.\n */\nexport interface RawRedirectExcluded extends Omit<Partial<RawRedirect>, 'match'> {\n id: string\n source: string\n /** The source's own match type — may be a condition (`login`, `referrer`, …) rather than a {@link RawRedirectMatch}. */\n match?: RawRedirectMatch | (string & {})\n reason:\n | 'disabled'\n | 'source-inactive'\n | 'slug-reused'\n | 'no-slug-address'\n | `not-a-redirect:${string}`\n | `conditional-match:${string}`\n /** The condition a conditional rule depends on, verbatim. */\n condition?: unknown\n}\n\n// ─── SEO ───\n\nexport const SEO_PROVIDERS = ['yoast', 'rank_math', 'aioseo', 'seopress'] as const\nexport type SeoProvider = (typeof SEO_PROVIDERS)[number]\n\n/** Whether a provider runs on the site, and its version. */\nexport interface SeoProviderStatus {\n status: 'active' | 'inactive-with-data' | 'absent'\n version?: string\n}\n\n/** One page's SEO as one provider holds it. */\nexport interface RawSeoEntry {\n /** true: the running plugin rendered these values; false: stored values and templates only. */\n resolved?: boolean\n title?: string\n description?: string\n canonical?: string\n robots?: { index?: 'index' | 'noindex', follow?: 'follow' | 'nofollow', advanced?: string[] }\n /** The directives the page actually carries, after WordPress core and the plugin reconcile them. */\n robots_served?: string[]\n open_graph?: { title?: string, description?: string, type?: string, url?: string, image?: string, site_name?: string }\n twitter?: { card?: string, title?: string, description?: string, image?: string }\n focus_keyword?: string\n /** Schema.org types, and the JSON-LD graph when the plugin rendered it. */\n schema?: { types: string[], graph?: unknown }\n /** What the plugin stored for this page (templates such as `%%title%% %%sep%%`), secrets removed. */\n stored?: Record<string, unknown>\n /**\n * The page's SEO as the exporter rendered the plugin's templates itself,\n * for a plugin that is not the one serving the live head (`resolved` is\n * not true): final text, no template tokens. A template variable it could\n * not render is taken out of the string (and the separators it leaves\n * tidied) and named in `unresolved`.\n */\n rendered?: {\n title?: string\n description?: string\n canonical?: string\n robots?: { index?: 'index' | 'noindex', follow?: 'follow' | 'nofollow' }\n /** `image_width` / `image_height` when the image is a local attachment of known size. */\n open_graph?: { title?: string, description?: string, image?: string, image_width?: number, image_height?: number }\n twitter?: { title?: string, description?: string, image?: string, image_width?: number, image_height?: number }\n /** Schema nodes the plugin stored for the page (Rank Math), their variables rendered, the plugin's own `metadata` key removed. */\n schema?: { graph: object[] }\n }\n /** Who rendered `rendered`, e.g. `bridge`. */\n rendered_by?: string\n /** Where each rendered value's template came from: the page's own, its post type's, or the plugin default. */\n template_source?: Partial<Record<'title' | 'description' | 'robots', 'post' | 'post_type' | 'default'>>\n /** Template tokens `rendered` could not resolve (e.g. `%customfield(x)%`) — removed from its strings, listed for a person to see. */\n unresolved?: string[]\n}\n\n/** A provider's site-wide settings. */\nexport interface RawSeoSettings {\n separator?: string | null\n /** Keyed by context: `post`, `page`, `home`, `author`, `archive`, `tax:<taxonomy>`, `ptarchive-<type>`, … */\n title_templates: Record<string, string>\n description_templates: Record<string, string>\n noindex?: Record<string, boolean>\n social?: Record<string, unknown>\n /** Site verification codes — public meta values. */\n verification?: Record<string, string>\n /** Option name → cleaned value, verbatim otherwise. */\n raw: Record<string, unknown>\n}\n\n/**\n * The site's SEO layer: which plugin serves the head, its settings, and each\n * page's values. `status: 'none'` is an answer — no SEO plugin, and WordPress\n * core renders only the title — not a missing export.\n */\nexport interface RawSeo {\n /** Producer's format identifier, e.g. `contentrain-bridge-seo@1`. */\n format?: string\n status: 'present' | 'none'\n /** Whose output the live site serves. */\n serving: SeoProvider | 'wordpress-core'\n /** SEOPress is optional here: exports made before it was covered do not name it. */\n providers: Record<Exclude<SeoProvider, 'seopress'>, SeoProviderStatus> & Partial<Record<'seopress', SeoProviderStatus>>\n settings: Partial<Record<SeoProvider, RawSeoSettings>>\n /** `post:<id>` or `term:<taxonomy>:<id>` → one block per provider that has data for it. */\n entries: Record<string, Partial<Record<SeoProvider, RawSeoEntry>>>\n /**\n * The home page's SEO, one block per provider, when the home page lists\n * posts: it is no record, so it has no `post:<id>` entry. A static front\n * page is its page's `post:<id>`.\n */\n home?: Partial<Record<SeoProvider, RawSeoEntry>>\n /** Values deliberately not exported (e.g. a sensitive key), by address. */\n excluded?: { source: string, reason: string }[]\n}\n\n// ─── Routing ───\n\n/** A page WordPress routes to itself (front page, posts page). */\nexport interface RawRoutedPage {\n id: number\n slug: string\n /** Site-root-relative address. */\n path: string\n}\n\n/**\n * The site's URL rules — what `RawPost.link` holds as facts, stated as the\n * structure that produced them. Permastructs are site-root-relative with\n * `front` already applied where `with_front` asks for it.\n */\nexport interface RawRouting {\n /** Producer's format identifier, e.g. `contentrain-bridge-routing@1`. */\n format?: string\n /** The site's home URL. */\n home?: string\n permalink_structure: string\n /** Plain `?p=` permalinks — no structure at all. */\n plain: boolean\n trailing_slash: boolean\n front: string\n category_base: string\n tag_base: string\n pagination_base: string\n author_base: string\n search_base: string\n feed_base?: string\n comments_pagination_base?: string\n author_structure?: string | null\n date_structure?: string | null\n page_structure?: string | null\n /** `posts` or `page`. */\n show_on_front: string\n page_on_front?: RawRoutedPage | null\n page_for_posts?: RawRoutedPage | null\n posts_per_page: number\n post_types: {\n name: string\n hierarchical: boolean\n rewrite: { slug?: string, with_front?: boolean, feeds?: boolean, pages?: boolean } | false\n permastruct: string | null\n has_archive: boolean\n archive_path: string | null\n query_var: string | false\n }[]\n taxonomies: {\n name: string\n object_types: string[]\n hierarchical: boolean\n rewrite: { slug?: string, with_front?: boolean, hierarchical?: boolean } | false\n permastruct: string | null\n }[]\n}\n\n// ─── Hardcoded text ───\n//\n// Interface text that lives outside the content tables — theme templates,\n// scripts, widgets, menus, options, Customizer mods, and what the rendered\n// pages show. Every piece found gets exactly one outcome: transferred to a\n// named target, excluded with a reason, or — for a source that could not be\n// read — an error. Nothing is dropped silently.\n\n/** Where a candidate was found. */\nexport const TEXT_CANDIDATE_KINDS = [\n 'php-gettext',\n 'php-html',\n 'php-echo',\n 'html',\n 'js',\n 'widget',\n 'menu',\n 'option',\n 'customizer',\n 'render',\n] as const\n\nexport type RawTextCandidateKind = (typeof TEXT_CANDIDATE_KINDS)[number]\n\n/** Why a candidate is not transferred. `RawTextCandidate.reason` joins one or more with `,`. */\nexport const TEXT_EXCLUDE_REASONS = [\n 'empty',\n 'too-long',\n 'secret',\n 'code',\n 'url',\n 'number',\n 'no-letters',\n 'placeholder-only',\n 'dynamic',\n 'not-text',\n 'content',\n 'rendered-from-source',\n] as const\n\nexport type RawTextExcludeReason = (typeof TEXT_EXCLUDE_REASONS)[number]\n\n/** Where a transferred candidate lands in the store. */\nexport type RawTextTarget =\n | 'dictionary:ui-strings'\n | `theme-settings.${string}`\n | 'site.title'\n | 'site.description'\n | 'content:wp-menu-items'\n\n/** One place a candidate's text was found. */\nexport interface RawTextOccurrence {\n kind: RawTextCandidateKind\n /** A file path relative to `wp-content`, `render:<state>`, `nav_menu_item#<id>`, … */\n source: string\n /** 1-based line in a file; `0` for a source without lines (render, menu, option). */\n line: number\n}\n\n/**\n * One piece of interface text in one place. Candidates merge only when text,\n * locale and context are all equal: \"Read more\" in an `a` and in a `button`\n * are two candidates, and so are `_x('Post', 'noun')` and `_x('Post', 'verb')`.\n */\nexport interface RawTextCandidate {\n /** `sha256(text \\0 locale \\0 context)`, first 20 hex characters. */\n id: string\n /** The text; `[redacted]` when `reason` includes `secret`. */\n value: string\n locale: string\n /**\n * Where the text sits: an HTML tag (`a`, `input@placeholder`),\n * `gettext[:<ctx>][:plural]`, `js`, `widget:<tag>` / `widget:title`, `menu`,\n * `option:<name>`, `customizer:<mod>`, or for rendered text\n * `<landmark>><tag>` (`footer>p`, `nav>nav@aria-label`).\n */\n context: string\n kind: RawTextCandidateKind\n /** The first occurrence's `source` and `line`. */\n source: string\n line: number\n /** Every place the text was found, in the producer's listing order. */\n occurrences: RawTextOccurrence[]\n outcome: 'transfer' | 'exclude'\n /** Set exactly when `outcome` is `transfer`. */\n target?: RawTextTarget\n /** Set exactly when `outcome` is `exclude`: one or more `RawTextExcludeReason`s joined with `,`. */\n reason?: string\n /**\n * With `rendered-from-source`: ids of the source candidates this page text\n * came from. Page text found in source is excluded here and transferred\n * there, so the same words never land twice and contexts are not merged.\n */\n related?: string[]\n /**\n * The store key: `<group>.<context>.<words>-<sha256(text \\0 context)[0:6]>`,\n * or `theme-settings.<mod>` for a Customizer value. A function of text and\n * context only — moving a string to another file changes no key. A\n * collision fails the export rather than merging two texts.\n */\n key: string\n decision: 'review' | 'include' | 'exclude'\n}\n\n/**\n * Every piece of interface text a site shows, each with one outcome. Totals\n * close: `by_outcome.transfer + by_outcome.exclude = candidates`,\n * `by_outcome.error = errors.length`, and `occurrences` counts every\n * occurrence the scanners found — listed ones plus those past the per-file\n * listing cap, which are counted by reason instead.\n */\nexport interface RawHardcodedText {\n /** Producer's format identifier, e.g. `contentrain-bridge-hardcoded-text@1`. */\n format: string\n candidates: RawTextCandidate[]\n /**\n * Sources that could not be read — an outcome, not a silent gap. Reasons:\n * `source-over-2MiB`, `source-unreadable`, `render-fetch-failed: <code>`,\n * `render-http-<status>`, `render-no-body`.\n */\n errors: { source: string, reason: string }[]\n totals: {\n occurrences: number\n occurrences_listed: number\n /** Occurrences past the listing cap: excluded and counted, not listed. */\n unlisted_excluded: number\n unlisted_by_reason: Record<string, number>\n candidates: number\n by_outcome: { transfer: number, exclude: number, error: number }\n sources: { files: number, settings: number, render_states: number }\n }\n}\n\n// ─── Integrations ───\n//\n// Outside services the site is connected to — analytics, CRM, newsletter,\n// captcha, CDN, embeds. A static site cannot carry these connections over:\n// each one with an account must be connected again, with the new site's own\n// credentials. Credentials never leave WordPress; only whether one is set does.\n\nexport const INTEGRATION_CATEGORIES = ['analytics', 'crm', 'newsletter', 'ads', 'comments', 'captcha', 'cdn', 'other'] as const\n\nexport type IntegrationCategory = (typeof INTEGRATION_CATEGORIES)[number]\n\nexport const INTEGRATION_EVIDENCE_KINDS = ['plugin', 'option-key', 'script-domain', 'form-config', 'embed-domain'] as const\n\nexport type IntegrationEvidenceKind = (typeof INTEGRATION_EVIDENCE_KINDS)[number]\n\n/** One outside service the site uses, and why we believe it does. */\nexport interface RawIntegration {\n /** Catalog id: `mailchimp`, `hubspot`, `google-tag-manager`, `jetpack-stats`, `embed-youtube`, … */\n service: string\n /** Display name. */\n name: string\n category: IntegrationCategory\n /**\n * Never a value. `detail` names a plugin and its version, a setting by name\n * (`mc4wp (api_key)`), a script host and where it was seen\n * (`static.hotjar.com @ render:home`), a form's wiring, or an embed host.\n */\n evidence: { kind: IntegrationEvidenceKind, detail: string }[]\n /** An account must be connected again on the new site; `false` for an embed, which has no account. */\n reconnect_required: boolean\n /** A credential is configured on WordPress. Only this boolean is exported, never the value. */\n secret_present: boolean\n /** What to do, in words for the person receiving the site. */\n notes: string\n}\n\n/**\n * The document a bridge writes (`bridge/integrations.json`). `RawIR.integrations`\n * carries its `services`. An empty `services` with `scanned` beside it is an\n * answer — the site uses no outside service we recognise — not a missing scan.\n */\nexport interface RawIntegrationScan {\n /** Producer's format identifier, e.g. `contentrain-bridge-integrations@1`. */\n format: string\n /** How much was looked at: option and post counts, active plugins, script sources, and whether the home page was rendered. */\n scanned: { options: number, plugins: number, posts: number, rendered_home: boolean, script_sources: number }\n services: RawIntegration[]\n totals: { services: number, reconnect_required: number }\n}\n\n/**\n * The intake / handoff issue for services that must be connected again. Raised\n * once, listing every `RawIntegration` with `reconnect_required: true`; not\n * raised when there are none.\n */\nexport interface IntegrationReconnectRequiredIssue {\n code: 'integration_reconnect_required'\n services: Pick<RawIntegration, 'service' | 'name' | 'category' | 'secret_present'>[]\n}\n\n/**\n * Translation grouping for multilingual sites. The REST rungs read it from\n * Polylang's `translations` / WPML's `wpml_translations` post fields, WXR from\n * the `post_translations` taxonomy; the bridge rung from the plugin tables.\n * One pair per group; `translations` includes the post itself.\n */\nexport interface RawLanguagePair {\n post: number\n /** locale → post id of the translation. */\n translations: Record<string, number>\n}\n\n/**\n * The complete raw extraction of one WordPress site at one access rung.\n * This is the boundary document between extraction (Bridge/importers) and\n * everything downstream — content conversion, capability analysis, migration.\n */\nexport interface RawIR {\n version: number\n provenance: RawProvenance\n site: RawSite\n authors: RawAuthor[]\n terms: RawTerm[]\n posts: RawPost[]\n attachments: RawAttachment[]\n menus?: RawMenu[]\n comments?: RawComment[]\n redirects?: RawRedirect[]\n /** Rules the sources hold that the site does not serve as plain redirects. */\n redirects_excluded?: RawRedirectExcluded[]\n seo?: RawSeo\n routing?: RawRouting\n /** Interface text outside the content tables, each piece with one outcome. */\n hardcoded_text?: RawHardcodedText\n /** Outside services the site is connected to (`RawIntegrationScan.services`). */\n integrations?: RawIntegration[]\n language_pairs?: RawLanguagePair[]\n /** Site options (bridge rung), verbatim. */\n options?: Record<string, unknown>\n /** ACF Options Pages (bridge rung): site-wide fields, one entry per page. */\n acf_options?: RawAcfOptionsPage[]\n}\n\n// ─── CapabilityManifest ───\n//\n// What the site *uses*, as detected — the input for migration planning,\n// effort/pricing, and the \"what happens to X\" conversation with the user.\n// Detection is evidence-based and never certain; `present: true` with evidence\n// beats a silent guess.\n\nexport const CAPABILITY_KEYS = [\n 'seo',\n 'forms',\n 'comments',\n 'search',\n 'newsletter',\n 'analytics',\n 'consent',\n 'share',\n 'video',\n 'ads',\n 'builder',\n 'blocks',\n 'i18n',\n 'cache',\n 'security',\n 'acf',\n 'ecommerce',\n 'jetpack',\n 'redirects',\n 'membership',\n 'accessibility',\n 'media',\n 'scheduling',\n] as const\n\nexport type CapabilityKey = (typeof CAPABILITY_KEYS)[number]\n\nexport interface CapabilityDetection {\n present: boolean\n /** Identified plugin/provider, when known (e.g. \"yoast\", \"contact-form-7\"). */\n plugin?: string | null\n /** How it was detected: \"rest\", \"dom\", \"slug\", \"headers\", \"meta\", … */\n evidence?: string[]\n detail?: string\n}\n\nexport interface CapabilityManifest {\n version: number\n site_url: string\n access: {\n html_status?: number | null\n rest_status?: number | null\n /**\n * Highest source-access rung actually achieved for this site, or `null`\n * when none was — the site answered nothing a rung describes.\n *\n * Nullable rather than optional, and rather than a `'none'` rung. Optional\n * would let a producer omit the field and call that \"not measured\", which\n * is the failure this field exists to prevent: a manifest that reports\n * `rest_public` for every site makes the access ladder unreadable, and the\n * ladder is what the coverage figures are computed from. Required-and-\n * nullable makes \"I reached nothing\" a thing the producer has to say.\n *\n * A `'none'` rung was the other candidate and is worse: `SourceAccessKind`\n * is also `RawProvenance.kind`, where it answers \"how was this data\n * obtained\". A RawIR stamped `kind: 'none'` is a document that exists\n * without having been obtained — an impossible state made representable in\n * an unrelated contract. `SOURCE_ACCESS_LADDER` would stop being a ladder\n * too: it is an ordered array and coverage arithmetic indexes into it.\n *\n * `null` reads with `rest_status: null` beside it as its evidence. Coverage\n * arithmetic should drop these sites from the numerator, not score them\n * zero — a site behind an access wall was not measured, it did not fail.\n */\n achieved: SourceAccessKind | null\n }\n generator?: string | null\n theme?: string | null\n plugins?: string[]\n rest_namespaces?: string[]\n custom_post_types?: Array<{\n slug: string\n /** Whether the CPT is exposed in public REST (`rest: false` CPTs need a higher rung). */\n rest_visible: boolean\n count?: number | null\n }>\n /** Comments get first-class treatment: they are a launch-critical capability. */\n comments: {\n active: boolean\n form_status?: 'open' | 'closed' | 'mixed' | null\n rest_total?: number | null\n plugin?: string | null\n }\n capabilities: Partial<Record<CapabilityKey, CapabilityDetection>>\n /** Dynamic front-end behaviors observed in the DOM (\"carousel\", \"modal\", \"search-overlay\", …). */\n behaviors?: string[]\n languages?: string[]\n}\n\n// ─── ProjectIR ───\n//\n// The reproducible model of the site: not \"this page's HTML\" but the design\n// system, component architecture, route model, and content queries that let\n// *unseen* pages — tomorrow's posts, another category — be generated correctly.\n// Produced by analysis; consumed by the open emitter. Three layers are kept\n// together on purpose: visual structure (families/chrome/tokens), content\n// binding (slots), and business rules (routes/queries). A page family exists\n// per genuinely different behavior, not per page.\n\n/**\n * How the legacy CSS for a family was packaged.\n * - `purge_set`: rules kept if used by any page in the family's page set —\n * small file, safe against per-page rule loss (single-page purging is not).\n * - `localcss`: the site's stylesheets localized wholesale — larger, zero risk.\n */\nexport type CssStrategy = 'purge_set' | 'localcss'\n\n/**\n * Cascade layer name the emitter quarantines legacy CSS under, so a modern\n * utility layer (e.g. Tailwind) can coexist with pixel-faithful migrated pages.\n */\nexport const LEGACY_CSS_LAYER = 'legacy'\n\n/** Design tokens extracted from the source site, e.g. for a Tailwind `@theme` block. */\nexport interface DesignTokens {\n colors?: Record<string, string>\n font_families?: Record<string, string>\n font_sizes?: Record<string, string>\n spacing?: Record<string, string>\n breakpoints?: Record<string, string>\n}\n\nexport type RouteKind =\n | 'front'\n | 'single'\n | 'page'\n | 'archive'\n | 'term'\n | 'author'\n | 'date'\n | 'search'\n | 'not_found'\n | 'custom'\n\nexport interface RouteParamDef {\n name: string\n /**\n * Where the value comes from. The date parts exist because\n * `/%year%/%monthnum%/%day%/%postname%/` is a common WordPress permalink\n * structure: treating the template post's date as a fixed prefix sends every\n * other post to a wrong address, and SEO continuity is the point of a\n * migration. Posts carry their own values (`EmitPost.params`).\n */\n source:\n | 'post_slug'\n | 'post_id'\n | 'post_year'\n | 'post_month'\n | 'post_day'\n | 'term_slug'\n | 'author_slug'\n | 'page_number'\n | 'custom'\n}\n\n/** Selects a family/component variant when a route parameter matches. */\nexport interface RouteVariantRule {\n param: string\n in: string[]\n variant: string\n}\n\n/**\n * One route pattern → one layout family. Pagination is a parameter of a route\n * (`/news/page/2` is `/news` with `page_number = 2`), never a separate family —\n * across a 30-site corpus, page 2 never produced a new layout.\n */\nexport interface RouteModel {\n id: string\n /**\n * URL pattern with `:param` placeholders — `/category/:term`, `/news/page/:page`.\n *\n * A trailing `*` marks a **multi-segment (rest) parameter**: `/category/:term*`\n * matches `/category/about-cc/events/` with `term = \"about-cc/events\"`.\n * Hierarchical taxonomies and nested pages need this — a single-segment\n * parameter silently flattens `/category/about-cc/events/` to\n * `/category/events/`, breaking link continuity for every nested address.\n */\n pattern: string\n kind: RouteKind\n /** LayoutFamily id. */\n family: string\n params?: RouteParamDef[]\n /**\n * Content collection this route generates one page per entry from. `single`\n * routes default to `posts`; naming a collection makes any route\n * collection-driven — a site's pages and custom post types are per-entry\n * routes too, they just are not called \"single\".\n *\n * Without a name per route they would all write to (and overwrite) the same\n * collection.\n */\n collection?: string\n /** QueryBinding id feeding this route's list, when it renders one. */\n query?: string\n /**\n * Document title for routes whose pages have no per-page title of their own\n * (a static page, an archive without `QueryPage.title`).\n */\n title?: string\n /**\n * Locale this route serves. A multilingual site has a route per language\n * (`/:slug`, `/en/:slug`) with its own family and query; the emitted page's\n * `lang` comes from here rather than from the project default.\n */\n locale?: string\n variant_rules?: RouteVariantRule[]\n}\n\nexport type SlotKind =\n | 'body'\n | 'title'\n | 'date'\n | 'excerpt'\n | 'author_name'\n | 'author_link'\n | 'featured_image'\n | 'term_list'\n | 'self_link'\n | 'custom'\n\n/** Where a content field lands in a family's markup. */\nexport interface SlotBinding {\n kind: SlotKind\n /** The region is printed only on posts that have this slot filled — see `CHROME_IF_OPEN`. */\n optional?: boolean\n /** The region repeats per list item (terms, authors) — see `CHROME_REPEAT_OPEN`. */\n repeat?: boolean\n /** CSS selector of the slot's container, when one could be determined. */\n selector?: string\n /** Content-model field backing a `custom` slot. */\n field?: string\n /** Date rendering format for `date` slots, as observed on the source site. */\n date_format?: string\n}\n\n/**\n * Marks whose name ends in this suffix are inserted as raw HTML instead of\n * being escaped. Escaping stays the default — content-derived text must never\n * be able to break the page — but some themes render a post's full content or\n * a link-bearing excerpt inside a list card, and escaping those prints markup\n * as text (measured on one theme's category page: 48.6).\n */\nexport const RAW_MARK_SUFFIX = '_html'\n\n/**\n * Repeat block: `<!--@@repeat:NAME@@-->…<!--@@/repeat@@-->`, optionally with a\n * separator — `<!--@@repeat:NAME|, @@-->`. The inner fragment renders once per\n * item of the list named NAME, with per-item marks `item` / `item_index` (and\n * `item_<key>` for object items).\n *\n * Fixed marks (`term0…termN`) cannot express a list whose length varies per\n * post: a template built from a 3-term post leaves stray separators on a\n * 6-term one (measured symptoms: `\"Business,\"`, `\"Releases, Events,\"`,\n * `\"Automattic, ,\"`), and the same class shows up with multiple authors.\n */\nexport const CHROME_REPEAT_OPEN = '<!--@@repeat:'\nexport const CHROME_REPEAT_CLOSE = '<!--@@/repeat@@-->'\n\n/**\n * Conditional block: `<!--@@if:NAME@@-->…<!--@@/if@@-->`, negated with\n * `<!--@@if:!NAME@@-->`. The fragment survives only when NAME has a value\n * (non-empty string, non-empty list).\n *\n * Route-parameter variants (`RouteVariantRule`) cannot express this: two posts\n * on the SAME route differ by whether the theme printed a region at all\n * (measured: five posts of one family scored 3.5–84.5, the only difference\n * being a featured block that some posts render and others do not).\n */\nexport const CHROME_IF_OPEN = '<!--@@if:'\nexport const CHROME_IF_CLOSE = '<!--@@/if@@-->'\n\n/**\n * Marker a list section's wrapper carries where its items go —\n * `<div class=\"grid\"><!--@@items@@--></div>`.\n */\nexport const LIST_ITEMS_SLOT = '<!--@@items@@-->'\n\n/**\n * Marker a `body`-position chrome chunk carries where the page content goes.\n * A comment survives serialization, renders as nothing if ever left behind,\n * and — critically — can sit at ANY nesting depth: real themes put the content\n * container deep inside the chrome (`article > div.entry-content`), so the\n * chrome cannot be split into balanced before/after fragments. The emitter\n * splices content in at this marker and injects the result as ONE fragment.\n */\nexport const CHROME_BODY_SLOT = '<!--@@body@@-->'\n\n/**\n * Mount point of a component inside chrome: `<!--@@component:ID@@-->`, where\n * ID is a `ComponentDef.id`. The producer replaces the source region (the\n * theme's comment list and form, a contact form) with this marker; the emitter\n * renders the real component there and imports it into the layout, so a\n * runtime component is MOUNTED, not merely emitted as an unused file.\n *\n * Same shape as `CHROME_BODY_SLOT` for the same reasons: a comment survives\n * serialization, renders as nothing if left behind, and can sit at any depth.\n */\nexport const CHROME_COMPONENT_OPEN = '<!--@@component:'\nexport const CHROME_COMPONENT_CLOSE = '@@-->'\n\n/** The marker for one component id — `componentSlot('c-comments')`. */\nexport const componentSlot = (id: string): string => `${CHROME_COMPONENT_OPEN}${id}${CHROME_COMPONENT_CLOSE}`\n\n/**\n * A rendered, asset-rewritten chunk of site chrome the emitter injects verbatim.\n * Positions: `head` lands in `<head>`; `body` is the whole body chrome carrying\n * `CHROME_BODY_SLOT` where content goes (preferred — nesting-safe); the legacy\n * `before_body`/`after_body` pair is composed into a single body with the slot\n * between them (only correct when the content container is top-level).\n *\n * `header` and `footer` lift a region OUT of the body chrome into its own Astro\n * component, rendered as a sibling of the body fragment. They are for the two\n * regions a site actually shares — the masthead and the colophon — and they buy\n * two things one blob cannot: families that carry the same header emit ONE\n * component instead of N copies of the markup, and the nav lives at a single\n * address, which is where a jQuery-free menu replaces the theme's.\n *\n * The producer emits them ONLY after verifying both properties:\n *\n * 1. **Balanced.** The fragment closes every element it opens. Splitting chrome\n * at an arbitrary point produces halves the parser silently repairs — the\n * failure that cost a page 36 against 100 and the reason `body` exists.\n * 2. **Outside the content path.** The region sits wholly before (header) or\n * after (footer) `CHROME_BODY_SLOT` and is not an ancestor of it. A region\n * that wraps the content cannot be lifted; it belongs in `body`.\n *\n * When either is in doubt, keep everything in one `body` chunk: a single blob\n * is always correct, and this split is an optimisation on top of it.\n */\nexport interface ChromeChunk {\n id: string\n position: 'head' | 'body' | 'before_body' | 'after_body' | 'header' | 'footer'\n html: string\n /**\n * Shared component name for a `header`/`footer` chunk — `SiteHeader`,\n * `BlogFooter`. Chunks that carry the same name AND the same html collapse\n * into one emitted component; same name with different html gets a suffixed\n * name and a warning, never a silent swap. Default: `SiteHeader`/`SiteFooter`.\n */\n component?: string\n}\n\nexport interface ComponentPlacement {\n /** ComponentDef id. */\n component: string\n variant?: string\n selector?: string\n /**\n * `QueryBinding.id` whose results fill this region, instead of the cloned\n * markup that was there.\n *\n * The case this exists for: a theme's \"recent posts\" block sits in the\n * chrome of every article. Cloned, it freezes on the day of the migration —\n * it keeps listing the same posts forever, and nobody notices because it\n * still looks right. Bound to a query it stays current and becomes editable,\n * which is the whole difference between a copy of a site and a site.\n *\n * On the placement rather than on `ComponentDef`, because it is a per-mount\n * fact: one `related` component can be mounted by a post family filtered to\n * the post's category and by an author family filtered to the author. The\n * definition says what the region *is*; the placement says what it shows\n * here — the same split `variant` (card shape) and `selector` (region)\n * already follow.\n */\n query?: string\n}\n\nexport interface FamilyVariant {\n key: string\n description?: string\n}\n\n/**\n * Attributes on the document's root elements, carried verbatim from the source\n * page. Themes hang layout on them — WordPress writes `<body class=\"wp-singular\n * post-template-default single …\">` and scripts add `<html class=\"js wf-…\">`,\n * and the stylesheet's container rules key off exactly those classes. Dropping\n * them costs a correct-content page its entire layout (measured: 36.4 vs 100).\n *\n * Values may carry `@@mark@@` placeholders, so per-page classes (`postid-123`)\n * survive the same filling as chrome.\n *\n * Whether a site needs them is not knowable from one page: the same test that\n * moved 10up 36.4 → 100 moved wptavern 36 → 36, because that theme does not key\n * off body classes. Carry them always.\n */\nexport interface RootAttrs {\n html?: Record<string, string>\n body?: Record<string, string>\n}\n\nexport interface LayoutFamily {\n id: string\n name?: string\n kind?: RouteKind\n chrome?: ChromeChunk[]\n /** `<html>` / `<body>` attributes from the source page — see `RootAttrs`. */\n root_attrs?: RootAttrs\n slots?: SlotBinding[]\n components?: ComponentPlacement[]\n css: {\n strategy: CssStrategy\n /**\n * Stylesheets every page of the family loads.\n *\n * Page-builder sites emit CSS per page (`post-11368.css`,\n * `local/global-11368-frontend-*`), so one member's stylesheet set is not\n * the family's: building from a single template page scored 35.6, while\n * the UNION of the members' stylesheets scored 100. Producers put the\n * union here and the per-page remainder on the page itself.\n */\n files?: string[]\n }\n /** Observed column counts per viewport class (e.g. desktop 4 → mobile 1). */\n columns?: Partial<Record<'desktop' | 'mobile', number>>\n variants?: FamilyVariant[]\n /** Verification trail: which pages formed the family, held-out fidelity score. */\n evidence?: {\n pages?: string[]\n holdout_score?: number\n }\n}\n\n/**\n * Component semantic vocabulary. `source` records where its content can come\n * from: `rest` (derivable from content data — author box, term list),\n * `runtime` (needs a live service — comments form, search), `chrome` (carried\n * as rendered markup — nav, ads).\n */\nexport const COMPONENT_TYPES = [\n 'nav',\n 'related',\n 'comments',\n 'form',\n 'ads',\n 'chrome',\n 'taxonomy',\n 'author',\n 'share',\n 'breadcrumb',\n 'meta',\n 'pagination',\n 'card',\n 'custom',\n] as const\n\nexport type ComponentType = (typeof COMPONENT_TYPES)[number]\n\nexport type ComponentSource = 'rest' | 'runtime' | 'chrome'\n\nexport interface ComponentVariantDef {\n key: string\n conditions?: RouteVariantRule[]\n description?: string\n}\n\nexport interface ComponentDef {\n id: string\n type: ComponentType\n source: ComponentSource\n name?: string\n selector?: string\n /**\n * Content model a runtime component talks to — the collection a `form`\n * submits to. A `comments` component needs none: its thread is addressed by\n * the entry of the page it is mounted on.\n */\n model?: string\n /** e.g. a card component with `news`, `compact`, `featured` variants. */\n variants?: ComponentVariantDef[]\n}\n\nexport type ExcerptSource = 'excerpt' | 'content_first_paragraph' | 'none'\nexport type PaginationKind = 'numbered' | 'infinite' | 'none'\n\n/**\n * The query behind a rendered list: which content, filtered how, in what\n * order, how many. Inferred by matching rendered items against candidate\n * queries — order matters, so the sequence is part of the contract.\n */\nexport interface QueryBinding {\n id: string\n /** Content source: \"posts\", \"pages\", a CPT slug, or a content-model id. */\n source: string\n taxonomy?: {\n taxonomy: string\n /** Fixed term slug, or the route param carrying it. */\n term?: string\n term_param?: string\n }\n author?: {\n slug?: string\n param?: string\n }\n order: {\n by: 'date' | 'title' | 'menu_order' | 'custom'\n direction: 'asc' | 'desc'\n }\n per_page: number | null\n pagination: PaginationKind\n excerpt_source?: ExcerptSource\n /** WordPress image size name the list renders (e.g. \"medium_large\"). */\n image_size?: string\n /** Locale this query is filtered to, on a multilingual site. */\n locale?: string\n}\n\n/**\n * - `responsive`: one build, CSS handles viewports.\n * - `split`: desktop and mobile variants are produced separately and served\n * by device — for sites whose mobile DOM differs beyond CSS.\n */\nexport type ViewportStrategy = 'responsive' | 'split'\n\nexport interface ProjectIR {\n version: number\n site: {\n url: string\n title?: string\n locales?: string[]\n }\n routes: RouteModel[]\n families: LayoutFamily[]\n components?: ComponentDef[]\n queries?: QueryBinding[]\n tokens?: DesignTokens\n /** Default CSS packaging for families that don't override it. */\n css_default: CssStrategy\n viewport_strategy?: ViewportStrategy\n /** Content-model ids this project's queries and slots refer to. */\n content_models?: string[]\n}\n\n// ─── Comments export ───\n//\n// Comments cross one more boundary than the rest of RawIR: they leave the\n// generated static site entirely and land in a live service's database,\n// addressed by content entry — not by WordPress post id. This export is that\n// bridge: the source map translates WP ids to entry addresses, and the\n// comments ride along unmodified.\n\nexport const COMMENTS_EXPORT_FORMAT = 'contentrain-comments@1'\n\n/** Where one WordPress post's content ended up. */\nexport interface EntrySourceRef {\n model_id: string\n entry_id: string\n locale?: string\n}\n\n/**\n * WordPress post id (stringified) → content entry address. Only the tool that\n * wrote the content store can produce this; nothing downstream can recover it.\n */\nexport type EntrySourceMap = Record<string, EntrySourceRef>\n\nexport interface CommentsExport {\n version: number\n format: typeof COMMENTS_EXPORT_FORMAT\n source: RawProvenance\n site_url?: string\n /** ISO 8601 UTC. */\n generated_at: string\n entries: EntrySourceMap\n /** WP post ids whose comment form was closed — the receiving side opens those threads closed. */\n threads_closed?: number[]\n /**\n * Verbatim `RawComment`s; parents are re-linked via `RawComment.parent` in a second pass.\n *\n * An allowlist — what travels: comments on **public** entries (published, no post password) that\n * the RawIR holds, whose status is approved (`approved: '1'` or absent) or waiting for moderation\n * (`'0'` — the receiving side puts them in its moderation queue, so the site owner's queue survives\n * the move). Everything else stays behind: comments on drafts, private, scheduled or\n * password-protected entries, on entries the RawIR does not hold, and any other status (spam,\n * trash, WordPress's `post-trashed`, a plugin's own). A reply whose parent was left out keeps its\n * `parent` id; the receiver treats a parent it does not have as none.\n */\n comments: RawComment[]\n /** What was left out and why, counted — so a smaller export is explained, not suspicious. */\n excluded?: { non_public_entry?: number; unknown_entry?: number; spam?: number; trash?: number; other_status?: number }\n}\n\n/**\n * Where the full comments export is. A producer writes at most one of\n * `path`, `url` and `inline`; a consumer that meets more than one reads them\n * in that order (see `commentsExportSource`). With none of them the export\n * exists but has no reference yet (a large export with no repository, or\n * one not readable yet): a valid state, nothing to import.\n *\n * The file at `path` or `url` is the export as UTF-8 JSON; `bytes` and\n * `sha256` are taken over its raw bytes, as stored.\n */\nexport interface HandoffCommentsExport {\n format: typeof COMMENTS_EXPORT_FORMAT\n /**\n * The export as a file in the generated repository: a POSIX path relative\n * to the repository root, in normal form (`comments-export.json`,\n * `data/comments.json`; see `isRepoRelativePath`). The consumer reads it at\n * the same commit it read the handoff from — the commit SHA, not a branch\n * name, which can move between the two reads — with the access it already\n * has to that repository, so a large export in a private repository needs\n * no public URL. A consumer reading a local checkout checks the file is not\n * a symlink (lstat) before reading it.\n */\n path?: string\n /** Where the full export can be fetched… */\n url?: string\n /** …or the export itself, inline, for small sites. */\n inline?: CommentsExport\n /** Size of the file at `path` or `url` in bytes, so a consumer can refuse an oversized one before reading it. */\n bytes?: number\n /** SHA-256 of the file at `path` or `url`, lowercase hex, to check what was read. */\n sha256?: string\n}\n\n/** Comments summary + payload pointer on the handoff (capability counts are not enough for intake). */\nexport interface HandoffComments {\n total: number\n by_status?: Record<string, number>\n types?: Record<string, number>\n export?: HandoffCommentsExport\n threads_closed?: number[]\n unresolved?: Array<{ comment_id: number; post: number; reason: string }>\n /** Comments the export left out on purpose (`CommentsExport.excluded`). */\n excluded?: { non_public_entry?: number; unknown_entry?: number; spam?: number; trash?: number; other_status?: number }\n}\n\n/**\n * A repository-relative POSIX path in normal form: non-empty, relative (no\n * leading `/`), no `.` or `..` or empty segment (so no `./` prefix, no `//`,\n * no trailing `/`), no `.git` segment, no backslash, no scheme or drive (`:`\n * before the first `/`), no control character, and Unicode in NFC. The one\n * form accepted, so two producers cannot spell the same file two ways\n * (`çay` composed or decomposed), and a path can never leave the repository\n * or reach its git internals.\n */\nexport function isRepoRelativePath(path: unknown): path is string {\n if (typeof path !== 'string' || path === '') return false\n if (path.normalize('NFC') !== path) return false\n if (path.includes('\\\\') || [...path].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127)) return false\n if (/^[^/]*:/.test(path)) return false\n return path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..' && segment.toLowerCase() !== '.git')\n}\n\n/** The source a consumer reads the export from, after precedence and validation. */\nexport type CommentsExportSource =\n | { kind: 'path'; path: string }\n | { kind: 'url'; url: string }\n | { kind: 'inline'; export: CommentsExport }\n\n/**\n * Where to read the comments export from: `path`, else `url`, else `inline`.\n * `path` comes first because it is in the same repository and ref as\n * everything else the handoff describes and needs no public access; `url`\n * wins over `inline` as it always has. A `path` that is not a\n * repository-relative path in normal form is ignored — reading it could\n * leave the repository — and so is a `url` that is not http(s).\n */\nexport function commentsExportSource(exp: HandoffCommentsExport | undefined): CommentsExportSource | undefined {\n if (!exp) return undefined\n if (isRepoRelativePath(exp.path)) return { kind: 'path', path: exp.path }\n if (typeof exp.url === 'string' && /^https?:\\/\\//i.test(exp.url)) return { kind: 'url', url: exp.url }\n if (exp.inline && typeof exp.inline === 'object') return { kind: 'inline', export: exp.inline }\n return undefined\n}\n\n/** What `validateHandoffCommentsExport` found: `errors` make the pointer wrong, `warnings` weaker than it should be. */\nexport interface HandoffCommentsExportReport {\n errors: string[]\n warnings: string[]\n}\n\n/**\n * What is wrong with a handoff's export pointer, as short sentences. For a\n * producer's own check and a consumer's report — it does not decide what to\n * read (`commentsExportSource` does). A pointer with no source at all is\n * valid (the export has no reference yet) and reports nothing.\n */\nexport function validateHandoffCommentsExport(exp: HandoffCommentsExport): HandoffCommentsExportReport {\n const issues: string[] = []\n const warnings: string[] = []\n if (exp.format !== COMMENTS_EXPORT_FORMAT) issues.push(`format is not ${COMMENTS_EXPORT_FORMAT}`)\n const given = (['path', 'url', 'inline'] as const).filter((key) => exp[key] !== undefined)\n if (given.length > 1) issues.push(`more than one of path, url, inline is set (${given.join(', ')}); path is read first`)\n if (exp.path !== undefined && !isRepoRelativePath(exp.path)) issues.push('path is not a repository-relative POSIX path in normal form')\n if (exp.url !== undefined && !(typeof exp.url === 'string' && /^https?:\\/\\//i.test(exp.url))) issues.push('url is not an http(s) URL')\n if (exp.bytes !== undefined && !(Number.isSafeInteger(exp.bytes) && exp.bytes >= 0)) issues.push('bytes is not a non-negative integer')\n if (exp.sha256 !== undefined && !(typeof exp.sha256 === 'string' && /^[0-9a-f]{64}$/.test(exp.sha256))) issues.push('sha256 is not 64 lowercase hex characters')\n if ((exp.bytes !== undefined || exp.sha256 !== undefined) && exp.path === undefined && exp.url === undefined) issues.push('bytes and sha256 describe a file at path or url, and neither is set')\n if (exp.path !== undefined || exp.url !== undefined) {\n if (exp.sha256 === undefined) warnings.push('sha256 is missing — what is read cannot be checked')\n if (exp.bytes === undefined) warnings.push('bytes is missing — an oversized export cannot be refused before reading it')\n }\n return { errors: issues, warnings }\n}\n\n// ─── Runtime binding ───\n//\n// A runtime component (comments, forms) talks to a live service. The static\n// site only needs two facts to do that: where the public API lives and which\n// project it belongs to. Everything else — the model, the entry, the locale —\n// is already on the page that mounts the component.\n\n/**\n * Public API root of the runtime provider plus the project id. `base_url` is\n * the origin (`https://studio.contentrain.io`); components append their own\n * `/api/{forms,comments}/v1/{project_id}/…` paths. No credential travels with\n * it: the public endpoints are unauthenticated by design, and a browser page\n * cannot keep a secret.\n */\nexport interface RuntimeBinding {\n base_url: string\n project_id: string\n}\n\n// ─── MigrationHandoff ───\n//\n// What the migration hands the user: where the generated project lives, what\n// happened to each detected capability, and — for capabilities that need a\n// runtime — the offers. Offering is this document's job; fulfilling an offer\n// is the receiving product's. The migration itself is complete without any\n// offer being accepted.\n\nexport type CapabilityDisposition =\n | 'migrated_static'\n | 'archived'\n | 'needs_runtime'\n | 'external_adapter'\n | 'kept_on_wordpress'\n | 'dropped'\n\nexport interface HandoffCapability {\n key: CapabilityKey\n disposition: CapabilityDisposition\n detail?: string\n /** e.g. comments: { migrated: 412, spam_skipped: 60 } */\n counts?: Record<string, number>\n}\n\nexport type OfferProvider = 'studio_managed' | 'adapter' | 'keep_wordpress'\n\nexport interface CostEstimate {\n currency: string\n monthly: number\n assumptions?: string[]\n}\n\n/**\n * The comparison shown when a runtime capability is offered: what running the\n * old server costs (maintenance, security, hosting) against the managed\n * option — so declining is an informed choice, not a default.\n */\nexport interface CostComparison {\n self_host?: CostEstimate\n managed?: CostEstimate\n}\n\nexport interface HandoffOffer {\n capability: CapabilityKey\n provider: OfferProvider\n /** Adapter identifier when `provider` is `adapter`. */\n adapter?: string\n cost_comparison?: CostComparison\n /** e.g. \"keeping comments on WordPress means the WordPress server stays live\". */\n warning?: string\n}\n\n/**\n * What a standalone page's body is, as far as the migration can carry it.\n *\n * - `html` — markup written in the editor. It travels as content and stays\n * editable.\n * - `builder_html` — markup a page builder rendered (the builder's own data\n * is not HTML). It travels as it was rendered: the page looks right, but its\n * layout is no longer edited in the builder that made it.\n * - `none` — no body; the theme renders the page from other data. Nothing to\n * carry as content.\n *\n * Counted because they promise different things to the person receiving the\n * site, and a single \"pages migrated\" number hides which promise was made.\n */\nexport const PAGE_BODY_KINDS = ['html', 'builder_html', 'none'] as const\nexport type PageBodyKind = (typeof PAGE_BODY_KINDS)[number]\n\nexport interface MigrationHandoff {\n version: number\n site_url: string\n /** ISO 8601 UTC. */\n generated_at: string\n repository?: {\n provider: 'github' | 'gitlab'\n owner: string\n name: string\n default_branch: string\n }\n preview_url?: string\n content_summary?: {\n models: number\n entries: number\n locales?: string[]\n /**\n * Standalone pages by body kind — see `PageBodyKind`. Every kind is present\n * when the field is, so a zero is a count, not an omission.\n */\n body_kinds?: Record<PageBodyKind, number>\n }\n capabilities: HandoffCapability[]\n /** Present whenever the source had comments — see `HandoffComments`. */\n comments?: HandoffComments\n offers?: HandoffOffer[]\n /** Where the generated site's runtime components were bound, when an offer was fulfilled. */\n runtime?: RuntimeBinding\n notes?: string[]\n}\n","// ─── Execution, Approval and Source-Delta Contracts ───\n//\n// Shared, MIT-licensed shapes for operations that *do* something: an agent run,\n// a bulk edit, a deploy, a cutover. `migration.ts` describes a site; this file\n// describes an act upon one.\n//\n// Three repositories meet here and none of them may define these shapes for\n// itself. The migration engine (private) produces plans and receipts. Studio\n// (AGPL) renders the plan card, collects approvals and stores the run. The MCP\n// server (MIT) is where a plan's steps actually execute. If each wrote its own\n// `RiskClass`, a \"destructive\" operation would mean three different things and\n// the approval that guards it would be theatre.\n//\n// Everything here is plain JSON — snake_case keys, no class instances, no\n// functions — because these documents cross process, repository and license\n// boundaries, and because a plan must survive being written to disk, approved\n// hours later, and executed by a different process than the one that built it.\n\nimport { canonicalStringify } from './canonical.js'\n\n/**\n * Version stamped into every execution document (`version` field on each root).\n * A reader that sees a higher version than it knows should refuse rather than\n * guess — a misread plan is a plan executed with the wrong scope.\n */\nexport const EXECUTION_CONTRACT_VERSION = 1\n\n// ─── Actors ───\n\n/**\n * Who is acting. `kind` is load-bearing for approval: an agent may never\n * approve its own work, so the evaluator has to be able to tell an agent\n * from a person without consulting Studio's user table.\n */\nexport interface ActorRef {\n kind: 'human' | 'agent' | 'system'\n /** Stable identifier in the issuing system (Studio user id, agent name). */\n id: string\n /** Display name at the time of acting; advisory, never matched against. */\n name?: string\n /** Project role held at the time of acting (e.g. \"owner\", \"admin\", \"editor\"). */\n role?: string\n}\n\n// ─── Risk ───\n\n/**\n * What is at stake in an operation, in ascending severity.\n *\n * The order is the contract, not a convenience: `RISK_CLASSES` is a ladder and\n * a policy written for `bulk_content` is expected to also cover everything\n * above it. The seven rungs come from the approval model — read-only work is\n * automatic, content edits follow project policy, and anything that leaves the\n * repository (money, third parties, production) is a separate decision from\n * whether the content is correct.\n */\nexport const RISK_CLASSES = [\n /** Reads only; no file, no side effect. Automatic by default. */\n 'read_only',\n /** A bounded content edit — one entry, one locale. Project policy decides. */\n 'low_risk_content',\n /** Many entries at once: bulk status changes, mass field edits, imports. */\n 'bulk_content',\n /** Deletes content or changes a model's shape. Both plan and change approval. */\n 'destructive_schema',\n /** Reaches a third party: email, CRM, webhook, provider API. */\n 'external_effect',\n /** Spends money in a way worth a budget decision. */\n 'financially_material',\n /** Publishes, deploys, cuts over, or rewrites redirects. Release approval. */\n 'deployment',\n] as const\n\nexport type RiskClass = (typeof RISK_CLASSES)[number]\n\n/** Position on the ladder; higher is more severe. */\nexport function riskRank(risk: RiskClass): number {\n return RISK_CLASSES.indexOf(risk)\n}\n\n/**\n * The most severe of the given classes — the class a multi-step plan is\n * evaluated against. A plan is exactly as safe as its worst step, so a\n * read-only survey that ends in a deploy is a deploy.\n */\nexport function highestRisk(risks: readonly RiskClass[]): RiskClass {\n let worst: RiskClass = 'read_only'\n for (const risk of risks) {\n if (riskRank(risk) > riskRank(worst)) worst = risk\n }\n return worst\n}\n\n// ─── Approval ───\n\n/**\n * The three moments at which a decision is asked for. They are distinct\n * questions and collapsing them is the mistake this type exists to prevent:\n * approving *what will be done* is not approving *what was produced*, and\n * neither is permission to put it in front of the public.\n */\nexport const APPROVAL_GATES = [\n /** Before the work starts: scope, cost, risk, tools, affected resources. */\n 'plan',\n /** After the branch exists: semantic diff, schema change, validation output. */\n 'change',\n /** Before production effect: publish, deploy, cutover, redirects, bulk delete. */\n 'release',\n] as const\n\nexport type ApprovalGate = (typeof APPROVAL_GATES)[number]\n\n/**\n * How many people, and which, must say yes. Deliberately three modes: a\n * generic workflow canvas was considered and rejected — policy-based approval\n * covers every case the product actually has.\n */\nexport const APPROVAL_MODES = [\n /** No human decision needed. */\n 'auto',\n /** One authorised reviewer is enough. */\n 'single',\n /** A count, or a role combination, must be met. */\n 'quorum',\n] as const\n\nexport type ApprovalMode = (typeof APPROVAL_MODES)[number]\n\n/**\n * One rule in `.contentrain/approval-policies.json`: for operations at or\n * above `risk`, this gate is required in this mode.\n *\n * Rules are additive. Every matching rule contributes a requirement; nothing a\n * rule says can remove a requirement another rule created. A policy file can\n * therefore only ever make a project stricter, which is what makes it safe to\n * merge one from a template.\n */\nexport interface ApprovalRule {\n /** Applies to this class and — because the classes are a ladder — everything above it. */\n risk: RiskClass\n gate: ApprovalGate\n mode: ApprovalMode\n /** Quorum only: how many distinct approvers are needed. Ignored otherwise. */\n min_approvals?: number\n /** Roles that may satisfy this rule. Empty/absent = any authorised reviewer. */\n roles?: string[]\n /** Narrow the rule to certain models. Absent = every model. */\n models?: string[]\n /** Narrow the rule to certain locales. Absent = every locale. */\n locales?: string[]\n}\n\n/**\n * `.contentrain/approval-policies.json`. Lives in git, beside the content it\n * governs, so that the policy in force is the policy on the branch — a policy\n * stored only in a database can be changed after a plan is built and before it\n * is approved.\n */\nexport interface ApprovalPolicyFile {\n version: number\n /**\n * Applied at the `change` gate when no rule matches at all. Absent means no\n * rule matched and therefore nothing is required — the policy's silence is\n * taken literally rather than filled in with each consumer's idea of a\n * sensible default.\n */\n default_mode?: ApprovalMode\n rules: ApprovalRule[]\n /**\n * Whether an actor may approve a plan they themselves created. Defaults to\n * false; an agent can never self-approve regardless of this setting.\n */\n allow_self_approval?: boolean\n}\n\n/**\n * An outstanding or satisfied demand for a decision, derived from a rule.\n * `because` carries the rule's risk class so a UI can say *why* a gate\n * appeared without re-running the policy.\n */\nexport interface ApprovalRequirement {\n gate: ApprovalGate\n mode: ApprovalMode\n /** Distinct approvers still needed; 0 once satisfied. */\n min_approvals: number\n roles?: string[]\n /**\n * The risk class of the rule that produced this requirement: the rule's floor, which a heavier plan\n * climbs past, not the plan's own risk (that is `ApprovalDecision.risk`). When the policy's default mode\n * produced it, the plan's class.\n */\n because: RiskClass\n}\n\n/**\n * A decision that was actually given.\n *\n * A grant is bound to an exact `plan_hash`: if the plan changes, its grants no\n * longer apply and the gate reopens. That binding is the whole point — an\n * approval of \"publish these 12 posts\" must not carry over to a plan that\n * publishes 400.\n */\nexport interface ApprovalGrant {\n gate: ApprovalGate\n /** The plan this decision was given for. */\n plan_hash: string\n /** The branch tip reviewed, for `change` grants on a produced diff. */\n commit_sha?: string\n approver: ActorRef\n /** ISO 8601 UTC. */\n approved_at: string\n /** ISO 8601 UTC; a grant past this instant does not count. */\n expires_at?: string\n note?: string\n}\n\n// ─── Execution scope ───\n\n/**\n * What an operation touches. Used twice with the same shape: on a plan it is\n * the prediction, on a receipt it is what actually happened. Keeping one shape\n * for both is deliberate — the diff between them is the most useful thing a\n * receipt can show, and it is only computable if the two are comparable.\n *\n * Every field is optional and an absent field means \"none\", not \"unknown\": a\n * plan that cannot enumerate what it will touch is not a plan that should be\n * approved.\n */\nexport interface ExecutionScope {\n models?: string[]\n locales?: string[]\n /** Entry ids, or `model:id` where the model is not implied by `models`. */\n entries?: string[]\n /** Routes/URLs affected — cutovers and redirect rewrites report these. */\n routes?: string[]\n /** Repository-relative file paths. */\n files?: string[]\n /** Asset ids or paths. */\n assets?: string[]\n /** Provider identifiers reached (deployment, email, CRM). */\n providers?: string[]\n /** Hosts contacted outside the project — the evidence behind `external_effect`. */\n external_domains?: string[]\n}\n\n/** One tool invocation inside a plan. */\nexport interface ExecutionStep {\n id: string\n /** Tool name as the runner knows it (e.g. \"contentrain_bulk\"). */\n tool: string\n /** One line, written for the person approving, not for the log. */\n summary: string\n risk: RiskClass\n /** What this step alone touches; the plan's scope is the union. */\n scope?: ExecutionScope\n /** Opaque tool arguments. Hashed with the rest of the plan. */\n input?: Record<string, unknown>\n}\n\n/**\n * Predicted cost. Currency is ISO 4217; absent means the cost is not monetary.\n *\n * The non-monetary fields exist because cost is metered before it is priced:\n * a run's bill is assembled from tokens, compute time, bytes stored and bytes\n * transferred, and those are what a runner can actually measure at the moment\n * it finishes a step. `cost` is the conversion, and it is often computed later\n * from these.\n */\nexport interface ExecutionEstimate {\n currency?: string\n /** Money, in major units. */\n cost?: number\n /** Model tokens, when the step runs an agent. */\n tokens?: number\n duration_ms?: number\n /**\n * Items the operation expects to write — entries, files, assets. A count,\n * not a size: use `bytes_stored` for how much was written.\n */\n items?: number\n /**\n * Bytes the operation leaves at rest — a built artefact, an uploaded asset.\n * Object storage bills for this per unit time, so it is the field a storage\n * line on an invoice traces back to.\n */\n bytes_stored?: number\n /**\n * Bytes the operation moves across the network — egress, and fetches made on\n * the way. Billed separately from storage and by a different rate, which is\n * why it is a separate field rather than one `bytes`: a run that stores 40 MB\n * once and serves it a thousand times has one storage figure and a very\n * different transfer figure.\n */\n bytes_out?: number\n}\n\n/** Cost as measured. Same shape as the estimate so the two can be subtracted. */\nexport type ExecutionCost = ExecutionEstimate\n\n/**\n * How to undo the operation. A command, not a promise: if the runner cannot\n * state the undo, `available` is false and the approver learns that *before*\n * deciding rather than after failing.\n */\nexport interface RollbackPlan {\n available: boolean\n /** Shell command or tool call that reverses the effect. */\n command?: string\n /** Commit to reset the content branch to. */\n revert_to?: string\n /** Why it cannot be undone, when `available` is false. */\n reason?: string\n}\n\n/** The repository state a plan was built against. */\nexport interface ProjectRef {\n branch?: string\n commit_sha?: string\n /** Content branch tip, when it differs from the base branch. */\n content_commit_sha?: string\n}\n\n/**\n * An operation, fully described, before it runs.\n *\n * `plan_hash` is the identity of *what will happen*. It covers the steps,\n * scope, risk, estimate and rollback, and excludes who built the plan and\n * when — regenerating the same operation must produce the same hash, or\n * idempotency and approval binding both break. Compute it with\n * {@link computePlanHash}; never hand-roll it, because a hash computed two\n * different ways is a gate that silently opens.\n */\nexport interface ExecutionPlan {\n version: number\n id: string\n /** SHA-256 of the plan's semantic payload — see {@link computePlanHash}. */\n plan_hash: string\n /** What this achieves, in the requester's words. */\n intent: string\n /** The plan's class: the highest risk among its steps. */\n risk: RiskClass\n steps: ExecutionStep[]\n /** Union of the steps' scopes, stated once so an approver reads one list. */\n scope: ExecutionScope\n estimate?: ExecutionEstimate\n rollback?: RollbackPlan\n /** Repository state the plan assumes; re-checked at execution time. */\n project?: ProjectRef\n /** ISO 8601 UTC. Excluded from the hash. */\n created_at?: string\n /** Excluded from the hash, but checked for self-approval. */\n created_by?: ActorRef\n /** ISO 8601 UTC; a plan past this instant must be rebuilt, not run. */\n expires_at?: string\n /**\n * Caller-supplied key that makes a re-submission a no-op rather than a\n * second run. Excluded from the hash: two runs of the same work differ by\n * this key alone and must still share a plan hash.\n */\n idempotency_key?: string\n}\n\n// ─── Receipt ───\n\n/**\n * Run lifecycle. The main line is `draft → planned → awaiting_approval →\n * approved → scheduled → queued → running → verifying → completed`; the rest\n * are the states a run can sit in when something interrupts it.\n */\nexport const RUN_STATUSES = [\n 'draft',\n 'planned',\n 'awaiting_approval',\n 'approved',\n 'scheduled',\n 'queued',\n 'running',\n 'verifying',\n 'completed',\n 'paused',\n 'needs_attention',\n 'retrying',\n 'failed',\n 'cancelled',\n 'expired',\n 'rolled_back',\n] as const\n\nexport type RunStatus = (typeof RUN_STATUSES)[number]\n\n/** Statuses from which a run will not proceed on its own. */\nexport const TERMINAL_RUN_STATUSES = [\n 'completed',\n 'failed',\n 'cancelled',\n 'expired',\n 'rolled_back',\n] as const satisfies readonly RunStatus[]\n\nexport function isTerminalRunStatus(status: RunStatus): boolean {\n return (TERMINAL_RUN_STATUSES as readonly RunStatus[]).includes(status)\n}\n\n/**\n * A step boundary the runner survived. Checkpoints are what make a resumed\n * run skip work it already did instead of repeating it.\n */\nexport interface Checkpoint {\n step_id: string\n status: 'completed' | 'skipped' | 'failed'\n /** ISO 8601 UTC. */\n at: string\n /** What this step actually touched. */\n scope?: ExecutionScope\n /** Commit written by this step, when it wrote one. */\n commit_sha?: string\n message?: string\n}\n\n/** One post-run assertion — a build, a link check, a parity score. */\nexport interface VerificationCheck {\n id: string\n /** One line naming what was asserted. */\n name: string\n passed: boolean\n /** Measured value, when the check produced one. */\n value?: number | string\n /** Threshold the value was compared against. */\n expected?: number | string\n detail?: string\n}\n\n/**\n * What happened, after the fact. The receipt is the durable record: it\n * survives the run, is exported for audit, and is the only artefact that can\n * answer \"who allowed this and what did it actually change?\".\n *\n * Release approvers are the entries of `approvals` whose `gate` is `release`\n * — there is deliberately no separate `release_approved_by` field, because two\n * places recording the same fact is two places that can disagree. Read them\n * with {@link approversFor}.\n */\nexport interface ExecutionReceipt {\n version: number\n id: string\n plan_id: string\n /** The plan that ran. Must match the approvals' `plan_hash`. */\n plan_hash: string\n status: RunStatus\n actor: ActorRef\n /** Every decision that permitted this run, plan/change/release alike. */\n approvals?: ApprovalGrant[]\n /** ISO 8601 UTC. */\n started_at: string\n finished_at?: string\n /** What was actually touched — compare against the plan's `scope`. */\n applied: ExecutionScope\n checkpoints?: Checkpoint[]\n verification?: VerificationCheck[]\n /** Measured cost — compare against the plan's `estimate`. */\n cost?: ExecutionCost\n /** The undo, as it stands now that the run has happened. */\n rollback?: RollbackPlan\n /** Repository state the run left behind. */\n project?: ProjectRef\n /** Set when `status` is `failed` or `needs_attention`. */\n error?: { code: string, message: string, step_id?: string }\n}\n\n/** Approvers recorded on a receipt for one gate, in grant order. */\nexport function approversFor(receipt: ExecutionReceipt, gate: ApprovalGate): ActorRef[] {\n return (receipt.approvals ?? []).filter(grant => grant.gate === gate).map(grant => grant.approver)\n}\n\n// ─── Deployment ───\n\n/**\n * Where a build is published. Carries a *reference* to a secret, never a\n * secret: this document is written to git, and a token in git is a token\n * leaked. The reference is resolved at execution time by whoever holds the\n * vault.\n */\nexport interface DeploymentTarget {\n id: string\n /** Provider identity (e.g. \"netlify\", \"vercel\", \"cloudflare-pages\"). */\n provider: string\n /** Provider-side site/project identifier. */\n site_id?: string\n branch?: string\n /** Key naming the credential in the executing side's secret store. */\n secret_ref?: string\n /** Non-secret provider settings. */\n config?: Record<string, string>\n}\n\n// ─── Automation (type reservation) ───\n//\n// Reserved now so that the file `.contentrain/automations.json` has a declared\n// shape before anything writes one. No tool in this repository reads these\n// yet; the shape exists so that the first writer does not invent a fourth\n// vocabulary for schedules.\n\nexport const SCHEDULE_KINDS = [\n 'once',\n 'recurring',\n 'content_event',\n 'git_event',\n 'runtime_event',\n 'external_webhook',\n 'manual',\n] as const\n\nexport type ScheduleKind = (typeof SCHEDULE_KINDS)[number]\n\n/** What to do with an occurrence that was missed while the runner was down. */\nexport type MisfirePolicy = 'skip' | 'run_once' | 'run_all'\n\nexport interface AutomationSchedule {\n kind: ScheduleKind\n /** ISO 8601 UTC, for `once`. */\n at?: string\n /** Cron expression, for `recurring`. */\n cron?: string\n /** IANA zone (e.g. \"Europe/Istanbul\"); cron without one is ambiguous twice a year. */\n timezone?: string\n /** Event name, for the event kinds. */\n event?: string\n misfire?: MisfirePolicy\n}\n\nexport interface AutomationDefinition {\n version: number\n id: string\n name: string\n enabled: boolean\n schedule: AutomationSchedule\n /** What the run should do, in the requester's words. */\n intent: string\n /** Risk the automation declares; the policy is re-evaluated at fire time. */\n risk?: RiskClass\n /** Budget ceiling per occurrence. */\n budget?: ExecutionEstimate\n /** Deduplication key for the occurrence. */\n idempotency_key?: string\n /** Bounded retry; absent means no retry. */\n max_retries?: number\n}\n\n// ─── SourceDeltaPlan ───\n//\n// The WordPress→repo delta layer. This is NOT `contentrain_reconcile`: that\n// tool merges two git branches through their common ancestor and knows\n// nothing about WordPress. A source delta is the other axis — what changed at\n// the origin since the snapshot the repository was built from — and it must be\n// written to the repository *before* reconcile runs on the git side.\n//\n// The trap this shape exists to close: `modified_after` is a filter on changed\n// records, not a deletion feed. A post deleted in WordPress simply stops\n// appearing, and a delta built from \"what changed\" will never notice. Hence\n// explicit tombstones, produced by comparing inventories rather than by\n// reading a feed.\n\n/** The origin state a delta is measured from. */\nexport interface SourceCursor {\n kind: 'rest_modified_after' | 'wxr_export' | 'bridge_inventory'\n /** ISO 8601 UTC, for `rest_modified_after`. */\n modified_after?: string\n /** Export identifier, for `wxr_export`. */\n export_id?: string\n /** Inventory hash, for `bridge_inventory` — the only kind that can prove deletions. */\n inventory_hash?: string\n /** ISO 8601 UTC time the cursor was taken (T0). */\n taken_at: string\n}\n\nexport const SOURCE_DELTA_OPS = [\n /** Present at the origin, absent in the repository. */\n 'created',\n /** Present in both, origin's fingerprint changed. */\n 'updated',\n /** Absent at the origin, present in the repository — a tombstone. */\n 'deleted',\n /** Same record, different slug/permalink. Drives redirect generation. */\n 'moved',\n] as const\n\nexport type SourceDeltaOp = (typeof SOURCE_DELTA_OPS)[number]\n\n/**\n * One record's delta.\n *\n * `conflict` is the case that cannot be resolved mechanically: the same record\n * changed at the origin *and* in the repository since the cursor. Taking\n * either side silently loses an edit, so the plan reports it and a human\n * decides.\n */\nexport interface SourceDeltaEntry {\n op: SourceDeltaOp\n /** Origin identifier (WordPress post/term/attachment id). */\n wp_id: number\n /** Origin object type (e.g. \"post\", \"page\", \"attachment\", \"category\"). */\n wp_type?: string\n /** Where it lives in the store, when it is already mapped. */\n model?: string\n entry_id?: string\n locale?: string\n /** Origin content fingerprint at cursor time and now. */\n fingerprint_before?: string\n fingerprint_after?: string\n /** For `moved`: the slugs, in that order. */\n slug_before?: string\n slug_after?: string\n /**\n * For `moved`: the site-root-relative addresses, in that order. A slug\n * cannot say that a page changed parent, a term base changed or a dated\n * permalink moved; the path can, and redirects are generated from it.\n */\n path_before?: string\n path_after?: string\n /**\n * For `deleted`: `trashed` is still at the origin and can come back;\n * `purged` is gone from it. A draft or private record is `updated`, never\n * `deleted` — unpublishing is the planner's decision.\n */\n deleted_kind?: 'trashed' | 'purged'\n /** True when the repository also changed this record since the cursor. */\n conflict?: boolean\n /**\n * The repository-side write behind `conflict`: the entry's meta when it was\n * last written by something other than the importer (Studio, an agent, a\n * person). `updated_at` is absent when the store never recorded it — an\n * unknown time is still a conflict.\n */\n repo_edit?: { updated_by: string, updated_at?: string, source: string }\n /**\n * Why the planner could not place this record in the store:\n * `not-in-source-map` (a post-type record the source map does not list),\n * `no-model-for-type` (no store model holds this origin type),\n * `entry-not-found` (the model exists, the entry does not).\n */\n unmapped?: 'not-in-source-map' | 'no-model-for-type' | 'entry-not-found'\n /**\n * For `updated` and `moved`: the fields whose stored value differs from the\n * incoming export's, by field name (`body` for a document's body). Computed\n * against the repository as it is now, so a repository-side edit shows here\n * too — which is what `conflict` is for.\n */\n fields_changed?: string[]\n /** For `moved`: the entry id at the new address, when the store derives ids from slugs and the id changes. */\n entry_id_after?: string\n /** Why this entry is here, for the person reading the plan. */\n detail?: string\n}\n\n/**\n * A one-way transfer plan from the WordPress origin to the repository.\n *\n * Not a sync: there is no path back. The plan is produced, written, and only\n * then does `contentrain_reconcile` merge the result on the git side.\n */\nexport interface SourceDeltaPlan {\n version: number\n /** ISO 8601 UTC. */\n generated_at: string\n /** Origin state this delta is measured from. */\n cursor: SourceCursor\n /** Origin state this delta brings the repository up to. */\n next_cursor?: SourceCursor\n entries: SourceDeltaEntry[]\n /**\n * Whether deletions could be determined at all. A `rest_modified_after`\n * cursor cannot see them; a plan that says `false` must not be read as\n * \"nothing was deleted\".\n */\n deletions_detectable: boolean\n /**\n * Origin types whose deletions could not be determined even though the plan\n * as a whole could — a type that left the inventory's scope (its plugin was\n * deactivated) is not a deletion of every record in it.\n */\n deletions_undetectable_types?: string[]\n /** Redirects implied by the `moved` entries. */\n redirects?: { from: string, to: string, status: 301 | 302 | 307 | 308 }[]\n /** Human-facing notes: truncation, skipped types, coverage limits. */\n warnings?: string[]\n}\n\n// ─── Plan hash ───\n\n/**\n * The exact bytes `plan_hash` is computed over. Exposed because a hash is only\n * useful if a disagreement can be investigated: when two sides compute\n * different hashes for the same plan, comparing payloads shows which field\n * differs, and comparing hashes shows nothing.\n *\n * Excluded: `plan_hash` itself (it cannot cover itself), and `id`,\n * `created_at`, `created_by` and `idempotency_key` — who built a plan, when,\n * under which run id, and with which deduplication key do not change what the\n * plan will do. Everything else is covered, so a changed step, a widened\n * scope, a raised estimate or a withdrawn rollback all invalidate every\n * approval the plan had collected.\n */\nexport function planHashPayload(plan: ExecutionPlan): string {\n const { plan_hash: _hash, id: _id, created_at: _at, created_by: _by, idempotency_key: _key, ...semantic } = plan\n return canonicalStringify(semantic)\n}\n\n/**\n * SHA-256 of {@link planHashPayload}, lowercase hex.\n *\n * Async because it uses Web Crypto (`globalThis.crypto.subtle`), which is\n * available in Node 18+, Deno, Bun, workers and browsers alike — this package\n * is consumed in all of them and must not reach for `node:crypto`. A non-\n * cryptographic hash was considered and rejected: approvals are pinned to this\n * value, so a collision is an approval bypass, not a cache miss.\n */\nexport async function computePlanHash(plan: ExecutionPlan): Promise<string> {\n const bytes = new TextEncoder().encode(planHashPayload(plan))\n const digest = await crypto.subtle.digest('SHA-256', bytes)\n return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('')\n}\n\n/** One origin record as the inventory saw it. */\nexport interface SourceInventoryRecord {\n /** Origin object type (\"post\", \"page\", \"attachment\", or a taxonomy name for a term). */\n wp_type: string\n /** Origin id — unique only together with `wp_type`: post 5 and category 5 are different records. */\n wp_id: number\n /** Origin status (`publish`, `draft`, `trash`, …); a term has none. */\n status?: string\n /**\n * Password-protected at the origin. The record is inventoried like any other\n * — so removing the password is an `updated`, not a `created` — but has no\n * public address.\n */\n protected?: boolean\n slug?: string\n /** Site-root-relative public address. A change here is a `moved`. */\n path?: string\n /** Origin id of the parent record, when it has one. */\n parent?: number\n /** ISO 8601 UTC, from the origin — a hint only: a meta-only edit does not change it. */\n modified_at?: string\n /**\n * Hash of the record as mapped, volatile fields excluded. `updated` is\n * decided on this, never on `modified_at`.\n */\n fingerprint: string\n /** Slugs the origin remembers for this record (WordPress `_wp_old_slug`). Corroborates a move; never the authority. */\n old_slugs?: string[]\n /** Locale of the record, when the origin is multilingual. */\n locale?: string\n}\n\n/**\n * Every record in scope at one moment — what a `bridge_inventory` cursor's\n * `inventory_hash` identifies.\n *\n * Deletions and moves are proven by comparing two inventories, not by\n * reading a feed; a `modified_after` query only narrows which bodies to fetch\n * again. A type in the earlier inventory's `scope` but not in this one's left\n * the scope — it was not deleted.\n */\nexport interface SourceInventory {\n /** Inventory format identifier, e.g. `contentrain-bridge-inventory@2`. */\n format: string\n /** ISO 8601 UTC time the inventory was taken. */\n taken_at: string\n /** What was enumerated. */\n scope: {\n post_types: string[]\n taxonomies?: string[]\n }\n records: SourceInventoryRecord[]\n /**\n * The value a cursor's `inventory_hash` carries: lowercase hex SHA-256 of the\n * records sorted by `wp_type` (byte order) then `wp_id` (numeric), each\n * serialized as the JSON array `[wp_type, wp_id, fingerprint, path ?? null,\n * status ?? null]` — no spaces, slashes and non-ASCII unescaped (what\n * `JSON.stringify` prints) — joined by `\\n`, no trailing newline. Other fields\n * do not enter it; a change they make shows through `fingerprint`.\n */\n inventory_hash: string\n}\n","// ─── Approval Evaluator ───\n//\n// The decision procedure behind `execution.ts`'s approval types: given a plan,\n// a policy and the decisions collected so far, may this proceed?\n//\n// It replaces a role check. Studio's `shouldAutoMerge` asks \"is this person an\n// owner?\" — which cannot express \"a bulk publish needs a second pair of eyes\n// even from the owner\", and cannot distinguish fixing a typo from cutting over\n// a domain. This asks about the action instead: its risk, its scope, and what\n// the project's own policy says about that combination.\n//\n// Pure and dependency-free, so the same answer comes out in Studio's server,\n// in a CLI, and in a test. Nothing here reads a file, a clock or a user table:\n// every input is passed in, `now` included, because a decision that silently\n// depends on the wall clock cannot be reproduced when someone asks why a run\n// was blocked.\n\nimport type {\n ActorRef,\n ApprovalGrant,\n ApprovalMode,\n ApprovalPolicyFile,\n ApprovalRequirement,\n ApprovalRule,\n ExecutionPlan,\n RiskClass,\n} from './execution.js'\nimport { APPROVAL_GATES, highestRisk, riskRank } from './execution.js'\n\n/**\n * Used when a project has no `.contentrain/approval-policies.json`.\n *\n * Read-only work proceeds; everything else wants one reviewer on the produced\n * diff. Deliberately stated as a value rather than buried in the evaluator so\n * a project can inspect it, extend it, or reject it — and so \"what happens\n * without a policy file\" has one answer instead of one per caller.\n *\n * Note this is a *default*, not the only gate in the system: whether a project\n * consults the evaluator at all is still governed by its `workflow` setting.\n */\nexport const DEFAULT_APPROVAL_POLICY: ApprovalPolicyFile = {\n version: 1,\n rules: [\n { risk: 'low_risk_content', gate: 'change', mode: 'single' },\n ],\n}\n\n/** Ascending strictness — used to keep the stricter of two modes. */\nconst MODE_RANK: Record<ApprovalMode, number> = { auto: 0, single: 1, quorum: 2 }\n\n/** How many distinct approvers a mode asks for. */\nfunction approvalsNeeded(rule: ApprovalRule): number {\n if (rule.mode === 'auto') return 0\n if (rule.mode === 'single') return 1\n return Math.max(1, rule.min_approvals ?? 2)\n}\n\n/**\n * Why a grant did not count. Machine-readable because the useful question is\n * never \"is it blocked\" but \"I approved this, why is it still blocked\".\n */\nexport type GrantRejection =\n /** Given for a different plan — the plan changed after the decision. */\n | 'plan_hash_mismatch'\n /** A `change` grant reviewed a different branch tip than the one presented. */\n | 'commit_mismatch'\n /** Past its `expires_at`. */\n | 'expired'\n /** An agent cannot approve. */\n | 'agent_approver'\n /** The approver authored the plan and the policy forbids self-approval. */\n | 'self_approval'\n /** The approver's role is not one the rule accepts. */\n | 'role_not_permitted'\n /** This approver already counted toward the requirement. */\n | 'duplicate_approver'\n /** No requirement at this gate — nothing for the grant to satisfy. */\n | 'no_matching_requirement'\n\nexport interface RejectedGrant {\n grant: ApprovalGrant\n reason: GrantRejection\n}\n\n/** A requirement plus the decisions that have accumulated against it. */\nexport interface ApprovalStatus extends ApprovalRequirement {\n /** Distinct approvers counted so far, in grant order. */\n approvers: ActorRef[]\n /** Still needed; 0 means satisfied. */\n remaining: number\n}\n\nexport interface ApprovalDecision {\n /** Every requirement is satisfied and the plan has not expired. */\n allowed: boolean\n /** The class the plan was evaluated at — its own, or its worst step's. */\n risk: RiskClass\n /** Every requirement the policy produced, satisfied or not. */\n requirements: ApprovalStatus[]\n /** The subset still needing approvers, in gate order. */\n outstanding: ApprovalStatus[]\n /** Decisions that were supplied but did not count, each with a reason. */\n rejected_grants: RejectedGrant[]\n /** One line per reason the plan cannot proceed; empty when it can. */\n reasons: string[]\n}\n\nexport interface ApprovalEvaluation {\n plan: ExecutionPlan\n /** Absent falls back to {@link DEFAULT_APPROVAL_POLICY}. */\n policy?: ApprovalPolicyFile\n /** Decisions collected so far. */\n grants?: readonly ApprovalGrant[]\n /**\n * The branch tip being presented for a `change` decision. When given, a\n * `change` grant must name this commit: a review of an older tip is not a\n * review of what is about to merge. When absent, `change` grants are matched\n * on `plan_hash` alone.\n */\n commit_sha?: string\n /** ISO 8601 UTC. Passed in, never read from the clock. */\n now?: string\n}\n\n/** Whether a rule's narrowing clauses match what the plan touches. */\nfunction ruleMatchesScope(rule: ApprovalRule, plan: ExecutionPlan): boolean {\n if (rule.models && rule.models.length > 0) {\n const touched = plan.scope.models ?? []\n if (!rule.models.some(model => touched.includes(model))) return false\n }\n if (rule.locales && rule.locales.length > 0) {\n const touched = plan.scope.locales ?? []\n if (!rule.locales.some(locale => touched.includes(locale))) return false\n }\n return true\n}\n\n/**\n * The class a plan is judged at: its declared risk, or its worst step's if\n * that is higher. A plan cannot understate itself — declaring `read_only`\n * while carrying a deploy step would otherwise walk straight past the policy.\n */\nexport function effectiveRisk(plan: ExecutionPlan): RiskClass {\n return highestRisk([plan.risk, ...plan.steps.map(step => step.risk)])\n}\n\n/**\n * The requirements a policy produces for a plan, before any decision is\n * collected. This is what a plan card renders: \"this needs one reviewer on the\n * diff and two on release, because it deploys\".\n *\n * Each matching rule contributes its own requirement and all of them must be\n * met. Merging two rules into one would need a rule for combining their modes,\n * roles and counts — and every such rule has a case where the result is\n * *looser* than one of its inputs. Keeping them separate is what makes the\n * policy additive in fact and not just in intent.\n *\n * `auto` rules are dropped rather than kept as zero-approver requirements: a\n * requirement nobody has to satisfy is noise on the card, and a stricter rule\n * matching the same gate still stands on its own.\n */\nexport function requiredApprovals(plan: ExecutionPlan, policy: ApprovalPolicyFile = DEFAULT_APPROVAL_POLICY): ApprovalRequirement[] {\n const risk = effectiveRisk(plan)\n const requirements: ApprovalRequirement[] = []\n let matched = false\n\n for (const rule of policy.rules) {\n if (!ruleMatchesScope(rule, plan)) continue\n\n // A demanding rule covers its own class and everything above it: the\n // classes are a ladder, so a policy written for `bulk_content` also\n // governs a deploy.\n //\n // An `auto` rule does NOT climb. It says \"this class needs nothing\", and\n // if it inherited upward a single `auto` rule on a low rung would exempt\n // every heavier operation above it — turning the one mode that demands\n // nothing into the only mode that can loosen a policy. Matching its own\n // class alone keeps the file additive in fact and not just in intent.\n const climbs = rule.mode !== 'auto'\n const applies = climbs ? riskRank(risk) >= riskRank(rule.risk) : risk === rule.risk\n if (!applies) continue\n\n matched = true\n const min_approvals = approvalsNeeded(rule)\n // `auto` produces no requirement — but it did match, so the policy's\n // `default_mode` does not step in behind it.\n if (min_approvals === 0) continue\n\n requirements.push({\n gate: rule.gate,\n mode: rule.mode,\n min_approvals,\n ...(rule.roles && rule.roles.length > 0 ? { roles: [...rule.roles] } : {}),\n because: rule.risk,\n })\n }\n\n if (!matched && policy.default_mode && policy.default_mode !== 'auto') {\n requirements.push({\n gate: 'change',\n mode: policy.default_mode,\n min_approvals: approvalsNeeded({ risk, gate: 'change', mode: policy.default_mode }),\n because: risk,\n })\n }\n\n // Gate order, then the strictest first within a gate, so a card reads\n // chronologically and the heaviest demand is not buried.\n return requirements.toSorted((a, b) =>\n APPROVAL_GATES.indexOf(a.gate) - APPROVAL_GATES.indexOf(b.gate)\n || MODE_RANK[b.mode] - MODE_RANK[a.mode]\n || b.min_approvals - a.min_approvals)\n}\n\n/** Whether a grant may be counted at all, independent of any requirement. */\nfunction grantDisqualification(\n grant: ApprovalGrant,\n plan: ExecutionPlan,\n policy: ApprovalPolicyFile,\n commit_sha: string | undefined,\n now: string,\n): GrantRejection | undefined {\n if (grant.plan_hash !== plan.plan_hash) return 'plan_hash_mismatch'\n if (grant.expires_at && grant.expires_at <= now) return 'expired'\n // An agent may never approve — not its own work, not anyone's. The whole\n // point of the gate is that something outside the automation looked.\n if (grant.approver.kind === 'agent') return 'agent_approver'\n if (\n !policy.allow_self_approval\n && plan.created_by\n && grant.approver.id === plan.created_by.id\n && grant.approver.kind === plan.created_by.kind\n ) return 'self_approval'\n // A `change` decision is a decision about a diff. Presented with a different\n // tip than the one reviewed, it says nothing about what is about to merge.\n if (grant.gate === 'change' && commit_sha && grant.commit_sha !== commit_sha) return 'commit_mismatch'\n return undefined\n}\n\n/**\n * May this plan proceed?\n *\n * Answers with the full picture rather than a boolean: which requirements\n * exist, which are met, who met them, and — for every decision that was\n * supplied but did not count — why it did not. The last part is what makes\n * \"I approved this, why is it still blocked?\" answerable without reading the\n * evaluator's source.\n */\nexport function evaluateApproval(input: ApprovalEvaluation): ApprovalDecision {\n const { plan, commit_sha } = input\n const policy = input.policy ?? DEFAULT_APPROVAL_POLICY\n const grants = input.grants ?? []\n const now = input.now ?? new Date().toISOString()\n\n const risk = effectiveRisk(plan)\n // `requiredApprovals` builds fresh objects (roles included) on every call, so\n // completing them in place is safe and costs no second allocation.\n const requirements: ApprovalStatus[] = requiredApprovals(plan, policy).map(requirement =>\n Object.assign(requirement, { approvers: [] as ActorRef[], remaining: requirement.min_approvals }))\n\n const rejected_grants: RejectedGrant[] = []\n\n for (const grant of grants) {\n const disqualified = grantDisqualification(grant, plan, policy, commit_sha, now)\n if (disqualified) {\n rejected_grants.push({ grant, reason: disqualified })\n continue\n }\n\n // One decision can satisfy several requirements at the same gate — it is\n // the same person saying yes to the same thing once.\n let counted = false\n let sawGate = false\n let roleBlocked = false\n let duplicate = false\n\n for (const requirement of requirements) {\n if (requirement.gate !== grant.gate) continue\n sawGate = true\n if (requirement.roles && !requirement.roles.includes(grant.approver.role ?? '')) {\n roleBlocked = true\n continue\n }\n if (requirement.approvers.some(approver => approver.id === grant.approver.id)) {\n duplicate = true\n continue\n }\n requirement.approvers.push(grant.approver)\n requirement.remaining = Math.max(0, requirement.min_approvals - requirement.approvers.length)\n counted = true\n }\n\n if (counted) continue\n if (!sawGate) rejected_grants.push({ grant, reason: 'no_matching_requirement' })\n else if (duplicate) rejected_grants.push({ grant, reason: 'duplicate_approver' })\n else if (roleBlocked) rejected_grants.push({ grant, reason: 'role_not_permitted' })\n }\n\n const outstanding = requirements.filter(requirement => requirement.remaining > 0)\n const reasons: string[] = []\n\n const expired = Boolean(plan.expires_at && plan.expires_at <= now)\n if (expired) {\n reasons.push(`The plan expired at ${plan.expires_at}; rebuild it rather than running it.`)\n }\n\n for (const requirement of outstanding) {\n const who = requirement.roles ? ` from ${requirement.roles.join(' or ')}` : ''\n const have = requirement.approvers.length\n // `because` is the matching rule's risk class: a floor the plan climbed\n // past, not the plan's own risk. Say \"the plan is X\" only when they agree.\n const cause = requirement.because === risk\n ? `the plan is ${risk}`\n : `the policy requires it at ${requirement.because} and above (this plan is ${risk})`\n reasons.push(\n `${requirement.gate} approval: ${requirement.remaining} more${who} needed `\n + `(${have}/${requirement.min_approvals}), because ${cause}.`,\n )\n }\n\n return {\n allowed: !expired && outstanding.length === 0,\n risk,\n requirements,\n outstanding,\n rejected_grants,\n reasons,\n }\n}\n","// ─── Migrate → Studio claim contract ───\n//\n// A paid Migrate order includes a Studio trial. When the migrated site is\n// delivered, Migrate hands the customer to Studio with a signed claim token;\n// Studio verifies it, grants the trial once per order, and opens the delivered\n// repository as a project.\n//\n// The token is a compact JWS, `alg: \"EdDSA\"` (Ed25519). Migrate signs with its\n// private key; Studio verifies with the public key. There is no shared secret.\n// A `kid` header is optional and used for key rotation. This module is the\n// payload both sides agree on. It deliberately does no cryptography — the\n// signature is checked by the consumer, before `validateMigrateStudioClaim`.\n//\n// Pure and dependency-free; every input is passed in, `now` included.\n\nimport type { CapabilityKey } from './migration.js'\nimport { CAPABILITY_KEYS } from './migration.js'\n\n/** Contract version carried in the payload as `v`. */\nexport const MIGRATE_STUDIO_CLAIM_VERSION = 1\n/**\n * v2: \"Migrate with Studio\". No trial: the Studio year is part of the order's\n * first invoice (Studio creates that checkout itself, server to server), and\n * the claim names the customer's GitHub account so Studio can find or create\n * the user without a sign-in. v1 stays valid for the claim-link flow.\n */\nexport const MIGRATE_STUDIO_CLAIM_VERSION_2 = 2\n/** JWS `alg` header value. */\nexport const MIGRATE_STUDIO_CLAIM_ALG = 'EdDSA'\n/** `iss` — who signs. */\nexport const MIGRATE_STUDIO_CLAIM_ISSUER = 'contentrain-migrate'\n/** `aud` — who may accept it. */\nexport const MIGRATE_STUDIO_CLAIM_AUDIENCE = 'contentrain-studio'\n/** Upper bound on `exp - iat`: a claim link is short-lived (30 minutes). */\nexport const MIGRATE_STUDIO_CLAIM_MAX_TTL_SECONDS = 1800\n/** Upper bound on the trial a claim can grant. v1 grants 60. */\nexport const MIGRATE_STUDIO_CLAIM_MAX_TRIAL_DAYS = 90\n/** Clock skew tolerated when checking `iat` / `exp`. */\nexport const MIGRATE_STUDIO_CLAIM_CLOCK_SKEW_SECONDS = 60\n\nexport const MIGRATE_STUDIO_PLANS = ['starter', 'pro'] as const\nexport type MigrateStudioPlan = (typeof MIGRATE_STUDIO_PLANS)[number]\n\n/**\n * One measurement the plan recommendation rests on, so Studio can say why:\n * \"3 200 comments a month exceed Starter's 500.\" `limit_key` is the Studio\n * plan-feature key the measurement was sized against; `limit` is the value of\n * that key for the recommended plan when the claim was signed. Sizing leaves\n * headroom (limit ≥ 1.2 × measured), so `measured <= limit` is expected.\n */\nexport interface MigrateStudioPlanEvidence {\n /** Studio plan-feature limit key, e.g. `comments.per_month`, `media.storage_gb`. */\n limit_key: string\n /** What discovery measured, in the limit's unit (a count per month, GB, MB). */\n measured: number\n /** The recommended plan's value for `limit_key` at signing time. */\n limit: number\n /** The discovered capability behind the measurement, when there is one. */\n capability?: CapabilityKey\n}\n\nexport interface MigrateStudioRepo {\n provider: 'github'\n owner: string\n name: string\n}\n\nexport interface MigrateStudioCapability {\n key: CapabilityKey\n /** Human-readable scale from discovery, e.g. \"1 240 comments\", \"2.1 GB\". */\n scale?: string | null\n}\n\n/** The claim token's payload. */\nexport interface MigrateStudioClaim {\n // Registered JWT claims\n iss: typeof MIGRATE_STUDIO_CLAIM_ISSUER\n aud: typeof MIGRATE_STUDIO_CLAIM_AUDIENCE\n /** Migrate account (user) id. */\n sub: string\n /** Single-use token id (uuid). Studio refuses a `jti` it has seen. */\n jti: string\n /** Issued at, seconds since the epoch. */\n iat: number\n /** Expires at, seconds since the epoch. `exp - iat` ≤ `MIGRATE_STUDIO_CLAIM_MAX_TTL_SECONDS`. */\n exp: number\n\n /** Contract version. */\n v: typeof MIGRATE_STUDIO_CLAIM_VERSION\n /** Migrate order id. Studio stores it UNIQUE: one grant per order. */\n order_id: string\n /**\n * Verified at Migrate. Studio shows it and may prefill with it, but does not\n * require a match: the customer's GitHub login email may differ.\n */\n email: string\n /** The plan sized from discovery. */\n plan: MigrateStudioPlan\n /** Why this plan — empty when discovery found nothing to size against (then `starter`). */\n plan_evidence: MigrateStudioPlanEvidence[]\n /** Trial length the grant opens, 1..`MIGRATE_STUDIO_CLAIM_MAX_TRIAL_DAYS`. Never taken from the client. */\n trial_days: number\n /** The delivered repository. */\n repo: MigrateStudioRepo\n /** Discovery summary shown on Studio's claim screen. Optional. */\n capabilities?: MigrateStudioCapability[]\n /**\n * The WordPress site the order migrated, as a bare origin (`https://host`,\n * lowercase, no path). Signed so Studio can trust it: media import fetches\n * only from this origin, never from an origin named in the repository.\n * Optional; `https:` only, plus `http:` for localhost.\n *\n * Must equal `new URL(origin).origin` exactly, so no userinfo, path, query\n * or fragment, and IDN hosts in punycode. A trailing-dot host is refused, so\n * one site has one spelling.\n */\n origin?: string\n /**\n * Where Studio fetches the order's comment export when the migrated site had\n * comments (`contentrain-comments@1`, the file the run delivered with\n * commenters' email, IP and avatar already removed). The export never enters\n * the repository, so this is its only way into Studio.\n *\n * A GET on Migrate's fixed export address with `token` as\n * `Authorization: Bearer`. The address carries no secret, so nothing that\n * logs request lines (proxies, the platform's edge) sees one. The token is\n * signed per job and valid until `expires_at` (the end of the grant window,\n * not the claim's 30 minutes); a missing, invalid or expired token reads as\n * 404. Studio fetches it server-side only and trusts the host only when it is\n * on its own Migrate allowlist — never because the claim names it. Absent\n * when the source had no comments.\n */\n comments_export?: MigrateStudioCommentsExport\n}\n\nexport interface MigrateStudioCommentsExport {\n /** `https:` URL (`http:` only for localhost), no userinfo, query or fragment. Carries no secret. */\n url: string\n /** The export's bearer token: a compact JWS, at most 2048 characters. Sent only as `Authorization: Bearer`. */\n token: string\n /** When the URL stops serving, seconds since the epoch. After `iat`. */\n expires_at: number\n /** Comments in the export, for the claim screen. */\n comments: number\n}\n\n/**\n * What the customer was quoted, signed so Studio can refuse a checkout whose\n * price would differ. Studio adds its own Studio-year line to `migrate_fee_cents`\n * and compares the sum with `quoted_total_cents`.\n */\nexport interface MigrateStudioClaimBilling {\n /** The Migrate dynamic fee, in cents, before the Studio year. ≥ 0. */\n migrate_fee_cents: number\n /** The first invoice the customer saw (Migrate fee + Studio year 1), in cents. > 0. */\n quoted_total_cents: number\n currency: 'usd'\n}\n\n/**\n * The v2 payload (\"Migrate with Studio\"): a v1 claim without `trial_days`, plus\n * the identity and billing a server-to-server provision needs.\n */\nexport interface MigrateStudioClaimV2 extends Omit<MigrateStudioClaim, 'v' | 'trial_days' | 'repo'> {\n v: typeof MIGRATE_STUDIO_CLAIM_VERSION_2\n /**\n * The delivered repository. Absent in a provision request: it is signed before payment, when the\n * delivery repository does not exist yet (it reaches Studio at the install-url / claim step). Present\n * means valid; never a placeholder.\n */\n repo?: MigrateStudioRepo\n /** The customer's GitHub user id (decimal string) from the Migrate sign-in. Studio finds or creates the user by it. */\n github_user_id: string\n /** Whether Migrate verified `email`. Studio binds by email only when `true`; never by an unverified one. */\n email_verified: boolean\n /** Where Studio sends the customer after paying (Migrate's payment page; it trusts the payment webhook, never this redirect): `https:` (`http:` for localhost), no credentials or fragment. Studio accepts it only for hosts on its own Migrate allowlist. */\n return_url: string\n billing: MigrateStudioClaimBilling\n}\n\nexport type MigrateStudioClaimV2Result =\n | { ok: true, claim: MigrateStudioClaimV2, warnings?: string[] }\n | { ok: false, errors: string[] }\n\nexport type MigrateStudioClaimResult =\n /**\n * `warnings`: optional parts that were dropped rather than failing the claim\n * (today only `comments_export.*`), as `field: problem` strings; absent when\n * nothing was dropped. `claim` no longer carries a dropped part.\n */\n | { ok: true, claim: MigrateStudioClaim, warnings?: string[] }\n | { ok: false, errors: string[] }\n\nconst CAPABILITY_SET: ReadonlySet<string> = new Set(CAPABILITY_KEYS)\nconst isObject = (x: unknown): x is Record<string, unknown> => typeof x === 'object' && x !== null && !Array.isArray(x)\nconst isText = (x: unknown): x is string => typeof x === 'string' && x.trim().length > 0\nconst isSeconds = (x: unknown): x is number => typeof x === 'number' && Number.isInteger(x) && x > 0\nconst isFiniteNonNegative = (x: unknown): x is number => typeof x === 'number' && Number.isFinite(x) && x >= 0\nconst isCents = (x: unknown): x is number => typeof x === 'number' && Number.isInteger(x) && x >= 0 && x <= 100_000_000\nconst LOCAL_HOSTS: ReadonlySet<string> = new Set(['localhost', '127.0.0.1', '[::1]'])\n\n/** A bare, canonical origin: `new URL(x).origin` gives it back unchanged. */\nfunction isClaimOrigin(x: unknown): x is string {\n if (typeof x !== 'string') return false\n let url: URL\n try { url = new URL(x) }\n catch { return false }\n if (url.origin !== x || url.hostname.endsWith('.')) return false\n return url.protocol === 'https:' || (url.protocol === 'http:' && LOCAL_HOSTS.has(url.hostname))\n}\n\n/** A compact JWS: three base64url segments, the signature non-empty. */\nfunction isCompactJws(x: unknown): x is string {\n return typeof x === 'string' && x.length <= 2048 && /^[\\w-]+\\.[\\w-]+\\.[\\w-]+$/.test(x)\n}\n\n/** A post-payment return address: https (http for localhost), no credentials or fragment; a query is fine. */\nfunction isReturnUrl(x: unknown): x is string {\n if (typeof x !== 'string' || x.length > 2048) return false\n let url: URL\n try { url = new URL(x) }\n catch { return false }\n if (url.username || url.password || url.hash || url.hostname.endsWith('.')) return false\n return url.protocol === 'https:' || (url.protocol === 'http:' && LOCAL_HOSTS.has(url.hostname))\n}\n\n/** A fetchable export address: https (http for localhost), no credentials, query or fragment — nowhere for a secret. */\nfunction isExportUrl(x: unknown): x is string {\n if (typeof x !== 'string' || x.length > 2048) return false\n let url: URL\n try { url = new URL(x) }\n catch { return false }\n if (url.username || url.password || url.search || url.hash || url.hostname.endsWith('.')) return false\n return url.protocol === 'https:' || (url.protocol === 'http:' && LOCAL_HOSTS.has(url.hostname))\n}\n\n/**\n * Check a decoded, signature-verified payload against the contract. Pass\n * `now` (seconds since the epoch) to also check the validity window; without\n * it only the shape and the window's own bounds are checked. Returns every\n * problem found, as stable machine-readable strings (`field: problem`).\n *\n * A malformed `comments_export` does not fail the claim: it is an add-on, and\n * a Migrate still sending an older shape must not stop the trial. It is\n * dropped from `claim` and reported in `warnings`; Studio then offers the\n * file upload instead.\n */\nexport function validateMigrateStudioClaim(input: unknown, options: { now?: number } = {}): MigrateStudioClaimResult {\n return validateClaim(input, options, MIGRATE_STUDIO_CLAIM_VERSION) as MigrateStudioClaimResult\n}\n\n/**\n * The v2 counterpart of `validateMigrateStudioClaim`: same envelope, plan, repo\n * and add-on checks, no `trial_days` (a v2 claim that carries one is refused),\n * plus `github_user_id`, `email_verified`, `return_url` and `billing`.\n */\nexport function validateMigrateStudioClaimV2(input: unknown, options: { now?: number } = {}): MigrateStudioClaimV2Result {\n return validateClaim(input, options, MIGRATE_STUDIO_CLAIM_VERSION_2) as MigrateStudioClaimV2Result\n}\n\n/** Validate a claim of either version; read `result.claim.v` to tell them apart. */\nexport function validateMigrateStudioClaimAny(input: unknown, options: { now?: number } = {}):\n | { ok: true, claim: MigrateStudioClaim | MigrateStudioClaimV2, warnings?: string[] }\n | { ok: false, errors: string[] } {\n const v = isObject(input) ? input.v : undefined\n return v === MIGRATE_STUDIO_CLAIM_VERSION_2\n ? validateMigrateStudioClaimV2(input, options)\n : validateMigrateStudioClaim(input, options)\n}\n\nfunction validateClaim(\n input: unknown,\n options: { now?: number },\n version: typeof MIGRATE_STUDIO_CLAIM_VERSION | typeof MIGRATE_STUDIO_CLAIM_VERSION_2,\n): MigrateStudioClaimResult | MigrateStudioClaimV2Result {\n const errors: string[] = []\n if (!isObject(input)) return { ok: false, errors: ['payload: not an object'] }\n const x = input\n\n if (x.iss !== MIGRATE_STUDIO_CLAIM_ISSUER) errors.push('iss: unexpected issuer')\n if (x.aud !== MIGRATE_STUDIO_CLAIM_AUDIENCE) errors.push('aud: unexpected audience')\n if (x.v !== version) errors.push('v: unsupported version')\n for (const key of ['sub', 'jti', 'order_id'] as const) {\n if (!isText(x[key])) errors.push(`${key}: required`)\n }\n if (!isText(x.email) || !x.email.includes('@')) errors.push('email: invalid')\n\n if (!isSeconds(x.iat)) errors.push('iat: invalid')\n if (!isSeconds(x.exp)) errors.push('exp: invalid')\n if (isSeconds(x.iat) && isSeconds(x.exp)) {\n const ttl = x.exp - x.iat\n if (ttl <= 0) errors.push('exp: not after iat')\n else if (ttl > MIGRATE_STUDIO_CLAIM_MAX_TTL_SECONDS) errors.push('exp: lifetime too long')\n if (options.now !== undefined) {\n if (options.now >= x.exp + MIGRATE_STUDIO_CLAIM_CLOCK_SKEW_SECONDS) errors.push('exp: expired')\n if (x.iat > options.now + MIGRATE_STUDIO_CLAIM_CLOCK_SKEW_SECONDS) errors.push('iat: in the future')\n }\n }\n\n if (!(MIGRATE_STUDIO_PLANS as readonly unknown[]).includes(x.plan)) errors.push('plan: unknown plan')\n if (!Array.isArray(x.plan_evidence)) {\n errors.push('plan_evidence: required (may be empty)')\n }\n else {\n x.plan_evidence.forEach((e, i) => {\n if (!isObject(e) || !isText(e.limit_key) || !isFiniteNonNegative(e.measured) || !isFiniteNonNegative(e.limit)) {\n errors.push(`plan_evidence[${i}]: invalid`)\n }\n else if (e.capability !== undefined && !CAPABILITY_SET.has(e.capability as string)) {\n errors.push(`plan_evidence[${i}].capability: unknown`)\n }\n })\n }\n\n if (version === MIGRATE_STUDIO_CLAIM_VERSION) {\n if (typeof x.trial_days !== 'number' || !Number.isInteger(x.trial_days)\n || x.trial_days < 1 || x.trial_days > MIGRATE_STUDIO_CLAIM_MAX_TRIAL_DAYS) {\n errors.push('trial_days: out of range')\n }\n }\n else {\n if (x.trial_days !== undefined) errors.push('trial_days: not part of v2')\n if (typeof x.github_user_id !== 'string' || !/^[1-9]\\d{0,18}$/.test(x.github_user_id)) errors.push('github_user_id: invalid')\n if (typeof x.email_verified !== 'boolean') errors.push('email_verified: required')\n if (!isReturnUrl(x.return_url)) errors.push('return_url: invalid')\n const b = x.billing\n if (!isObject(b) || !isCents(b.migrate_fee_cents) || !isCents(b.quoted_total_cents) || b.quoted_total_cents <= 0\n || b.currency !== 'usd' || b.quoted_total_cents < b.migrate_fee_cents) {\n errors.push('billing: invalid')\n }\n }\n\n // v2 is signed before payment, when no delivery repository exists yet: `repo` is absent then, and\n // a placeholder would be a false signed claim. A v2 claim that carries one still has to be valid.\n const repo = x.repo\n if (repo !== undefined || version === MIGRATE_STUDIO_CLAIM_VERSION) {\n if (!isObject(repo) || repo.provider !== 'github' || !isText(repo.owner) || !isText(repo.name)\n || repo.owner.includes('/') || repo.name.includes('/')) {\n errors.push('repo: invalid')\n }\n }\n\n if (x.capabilities !== undefined) {\n if (!Array.isArray(x.capabilities)) {\n errors.push('capabilities: not an array')\n }\n else {\n x.capabilities.forEach((c, i) => {\n if (!isObject(c) || !CAPABILITY_SET.has(c.key as string)) errors.push(`capabilities[${i}]: unknown capability`)\n else if (c.scale !== undefined && c.scale !== null && typeof c.scale !== 'string') errors.push(`capabilities[${i}].scale: invalid`)\n })\n }\n }\n\n if (x.origin !== undefined && !isClaimOrigin(x.origin)) errors.push('origin: invalid')\n\n const warnings: string[] = []\n if (x.comments_export !== undefined) {\n const ce = x.comments_export\n if (!isObject(ce)) {\n warnings.push('comments_export: invalid')\n }\n else {\n if (!isExportUrl(ce.url)) warnings.push('comments_export.url: invalid')\n if (!isCompactJws(ce.token)) warnings.push('comments_export.token: invalid')\n if (!isSeconds(ce.expires_at)) warnings.push('comments_export.expires_at: invalid')\n else if (isSeconds(x.iat) && ce.expires_at <= x.iat) warnings.push('comments_export.expires_at: not after iat')\n if (typeof ce.comments !== 'number' || !Number.isInteger(ce.comments) || ce.comments < 0) warnings.push('comments_export.comments: invalid')\n }\n }\n\n if (errors.length > 0) return { ok: false, errors }\n if (warnings.length === 0) return { ok: true, claim: x as never }\n const { comments_export: _dropped, ...rest } = x\n return { ok: true, claim: rest as never, warnings }\n}\n\n/**\n * Shape-only type guard (no clock check). The signature is the consumer's job. Stricter than\n * `validateMigrateStudioClaim`: a claim whose `comments_export` would be dropped is `false` here,\n * since the guard narrows the input itself, malformed export included. To keep such a claim, use\n * `result.claim` from `validateMigrateStudioClaim`.\n */\nexport function isMigrateStudioClaim(input: unknown): input is MigrateStudioClaim {\n const result = validateMigrateStudioClaim(input)\n return result.ok && !result.warnings\n}\n\n// ─── Account state (Offer sizing) ───\n//\n// Before the Offer, Migrate asks Studio what the customer's GitHub account\n// already has, so the Studio line and its price are right: nothing (`none`:\n// \"Migrate with Studio\"), a plan that already carries the site (`covers`:\n// \"added to your plan, $0\"), or one that does not (`too_small`: upgrade).\n// Server to server, signed like a claim (same key, `iss`/`aud`/`jti`/`exp`).\n\nexport const MIGRATE_ACCOUNT_STATES = ['none', 'covers', 'too_small'] as const\nexport type MigrateAccountStateKind = (typeof MIGRATE_ACCOUNT_STATES)[number]\n\n/** The signed request body Migrate sends. */\nexport interface MigrateAccountStateRequest {\n iss: typeof MIGRATE_STUDIO_CLAIM_ISSUER\n aud: typeof MIGRATE_STUDIO_CLAIM_AUDIENCE\n jti: string\n iat: number\n exp: number\n github_user_id: string\n /** The plan discovery sized (`MigrateStudioPlan`). */\n plan: MigrateStudioPlan\n}\n\n/** Studio's answer. `year1_cents` is what the Studio line adds to the first invoice (0 when `covers`). */\nexport interface MigrateAccountStateResponse {\n state: MigrateAccountStateKind\n /** The plan the Studio line sells or keeps: the sized plan, or the account's own when it covers. */\n plan: MigrateStudioPlan\n /** Studio part of the first invoice, in cents: year 1 at 20% off (`none`), the plan difference (`too_small`), 0 (`covers`). */\n year1_cents: number\n /** What the subscription renews at after year 1, in cents: the yearly list price of `plan` (0 when `covers`). Migrate shows it and never computes it; absent from a Studio that predates it (the cart then omits the renewal line). */\n renewal_cents?: number\n /** The account's current plan, when it has a running one. */\n current_plan?: MigrateStudioPlan\n}\n\n/** Plans in ascending order; a higher index carries more. */\nconst PLAN_RANK: Record<MigrateStudioPlan, number> = { starter: 0, pro: 1 }\n\n/**\n * Check the signed request Studio receives. Pass `now` (seconds since the\n * epoch) to also check the validity window. It does no cryptography, and it\n * does not remember `jti`: Studio keeps a replay record for account-state\n * requests, as it does for claims (a repeated `jti` is refused).\n */\nexport function validateMigrateAccountStateRequest(input: unknown, options: { now?: number } = {}):\n | { ok: true, request: MigrateAccountStateRequest }\n | { ok: false, errors: string[] } {\n if (!isObject(input)) return { ok: false, errors: ['payload: not an object'] }\n const x = input\n const errors: string[] = []\n if (x.iss !== MIGRATE_STUDIO_CLAIM_ISSUER) errors.push('iss: unexpected issuer')\n if (x.aud !== MIGRATE_STUDIO_CLAIM_AUDIENCE) errors.push('aud: unexpected audience')\n if (!isText(x.jti)) errors.push('jti: required')\n if (!isSeconds(x.iat)) errors.push('iat: invalid')\n if (!isSeconds(x.exp)) errors.push('exp: invalid')\n if (isSeconds(x.iat) && isSeconds(x.exp)) {\n const ttl = x.exp - x.iat\n if (ttl <= 0) errors.push('exp: not after iat')\n else if (ttl > MIGRATE_STUDIO_CLAIM_MAX_TTL_SECONDS) errors.push('exp: lifetime too long')\n if (options.now !== undefined) {\n if (options.now >= x.exp + MIGRATE_STUDIO_CLAIM_CLOCK_SKEW_SECONDS) errors.push('exp: expired')\n if (x.iat > options.now + MIGRATE_STUDIO_CLAIM_CLOCK_SKEW_SECONDS) errors.push('iat: in the future')\n }\n }\n if (typeof x.github_user_id !== 'string' || !/^[1-9]\\d{0,18}$/.test(x.github_user_id)) errors.push('github_user_id: invalid')\n if (!(MIGRATE_STUDIO_PLANS as readonly unknown[]).includes(x.plan)) errors.push('plan: unknown plan')\n return errors.length === 0 ? { ok: true, request: x as unknown as MigrateAccountStateRequest } : { ok: false, errors }\n}\n\n/**\n * Check Studio's answer against the plan that was asked about (`requested`,\n * required: an answer is only meaningful against its question), which is what\n * Migrate does before it prices an Offer from it:\n * - `none`: no `current_plan`; `plan` is the requested plan.\n * - `too_small`: `plan` is the requested plan and `current_plan` is below it.\n * - `covers`: `current_plan` is at least the requested plan, `plan` is that\n * current plan, and nothing is added (`year1_cents` 0).\n * Errors are the stable `field: problem` strings the claim validators use.\n */\nexport function validateMigrateAccountStateResponse(input: unknown, options: { requested: MigrateStudioPlan }):\n | { ok: true, response: MigrateAccountStateResponse }\n | { ok: false, errors: string[] } {\n if (!isObject(input)) return { ok: false, errors: ['payload: not an object'] }\n const errors: string[] = []\n const planOk = (MIGRATE_STUDIO_PLANS as readonly unknown[]).includes(input.plan)\n const currentOk = (MIGRATE_STUDIO_PLANS as readonly unknown[]).includes(input.current_plan)\n if (!(MIGRATE_ACCOUNT_STATES as readonly unknown[]).includes(input.state)) errors.push('state: unknown')\n if (!planOk) errors.push('plan: unknown plan')\n if (!isCents(input.year1_cents)) errors.push('year1_cents: invalid')\n else if (input.state === 'covers' && input.year1_cents !== 0) errors.push('year1_cents: must be 0 when covers')\n else if (input.state !== undefined && input.state !== 'covers' && input.year1_cents === 0) errors.push('year1_cents: must be > 0')\n if (input.renewal_cents === undefined) { /* optional: an older Studio does not send it */ }\n else if (!isCents(input.renewal_cents)) errors.push('renewal_cents: invalid')\n else if (input.state === 'covers' && input.renewal_cents !== 0) errors.push('renewal_cents: must be 0 when covers')\n else if (input.state !== undefined && input.state !== 'covers' && input.renewal_cents === 0) errors.push('renewal_cents: must be > 0')\n if (input.current_plan !== undefined && !currentOk) errors.push('current_plan: unknown plan')\n if (input.state === 'none' && input.current_plan !== undefined) errors.push('current_plan: not allowed when none')\n if ((input.state === 'covers' || input.state === 'too_small') && input.current_plan === undefined) errors.push('current_plan: required unless none')\n const asked = (options as { requested?: unknown } | undefined)?.requested as MigrateStudioPlan\n // Fail closed: a missing or unknown `requested` (a caller bypassing the types) can never pass.\n if (!(MIGRATE_STUDIO_PLANS as readonly unknown[]).includes(asked)) errors.push('requested: unknown plan')\n else if (planOk) {\n const plan = input.plan as MigrateStudioPlan\n if (input.state === 'covers') {\n if (currentOk && PLAN_RANK[input.current_plan as MigrateStudioPlan] < PLAN_RANK[asked]) errors.push('current_plan: below the requested plan')\n if (currentOk && plan !== input.current_plan) errors.push('plan: must be the current plan when covers')\n }\n else if (input.state === 'none' || input.state === 'too_small') {\n if (plan !== asked) errors.push('plan: not the requested plan')\n if (input.state === 'too_small' && currentOk && PLAN_RANK[input.current_plan as MigrateStudioPlan] >= PLAN_RANK[asked]) errors.push('current_plan: not below the requested plan')\n }\n }\n return errors.length === 0 ? { ok: true, response: input as unknown as MigrateAccountStateResponse } : { ok: false, errors }\n}\n\n// ─── Grant status and install URL (Studio setup beside the live move) ───\n//\n// While the move runs, the Migrate page can show a Studio card: is the trial\n// running, and may the customer connect Studio's GitHub App yet? Both are\n// asked of Studio, server to server, signed like the account-state request\n// (same key, `iss`/`aud`/`jti`/`exp`, the token POSTed as `{ token }`). The\n// order is the key: Studio keeps one grant per `order_id`, and an order Studio\n// has no grant for is a 404 there, not a state.\n\nexport const MIGRATE_GRANT_STATES = ['claimed', 'bound', 'redeemed', 'revoked'] as const\n/**\n * Where the order's Studio grant stands:\n * - `claimed`: provisioned, the Studio year not started (checkout not completed).\n * - `bound`: tied to a Studio workspace, the subscription not running yet\n * (checkout open or its webhook late).\n * - `redeemed`: the subscription is running (trial or paid). Only now may the\n * customer connect GitHub, or Studio could not open a project (402).\n * - `revoked`: the grant was withdrawn (by the founder, or after a refund).\n * It may still be `installed` (withdrawn after the App was installed); Migrate\n * shows it as \"not ready\", like an unknown state.\n */\nexport type MigrateGrantState = (typeof MIGRATE_GRANT_STATES)[number]\n\n/** The signed envelope every Migrate → Studio request after provisioning carries. */\ninterface MigrateS2sEnvelope {\n iss: typeof MIGRATE_STUDIO_CLAIM_ISSUER\n aud: typeof MIGRATE_STUDIO_CLAIM_AUDIENCE\n jti: string\n iat: number\n exp: number\n /** The Migrate order whose grant is asked about. */\n order_id: string\n}\n\n/** `POST /api/migrate/grants/status`: where does this order's grant stand? */\nexport type MigrateGrantStatusRequest = MigrateS2sEnvelope\n\n/** `POST /api/migrate/grants/install-url`: where may the customer install Studio's GitHub App? */\nexport type MigrateInstallUrlRequest = MigrateS2sEnvelope\n\nexport interface MigrateGrantStatusResponse {\n state: MigrateGrantState\n /** Studio's GitHub App is installed for the grant's workspace. Only possible once `redeemed` (and after, if `revoked`). */\n installed: boolean\n}\n\nexport interface MigrateInstallUrlResponse {\n /**\n * The GitHub App install page, `https://github.com/apps/<slug>/installations/new?state=<signed>`.\n * It carries Studio's own signed `state` (grant, workspace, return address), and no repository:\n * during the move the delivery repository does not exist yet. Opened by the customer's browser.\n */\n url: string\n /** When `url` stops being useful (its `state` expires), seconds since the epoch. */\n expires_at: number\n}\n\nfunction checkS2sEnvelope(input: unknown, options: { now?: number }):\n | { ok: true, request: MigrateS2sEnvelope }\n | { ok: false, errors: string[] } {\n if (!isObject(input)) return { ok: false, errors: ['payload: not an object'] }\n const x = input\n const errors: string[] = []\n if (x.iss !== MIGRATE_STUDIO_CLAIM_ISSUER) errors.push('iss: unexpected issuer')\n if (x.aud !== MIGRATE_STUDIO_CLAIM_AUDIENCE) errors.push('aud: unexpected audience')\n if (!isText(x.jti)) errors.push('jti: required')\n if (!isText(x.order_id)) errors.push('order_id: required')\n if (!isSeconds(x.iat)) errors.push('iat: invalid')\n if (!isSeconds(x.exp)) errors.push('exp: invalid')\n if (isSeconds(x.iat) && isSeconds(x.exp)) {\n const ttl = x.exp - x.iat\n if (ttl <= 0) errors.push('exp: not after iat')\n else if (ttl > MIGRATE_STUDIO_CLAIM_MAX_TTL_SECONDS) errors.push('exp: lifetime too long')\n if (options.now !== undefined) {\n if (options.now >= x.exp + MIGRATE_STUDIO_CLAIM_CLOCK_SKEW_SECONDS) errors.push('exp: expired')\n if (x.iat > options.now + MIGRATE_STUDIO_CLAIM_CLOCK_SKEW_SECONDS) errors.push('iat: in the future')\n }\n }\n return errors.length === 0 ? { ok: true, request: x as unknown as MigrateS2sEnvelope } : { ok: false, errors }\n}\n\n/** Check the signed status request Studio receives. Same rules as `validateMigrateAccountStateRequest`; the signature and `jti` replay are the consumer's. */\nexport function validateMigrateGrantStatusRequest(input: unknown, options: { now?: number } = {}):\n | { ok: true, request: MigrateGrantStatusRequest }\n | { ok: false, errors: string[] } {\n return checkS2sEnvelope(input, options)\n}\n\n/** Check the signed install-URL request Studio receives. Same rules as the status request. */\nexport function validateMigrateInstallUrlRequest(input: unknown, options: { now?: number } = {}):\n | { ok: true, request: MigrateInstallUrlRequest }\n | { ok: false, errors: string[] } {\n return checkS2sEnvelope(input, options)\n}\n\n/** Check Studio's status answer. `installed` is refused before `redeemed`: an install can only follow a running subscription (a `revoked` grant may keep an install made earlier). */\nexport function validateMigrateGrantStatusResponse(input: unknown):\n | { ok: true, response: MigrateGrantStatusResponse }\n | { ok: false, errors: string[] } {\n if (!isObject(input)) return { ok: false, errors: ['payload: not an object'] }\n const errors: string[] = []\n if (!(MIGRATE_GRANT_STATES as readonly unknown[]).includes(input.state)) errors.push('state: unknown')\n if (typeof input.installed !== 'boolean') errors.push('installed: required')\n else if (input.installed && input.state !== 'redeemed' && input.state !== 'revoked') errors.push('installed: only once redeemed')\n return errors.length === 0 ? { ok: true, response: input as unknown as MigrateGrantStatusResponse } : { ok: false, errors }\n}\n\n/**\n * Check Studio's install-URL answer before Migrate hands it to a browser: the\n * address must be GitHub's own install page for an App (`https://github.com/apps/<slug>/installations/new`,\n * no credentials, no fragment), and must not already be expired when `now` is given.\n */\nexport function validateMigrateInstallUrlResponse(input: unknown, options: { now?: number } = {}):\n | { ok: true, response: MigrateInstallUrlResponse }\n | { ok: false, errors: string[] } {\n if (!isObject(input)) return { ok: false, errors: ['payload: not an object'] }\n const errors: string[] = []\n if (!isGithubInstallUrl(input.url)) errors.push('url: not a GitHub App install address')\n if (!isSeconds(input.expires_at)) errors.push('expires_at: invalid')\n else if (options.now !== undefined && input.expires_at <= options.now) errors.push('expires_at: expired')\n return errors.length === 0 ? { ok: true, response: input as unknown as MigrateInstallUrlResponse } : { ok: false, errors }\n}\n\nfunction isGithubInstallUrl(x: unknown): x is string {\n if (typeof x !== 'string' || x.length > 4096) return false\n let url: URL\n try { url = new URL(x) }\n catch { return false }\n return url.protocol === 'https:' && url.hostname === 'github.com' && !url.port && !url.username && !url.password && !url.hash\n && /^\\/apps\\/[\\w-]+\\/installations\\/new(\\/permissions)?\\/?$/.test(url.pathname)\n}\n\n// ─── Provision (Migrate with Studio: one checkout for the move and the Studio year) ───\n//\n// `POST /api/migrate/provision`. Migrate asks Studio, server to server, to set up the\n// customer's Studio side before they pay: find or create the user by GitHub id, record the\n// grant, and open ONE Polar checkout whose first invoice is the whole bundle\n// (`billing.quoted_total_cents`: Migrate fee + Studio year 1). The request is the signed\n// `MigrateStudioClaimV2` itself, POSTed as `{ token }` and single-use by `jti` under its own\n// purpose, so a provision token cannot also be replayed as a claim link. Studio recomputes the\n// Studio line (`MigrateAccountStateResponse`) and refuses a quote it does not agree with.\n// The customer then pays at `checkout_url`; Migrate learns of the payment from its own webhook.\n\n/** Studio's answer to a provision request. */\nexport interface MigrateProvisionResponse {\n /** The Studio grant for the order (one per order; a repeated provision returns the same one). */\n grant_id: string\n /** `claimed` on a fresh provision; a repeat may find the grant further along. */\n state: MigrateGrantState\n /** The plan the Studio line sells or keeps (`MigrateAccountStateResponse.plan`). */\n plan: MigrateStudioPlan\n /** The Studio workspace the grant is tied to, when it already is (an existing subscriber's, or after binding). */\n workspace_slug?: string\n /** Polar's checkout page for the bundle: `https://polar.sh/...` or `https://sandbox.polar.sh/...`, no credentials or fragment. Opened by the customer's browser. */\n checkout_url: string\n /** The first invoice Studio put on the checkout, in cents. Must equal the request's `billing.quoted_total_cents`. */\n amount_cents: number\n /** When `checkout_url` stops being payable, seconds since the epoch. */\n checkout_expires_at: number\n}\n\n/**\n * Check Studio's provision answer before Migrate redirects a browser to it. `quoted_total_cents` is the\n * request's quote (required, like `requested` in the account-state check: an answer only means something\n * against its question); a different amount is refused, and so is an expired checkout when `now` is given.\n */\nexport function validateMigrateProvisionResponse(input: unknown, options: { quoted_total_cents: number, now?: number }):\n | { ok: true, response: MigrateProvisionResponse }\n | { ok: false, errors: string[] } {\n if (!isObject(input)) return { ok: false, errors: ['payload: not an object'] }\n const errors: string[] = []\n if (!isText(input.grant_id) || input.grant_id.length > 128) errors.push('grant_id: required')\n if (!(MIGRATE_GRANT_STATES as readonly unknown[]).includes(input.state)) errors.push('state: unknown')\n if (!(MIGRATE_STUDIO_PLANS as readonly unknown[]).includes(input.plan)) errors.push('plan: unknown plan')\n if (input.workspace_slug !== undefined && (typeof input.workspace_slug !== 'string' || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(input.workspace_slug))) errors.push('workspace_slug: invalid')\n if (!isPolarCheckoutUrl(input.checkout_url)) errors.push('checkout_url: not a Polar checkout address')\n const quoted = (options as { quoted_total_cents?: unknown } | undefined)?.quoted_total_cents\n if (!isCents(input.amount_cents) || input.amount_cents === 0) errors.push('amount_cents: invalid')\n // Fail closed: a missing or invalid quote (a caller bypassing the types) can never pass.\n else if (!isCents(quoted) || quoted === 0 || input.amount_cents !== quoted) errors.push('amount_cents: not the quoted total')\n if (!isSeconds(input.checkout_expires_at)) errors.push('checkout_expires_at: invalid')\n else if (options.now !== undefined && input.checkout_expires_at <= options.now) errors.push('checkout_expires_at: expired')\n return errors.length === 0 ? { ok: true, response: input as unknown as MigrateProvisionResponse } : { ok: false, errors }\n}\n\nfunction isPolarCheckoutUrl(x: unknown): x is string {\n if (typeof x !== 'string' || x.length > 4096) return false\n let url: URL\n try { url = new URL(x) }\n catch { return false }\n return url.protocol === 'https:' && (url.hostname === 'polar.sh' || url.hostname === 'sandbox.polar.sh') && !url.port && !url.username && !url.password && !url.hash\n && url.pathname.startsWith('/checkout/')\n}\n","// ─── Project plan ───\n//\n// The one holistic decision of a migration, between the fact pack (what the\n// WordPress site is) and the writer (the Astro + Contentrain project). It is\n// written once per site — by the planner from the facts, the builder mapping\n// table and the narrow decisions — and then applied deterministically: models\n// and content come from the import, components from the kit or the site\n// writer, and every page is composed from placements whose props are bound to\n// content. Nothing in a plan is page content: it names where content comes\n// from, never copies it. That keeps the cost proportional to the number of\n// components, not pages, and keeps Studio edits reaching the site.\n//\n// `site` mirrors the starter's `src/site.config.ts` field for field, plus the\n// three files the deterministic writer also owns: `astro.config` (`url`),\n// `redirects.json` and the `@theme` block of `global.css`.\n//\n// Addresses the starter owns are not plan routes: `/search/`, `/rss.xml`,\n// `/404`, `robots.txt` and the sitemap. A plan never emits them.\n\nimport { isTitleFieldType, titleFieldTarget, type FieldDef, type ModelKind } from './index.js'\n\nexport const PROJECT_PLAN_FORMAT = 'contentrain-project-plan@1'\n\n/** Semantic design roles the kit reads — the starter's `@theme` names without the `--` prefix. */\nexport const PLAN_TOKEN_ROLES = [\n 'color-surface', 'color-surface-muted', 'color-ink', 'color-ink-muted', 'color-line', 'color-accent', 'color-accent-ink',\n 'font-sans', 'font-serif', 'font-mono', 'container-prose', 'container-page', 'container-wide', 'radius-card',\n // The source theme's type and shape (theme.json styles): absent means the kit's own values.\n 'font-weight-heading', 'font-weight-body', 'text-body', 'leading-body', 'leading-heading', 'radius-control', 'radius-image', 'spacing-gutter',\n // The theme's type scale and rhythm: the site's theme applies them on the kit's markers (`data-kit-*`, `data-cr-part`).\n // `spacing-section` is the distance between two consecutive top-level sections, not a padding: each section gets half.\n // `spacing-title`: the space under a page's or post's title (the prose h1; absent: the kit's own, 0.8888889em or 0.8333333em at the large prose size).\n // `container-inset`: the page frame's `container-page` is the theme's wide size as CONTENT width, so the frame adds\n // this much on each side (the root padding); absent: 0, the starter's frame holds its gutter inside the width.\n 'spacing-title', 'container-inset',\n 'text-nav', 'text-footer', 'text-heading-1', 'text-heading-2', 'text-heading-3', 'spacing-section',\n // The source's link colour where it is not the accent (Hello Elementor: #c36 links, a grey button). Absent: the accent.\n 'color-link',\n // The page behind the content and the card on it, where the source tints one and floats the other (Astra: a blue-grey\n // page, white shadowed cards). Absent: both are the surface. `shadow-card` is a box-shadow, `spacing-card` the card's padding.\n 'color-page', 'color-card', 'shadow-card', 'spacing-card',\n // The brand colour a page builder declares and paints on a band of its own (Elementor's secondary on a call to action),\n // where it is not the accent the buttons paint; `color-brand-ink` is the text on it. Absent: the accent and its ink.\n 'color-brand', 'color-brand-ink',\n] as const\nexport type PlanTokenRole = (typeof PLAN_TOKEN_ROLES)[number]\n\nexport interface ProjectPlan {\n format: typeof PROJECT_PLAN_FORMAT\n source: PlanSource\n site: PlanSite\n /** The starter's single `BaseLayout`: chrome every route shares. */\n layout: { header?: PlanPlacement, footer?: PlanPlacement }\n models: PlanModel[]\n components: PlanComponent[]\n routes: PlanRoute[]\n /**\n * What becomes of each behavior the fact pack recorded (form, search, embed, accordion, …), one\n * record per fact behavior id — so none is dropped without a reason a reviewer can read.\n */\n behaviors?: PlanBehavior[]\n /** Every decision the plan rests on, with who made it — the audit trail a reviewer reads. */\n decisions?: PlanDecisionRecord[]\n}\n\nexport interface PlanSource {\n /** The WordPress origin, `https://example.com`. */\n origin: string\n builder: 'gutenberg' | 'elementor' | 'divi' | 'classic'\n /** Hash of the fact pack the plan was made from; a plan is only valid for those facts. */\n facts: string\n /** Version of the builder mapping table the plan applied. */\n mapping?: string\n}\n\n// ─── Site ───\n\n/** WordPress-style address pattern: `/blog/:slug/`, `/:path/`; tokens `:slug :path :year :month :day :id`. */\nexport type PlanPermalink = string\n\n/** From `min` px of viewport width up, a column `width` px wide, centred. */\nexport interface ChromeFrameStep { min: number, width: number }\n/** From `min` px of viewport up, the post cards run in `columns` columns. */\nexport interface ChromeColumnStep { min: number, columns: number }\n\nexport interface PlanSite {\n /** Public URL the site is built for (`astro.config` `site`). */\n url: string\n title: string\n description?: string\n /** Default locale, short form (`en`, `tr`). */\n locale: string\n permalinks: { post: PlanPermalink, page: PlanPermalink, category: PlanPermalink, tag: PlanPermalink, author: PlanPermalink, blog: PlanPermalink }\n home: { kind: 'posts' } | { kind: 'page', slug: string }\n postsPerPage: number\n /**\n * Menu slugs (`menus.slug`, as WordPress named them) for the starter's navigation areas — `getMenu(slug)`.\n * `footer` lists the footer's menus in the source's order, one column each (at most 4); empty for none.\n * A plan written before 1.25 has one slug, or `'none'`: read it through {@link footerMenusOf}.\n */\n menus: { primary: string, footer: string | string[] }\n /**\n * A single post as the source's single template lays it out: the header parts in their order, links\n * to the previous and next post (`core/post-navigation-link`), and how many other posts a list under\n * it shows (a `core/query` after the content; 0 for none). `moreIncludesCurrent`: that list also shows the post\n * being read, as the source's query does (it does not exclude the current post); absent, the post is left out. `adjacentText`: the size the previous/next links print at (a CSS size: the theme's preset on `core/post-navigation-link`); absent, the starter's own. `moreText`: the size that list prints its\n * titles at (a CSS size: the theme's `large` preset on `core/post-title`); absent, the starter's own.\n * Absent: the starter's own layout.\n */\n post?: { header: Array<'terms' | 'title' | 'byline' | 'cover'>, adjacent: boolean, adjacentText?: string, more: number, moreIncludesCurrent?: boolean, moreText?: string }\n /**\n * Post lists (blog index, archives): `cards`, or `full` when the source's query loop shows each post's\n * content; `list` is cards in one column, as themes whose archive is a single stack of posts show\n * it. `heading` shows the index's title on the front page. Absent: cards without a heading.\n * `text`: the size of each post's content in a `full` list, when the source's loop sets its own\n * (Twenty Twenty-Five: `has-medium-font-size` on the query's post content); absent, the body size.\n * `tone`: `muted` when the source's query loop sets its own text colour to the theme's muted one (its titles\n * carry their own colour, so only the meta text follows); absent, the body colour.\n * `surface`: cards are floating cards (the `color-card`, `radius-card`, `shadow-card` and `spacing-card` roles), not\n * bare. `byline`: each card prints its author and date below the title, the category above it, as the source's do.\n */\n lists?: { display: 'cards' | 'list' | 'full', heading: boolean, text?: string, tone?: 'muted', surface?: boolean, byline?: boolean }\n /**\n * The interface text the source theme itself printed (its \"Read more\" link, skip link, search placeholder, the\n * previous and next labels), by the `site/ui-strings` key it answers (`blog.read_more`, `nav.skip`): written over the\n * site's bundled table, because the source's wording is what its visitors saw. Plain text, no markup; at most 100.\n */\n uiStrings?: Record<string, string>\n /**\n * What the source's own page for a custom post type showed, by the type's single route id (`type-project`):\n * `adjacent` = links to the previous and next entry under it. The starter's entry view prints them for that\n * type only. `more` = a list of that many other entries under it (0 to 20); `moreIncludesCurrent`: the list also\n * shows the entry being read, as the source's query does; `moreOf: 'posts'`: the list holds the newest blog posts, as a\n * theme's single template does whatever the type, not other entries of the same type; `moreText`: the size it prints its titles at (a CSS size). Absent (or a type left out): none.\n */\n entryLayouts?: Record<string, { adjacent: boolean, more?: number, moreIncludesCurrent?: boolean, moreOf?: 'posts', moreText?: string }>\n /**\n * What the source's footer prints of the three things the starter's footer prints by default, each only when the\n * source does (a migration reads them off the rendered footer). `feed`: a visible \"RSS feed\" link in the last\n * column (the feed file itself, `/rss.xml`, is always kept for SEO). `border`: a border on the footer's top side.\n * `copyright`: the line it prints (`{year}` for the current year), or `false` when it prints none. The \"Powered by\n * WordPress\" credit is platform chrome and is never carried. Absent, or a key absent: the starter's own (the link,\n * the border on the default tone, \"© year Site\"). `chrome.copyright` is the older place for the line.\n */\n footer?: { feed?: boolean, border?: boolean, copyright?: string | false }\n /**\n * The header and footer as the source's theme prints them, where they differ from the starter's.\n * Every key is optional; absent, the starter's own. `brand`: the site title's size in the header\n * (Hello Elementor: a 2.5rem heading). `tagline`: the header shows the tagline under the title.\n * `copyright`: the footer's line as the source prints it (\"All rights reserved\"; `{year}` for the\n * current year), instead of \"© year Site\". `titleLinks`: post titles in lists take the link colour, as the source's do.\n * `navLinks`: so does the header navigation. `footerAlign`: `center` when the source's footer is\n * one centred column; absent, brand and links to the sides. `frame`: the width the header's and footer's\n * content is held to where the theme boxes it, read from the source's renders: from `min` px of viewport\n * up it is `width` px wide and centred (Hello Elementor: 768 → 600, 1280 → 1140); below the first step,\n * and absent, the starter's own fluid frame. `listFrame`: the same for the post lists' main column.\n * `postFrame`: the same for a single post's reading column, where the theme reads narrower than its frame\n * (Astra: 630 in a 1200 frame). `listColumns`: how many columns the post cards run in from `min` px of viewport up,\n * as the source's lists show them (Astra: one column at 768, three at 1280); absent, the starter's own grid.\n * `footerColumns`: the footer's link columns packed at the end edge at their own width, as the source's are (a theme's\n * footer is the brand left and two menus on the right, not a grid spread from the centre): `gap` px between the\n * columns, the links `size` px, and `narrowGap` px when the columns stay side by side below 768 (absent: they stack).\n * Absent, the starter's own: the columns share the footer's width.\n * `header`: what the source's header does differently from the starter's, each key only where it does (computed styles):\n * `border: false` draws no line under the bar; `brandWeight` and `brandTracking` set the site name's weight and\n * letter-spacing; `offsetTop` is the space above the bar and `padTop`/`padBottom` the space inside it above and below\n * its content (CSS lengths, a fluid theme's as a `clamp()`). A key absent: the starter's own.\n */\n chrome?: {\n brand?: string\n tagline?: boolean\n copyright?: string\n titleLinks?: boolean\n navLinks?: boolean\n footerAlign?: 'start' | 'center'\n frame?: ChromeFrameStep[]\n listFrame?: ChromeFrameStep[]\n postFrame?: ChromeFrameStep[]\n listColumns?: ChromeColumnStep[]\n footerColumns?: { gap: number, size?: number, narrowGap?: number }\n header?: { border?: false, brandWeight?: string, brandTracking?: string, offsetTop?: string, padTop?: string, padBottom?: string }\n }\n studio?: { baseUrl: string, projectId: string }\n /** `redirects.json`: old path → new path, or with a status other than 301. */\n redirects: Record<string, string | { status: number, destination: string }>\n tokens: PlanTokens\n}\n\nexport interface PlanTokens {\n /** Values of the kit's roles. A role left out falls back to the starter's default. */\n roles: Partial<Record<PlanTokenRole, string>>\n /** The site's own scale beyond the roles (`--color-brand-2: …`), as `@theme` declarations. */\n extra?: Record<string, string>\n /** WordPress preset slug → value per kind, for `has-<slug>-*` classes left in rich-text bodies. */\n presets?: Partial<Record<'color' | 'font-size' | 'font-family' | 'spacing' | 'shadow', Record<string, string>>>\n /** Fonts to self-host: files are fetched once and served from the project. */\n fonts?: { family: string, weight: string, style: string, unicodeRange?: string, files: string[] }[]\n}\n\n// ─── Models ───\n\n/**\n * A content model of the project. Imported models (`posts`, `pages`, `categories`, `tags`, `authors`,\n * `media`, `menus`, `menu-items`, `site`, `ui-strings`) are written by wp-import and only referenced;\n * a `plan` model is new — section content lifted out of builder pages into editable fields.\n */\nexport interface PlanModel {\n id: string\n kind: ModelKind\n origin: 'import' | 'plan'\n /** What a `plan` model needs to be a Contentrain model: display name, storage domain (`.contentrain/content/<domain>/<id>`), locales, and the field an entry list shows. */\n name?: string\n /** What the model holds, for Studio's model list (the model definition's `description`). */\n description?: string\n domain?: string\n i18n?: boolean\n /** Required for a `plan` collection/document: a text-like field (`validate` refuses a model without one). */\n title_field?: string\n /** Fields of a `plan` model (an imported model's fields are the import's). */\n fields?: Record<string, FieldDef>\n /** How entries of a `plan` model are filled from the source, deterministically. */\n extract?: PlanExtraction[]\n /**\n * A Studio form: submissions from the site's form are saved as entries of this model (a `plan`\n * collection with `i18n: false` whose fields are the source form's fields). Written to the model definition's `form` key.\n */\n form?: PlanFormConfig\n}\n\n/**\n * The form settings Studio reads from a model definition's `form` key (Studio's `FormConfig`, kept here\n * so the plan does not depend on Studio). Captcha, honeypot and notifications replace what the form\n * plugin did on WordPress; mail recipients and webhooks are never carried over.\n */\nexport interface PlanFormConfig {\n enabled: boolean\n /** Accept submissions from the public site (no Studio session). */\n public: boolean\n /** Model fields the public form shows and accepts, in order. */\n exposedFields: string[]\n /** Field → required on the form, where it differs from the model field's own `required`. */\n requiredOverrides?: Record<string, boolean>\n honeypot?: boolean\n captcha?: 'turnstile' | null\n successMessage?: string\n autoApprove?: boolean\n /** Email the workspace owner/admins on every submission. */\n notifications?: boolean\n}\n\n/**\n * Fill a plan model from builder elements. Each matched element becomes one entry (collection) or\n * the entry (singleton); each field reads a path of the element — the same paths the fact pack's\n * component variance reported. The engine applies it to every page; the model never retypes content.\n */\nexport interface PlanExtraction {\n /** Builder element (`core/cover`, `elementor/icon-box`), fact component id (`repeat:1x2y`), or `range:<from>-<to>` (top-level body blocks, for prose runs). */\n from: string\n /** Only on these WordPress entries (ids); all pages when absent. */\n pages?: number[]\n /** Entry id for a matched element: `page-slug`, `page-slug:index`, or a fixed id for a singleton. */\n entryId: string\n /**\n * Apply this builder mapping rule (`<builder>:<match>`, astro-kit `mapping/<builder>.json`) to the element:\n * its props, `into`/`each`/`item` fill the entry's fields of the same names. `fields` then only adds to it.\n */\n rule?: string\n /**\n * The object field of a page singleton this extraction fills (`hero`, `services`): the element is one\n * section, and `rule`'s props / `fields` fill that field's keys instead of the entry's top level.\n */\n field?: string\n /** Model field → `<path>|<prop>` inside the element (`0.1.0|text`, `0.0|src`), `attr:<name>`, or an element expression (`dom:`, `img:`, `link:`, `html:`). */\n fields?: Record<string, string>\n}\n\n// ─── Components ───\n\nexport interface PlanComponent {\n /**\n * Plan-level name (`Hero`, `PostCard`). A `site` component is written to `src/components/<id>.astro`; a\n * `kit` component is copied by the kit's copy planner to `src/components/kit/<kit-id>/`.\n */\n id: string\n /** Copied from the kit, or written for this site by the writer. Variants are per placement. */\n origin: 'kit' | 'site'\n kit?: { id: string }\n /** A `site` component's props. For a `kit` component the catalog is the source and this stays empty. */\n props?: Record<string, FieldDef>\n /** Fact component ids / builder elements this component stands for. */\n covers?: string[]\n /** For `site` components: what the writer must reproduce (fact template + region paths to look at). */\n brief?: { template: string, regions: string[], notes?: string }\n}\n\n// ─── Routes ───\n\nexport type PlanRouteKind = 'home' | 'post' | 'page' | 'blog' | 'category' | 'tag' | 'author' | 'custom'\n\nexport interface PlanRoute {\n id: string\n kind: PlanRouteKind\n /** Address pattern (a `site.permalinks` value or a custom one). */\n pattern: PlanPermalink\n /** Fact template this route renders like. */\n template: string\n /**\n * Entries the route builds one page each for (`getStaticPaths`); none for a single page. Routes over the\n * same model must not share an entry: disjoint `where: { wp_id: [...] }` sets plus at most one catch-all.\n */\n source?: { model: string, where?: Record<string, unknown>, paginate?: number }\n /**\n * The page model whose object fields hold this page's sections: a singleton (`page-about`), or, for pages\n * that share one layout, a collection that is also the route's `source` (one entry per page). The page's\n * text lives there: the writer does not render the source entry's `body`, and extraction leaves it empty.\n */\n page?: string\n /**\n * `rich-text`: the entry body renders as prose (posts, simple pages).\n * `composed`: the page is the `sections` below, top to bottom.\n */\n body: 'rich-text' | 'composed'\n /** The page body, in document order (chrome is `layout`'s). */\n sections: PlanPlacement[]\n}\n\nexport interface PlanPlacement {\n /** `PlanComponent.id`. */\n component: string\n /**\n * The section's name on its page (`hero`, `services`, `selected-work`): the page singleton's field for a\n * `section` binding. Unique within a route.\n */\n id?: string\n /**\n * What classified the section: a mapping table's section rule (`<builder>:section:<rule id>`, whoever\n * picked it: rule, cache or JEW), `opus` for a site component written for it, `prose` for the fallback.\n */\n rule?: string\n variant?: Record<string, string>\n bind: PlanBinding\n /** Interface strings from `ui-strings`: prop → key (`prevLabel: 'pagination.prev'`). */\n labels?: Record<string, string>\n /**\n * How wide the section's content runs, as the source block's alignment: `content` for a block without\n * one (the theme's contentSize, `container-prose`), `wide` for `alignwide`/`alignfull` (`container-page`).\n * The section's background spans the page either way. Absent: `wide`.\n */\n width?: 'content' | 'wide'\n}\n\n/**\n * A prop value, never content itself. A small closed set, the same in the mapping table's `from`:\n *\n * - `field:<name>` — a field of the bound entry\n * - `media:<field>` — an image field resolved to the kit's `ImageInput` (`src`, `alt`, `width`, `height`)\n * - `ref:<field>.<target field>` — a field of the entry a relation points at (`ref:category.name`)\n * - `href:self` — the bound entry's own address; `href:<relation field>` — the related entry's, via `site.permalinks`\n * - `term:<relation field>` — a related term as `{ label, href }` (the first of a multi-relation; all of them with `into`)\n * - `ui:<key>` — an interface string of `ui-strings`\n * - `site:<field>` — a field of the `site` singleton (`site:title`, `site:logo`)\n * - `menu:primary|footer` — the items of the menu `site.menus` names for that area (the first footer menu)\n * - `page:base|current|total|breadcrumb` — what the route knows about the page being built\n * - `const:<value>` — a layout switch, never content: `true`, `false`, a number, or a lowercase identifier\n * (`const:contact`); anything that reads like text is refused, so page copy cannot be baked into code\n *\n * The builder mapping tables use the same set plus element expressions; one placement can mix them\n * (a header's `siteName` from `site:title`, its `items` from `menu:primary`).\n */\nexport type PlanValue = `field:${string}` | `media:${string}` | `ref:${string}` | `href:${string}` | `term:${string}` | `ui:${string}` | `site:${string}` | `menu:${string}` | `page:${string}` | `const:${string}`\n\nexport const PLAN_VALUE_PATTERN = /^(?:field:[\\w-]+|media:[\\w-]+|ref:[\\w-]+\\.[\\w-]+|href:(?:self|[\\w-]+)|term:[\\w-]+|ui:[\\w.-]+|site:[\\w-]+|menu:(?:primary|footer)|page:(?:base|current|total|breadcrumb)|const:(?:true|false|-?\\d+(?:\\.\\d+)?|[a-z][a-z0-9_-]{0,31}))$/\n\n/** Where a placement's props come from. */\nexport type PlanBinding =\n /** The route's own entry. */\n | { kind: 'entry', props: Record<string, PlanValue> }\n /** One entry of a model (a singleton, or a fixed entry id). */\n | { kind: 'model', model: string, entry?: string, props: Record<string, PlanValue> }\n /**\n * A list: a query over a collection. With `into`, the list fills that one array prop (`card-grid.items`,\n * `faq.items`) and `item` maps each element; without it, the component repeats once per entry (`post-card`)\n * and `item` maps its props.\n */\n | { kind: 'collection', model: string, where?: Record<string, unknown>, sort?: string, limit?: number, into?: string, item: Record<string, PlanValue>, props?: Record<string, PlanValue> }\n /** A menu by slug; its items fill `into` (default `items`). */\n | { kind: 'menu', menu: string, into?: string, props?: Record<string, PlanValue> }\n /** Only fixed values and labels. */\n | { kind: 'static', props: Record<string, PlanValue> }\n /**\n * A page section: the object field `field` of a singleton (or of entry `entry`, or of the route's own entry\n * when `model` is the route's template collection) feeds the component. Its keys are the component's\n * content props in snake_case (`cta_label` → `ctaLabel`, DECISIONS §7b); the view passes each one\n * explicitly. `props` adds fixed values (a variant-like setting, a label) after the field's.\n */\n | { kind: 'section', model: string, entry?: string, field: string, props?: Record<string, PlanValue> }\n\n// ─── Behaviors ───\n\n/**\n * - `component` — a placed component carries it (`component`: a `PlanComponent` id). An element the\n * mapping already turned into a section (core/details → faq) points at that section's component; it\n * is not placed a second time.\n * - `starter` — the starter already provides it (`feature`: site search is Pagefind on `/search/`).\n * - `prose` — it sits in a rich-text body and the prose renderer handles it (an oEmbed in a post).\n * - `drop` — deliberately not reproduced; `reason` says why.\n * - `needs_review` — no counterpart yet (an exit-intent popup, a payment form); `reason` says what a person must decide.\n */\nexport type PlanBehaviorOutcome = 'component' | 'starter' | 'prose' | 'drop' | 'needs_review'\n\nexport const PLAN_STARTER_FEATURES = ['search'] as const\nexport type PlanStarterFeature = (typeof PLAN_STARTER_FEATURES)[number]\n\nexport interface PlanBehavior {\n /** The fact pack's behavior id (`form:3fa9c21e`, `embed:0c1d2e3f`, `nav:t2`). */\n fact: string\n outcome: PlanBehaviorOutcome\n /** `PlanComponent` id, for `component`. */\n component?: string\n /** Starter feature, for `starter`. */\n feature?: PlanStarterFeature\n /** The model a form's submissions are saved to (a plan model with `form`). */\n model?: string\n /** `<code>: <sentence>`, required for `drop` and `needs_review` (`form-feature: Studio forms has no payment step`). */\n reason?: string\n}\n\nexport const PLAN_BEHAVIOR_REASON = /^[a-z][a-z0-9-]*: \\S.*$/\n\n// ─── Decisions ───\n\nexport interface PlanDecisionRecord {\n /** The fact decision id (`field_type:repeat:1x2y:0.1.0:text`) or a plan-level question. */\n id: string\n answer: string\n by: 'rule' | 'cache' | 'jew' | 'haiku' | 'sonnet' | 'opus' | 'fallback' | 'human'\n /** The exact model id that decided (`claude-sonnet-5`), when `by` is a model. */\n model?: string\n confidence?: number\n}\n\n// ─── Validation ───\n\nexport interface ProjectPlanReport { errors: string[], warnings: string[] }\n\nconst PERMALINK = /^\\/(?:[^/\\s]+\\/)*$/\nconst IMPORTED_MODELS = new Set(['posts', 'pages', 'categories', 'tags', 'authors', 'media', 'menus', 'menu-items', 'site', 'ui-strings'])\n\n/**\n * Structural checks a plan must pass before anything is written: every reference resolves (routes →\n * components → models), permalinks are well-formed, kit components carry a kit id, plan models carry\n * their fields. Kit catalog and fact checks (does the kit id exist, does the template exist) belong to\n * the caller, which holds those documents.\n */\n/** The footer's menus as a list: an older plan's single slug is one menu, its `'none'` is none. */\nexport function footerMenusOf(site: Pick<PlanSite, 'menus'>): string[] {\n const footer = site.menus.footer\n if (typeof footer === 'string') return footer === 'none' || footer === '' ? [] : [footer]\n return [...footer]\n}\n\n/** A letter-spacing the starter writes into a style: `normal`, or a signed length. */\nconst LETTER_SPACING = /^(?:normal|-?\\d+(?:\\.\\d+)?(?:px|rem|em))$/\n\n/** A font size the starter writes into a style: a length, or a `clamp()`/`calc()`/`min()`/`max()` of lengths (a theme's fluid size). */\nconst CSS_LENGTH = /^(?!.*url\\()(?:\\d+(?:\\.\\d+)?(?:px|rem|em|%)|(?:clamp|calc|min|max)\\([\\d\\s.,+*/()a-z%-]+\\))$/\n\n/**\n * How many object levels a field nests: 0 for a scalar or a list of scalars, 1 for an object of scalars,\n * 2 for an object holding a list of objects (`hero.actions[]`). A list adds no level of its own.\n */\nexport function fieldDepth(field: FieldDef): number {\n const children = [...Object.values(field.fields ?? {}), ...(typeof field.items === 'object' ? [field.items] : [])]\n return (field.type === 'object' ? 1 : 0) + Math.max(0, ...children.map(fieldDepth))\n}\n\n/**\n * Placement `rule`: a mapping table's section rule (`elementor:section:hero.split`) or element rule\n * (`gutenberg:element:core/details`, `gutenberg:element:core/template-part:header`), a site component\n * (`opus`), or the rich-text fallback.\n */\nexport const PLAN_SECTION_RULE = /^(?:(?:gutenberg|elementor|divi|classic):(?:section:[a-z][\\w.-]*|element:[a-z][\\w./:-]*)|opus|prose)$/\n\n/** A pixel measure: a finite number from 0 to `max`. */\nconst pxWithin = (v: unknown, max: number): boolean => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= max\n\n/** A content prop's field name in a section object: the prop in snake_case (`ctaLabel` → `cta_label`). */\nexport function sectionFieldName(prop: string): string {\n return prop.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`)\n}\n\nexport function validateProjectPlan(plan: ProjectPlan): ProjectPlanReport {\n const errors: string[] = []\n const warnings: string[] = []\n if (plan.format !== PROJECT_PLAN_FORMAT) errors.push(`format is not ${PROJECT_PLAN_FORMAT}`)\n if (!/^https?:\\/\\/[^/]+$/.test(plan.source?.origin ?? '')) errors.push('source.origin is not an http(s) origin without a path')\n const site = plan.site\n for (const [key, value] of Object.entries(site?.permalinks ?? {})) {\n if (!PERMALINK.test(value)) errors.push(`site.permalinks.${key} must start and end with \"/\" (${value})`)\n }\n if (!Number.isSafeInteger(site?.postsPerPage) || site.postsPerPage < 1) errors.push('site.postsPerPage is not a positive integer')\n if (site?.post) {\n const parts = site.post.header ?? []\n if (parts.some(part => !['terms', 'title', 'byline', 'cover'].includes(part)) || new Set(parts).size !== parts.length) errors.push('site.post.header lists a part twice or one the starter does not have')\n if (!Number.isSafeInteger(site.post.more) || site.post.more < 0 || site.post.more > 20) errors.push('site.post.more is not a count from 0 to 20')\n if (site.post.moreIncludesCurrent !== undefined && typeof site.post.moreIncludesCurrent !== 'boolean') errors.push('site.post.moreIncludesCurrent is not true or false')\n if (site.post.adjacentText !== undefined && !CSS_LENGTH.test(site.post.adjacentText)) errors.push(`site.post.adjacentText ${site.post.adjacentText} is not a CSS size`)\n if (site.post.moreText !== undefined && !CSS_LENGTH.test(site.post.moreText)) errors.push(`site.post.moreText ${site.post.moreText} is not a CSS size`)\n }\n for (const [key, value] of Object.entries(site?.uiStrings ?? {})) {\n if (!/^[a-z][a-z0-9]*(?:\\.[a-z][a-z0-9_]*)+$/.test(key)) errors.push(`site.uiStrings key ${key} is not a ui-strings key`)\n else if (typeof value !== 'string' || !value.trim() || value.length > 200 || /[<>]/.test(value)) errors.push(`site.uiStrings.${key} is not a plain text of 1 to 200 characters`)\n }\n if (Object.keys(site?.uiStrings ?? {}).length > 100) errors.push('site.uiStrings holds more than 100 strings')\n if (site?.lists && !['cards', 'list', 'full'].includes(site.lists.display)) errors.push(`site.lists.display ${site.lists.display} is not cards, list or full`)\n if (site?.lists?.tone !== undefined && site.lists.tone !== 'muted') errors.push(`site.lists.tone ${String(site.lists.tone)} is not muted`)\n if (site?.lists?.text !== undefined && !CSS_LENGTH.test(site.lists.text)) errors.push(`site.lists.text ${site.lists.text} is not a CSS size`)\n for (const [route, layout] of Object.entries(site?.entryLayouts ?? {})) {\n if (typeof layout?.adjacent !== 'boolean') errors.push(`site.entryLayouts.${route}.adjacent is not true or false`)\n if (layout?.more !== undefined && (!Number.isSafeInteger(layout.more) || layout.more < 0 || layout.more > 20)) errors.push(`site.entryLayouts.${route}.more is not a count from 0 to 20`)\n if (layout?.moreIncludesCurrent !== undefined && typeof layout.moreIncludesCurrent !== 'boolean') errors.push(`site.entryLayouts.${route}.moreIncludesCurrent is not true or false`)\n if (layout?.moreOf !== undefined && layout.moreOf !== 'posts') errors.push(`site.entryLayouts.${route}.moreOf ${String(layout.moreOf)} is not posts`)\n if (layout?.moreText !== undefined && !CSS_LENGTH.test(layout.moreText)) errors.push(`site.entryLayouts.${route}.moreText ${layout.moreText} is not a CSS size`)\n }\n const printed = site?.footer\n if (printed?.feed !== undefined && typeof printed.feed !== 'boolean') errors.push('site.footer.feed is not a boolean')\n if (printed?.border !== undefined && typeof printed.border !== 'boolean') errors.push('site.footer.border is not a boolean')\n if (printed?.copyright !== undefined && printed.copyright !== false && (typeof printed.copyright !== 'string' || !printed.copyright.trim() || printed.copyright.length > 200 || /[<>]/.test(printed.copyright))) errors.push('site.footer.copyright is not false or a line of plain text (1–200 characters, no markup)')\n const chrome = site?.chrome\n if (chrome?.brand !== undefined && !CSS_LENGTH.test(chrome.brand)) errors.push(`site.chrome.brand ${chrome.brand} is not a CSS size`)\n if (chrome?.tagline !== undefined && typeof chrome.tagline !== 'boolean') errors.push('site.chrome.tagline is not a boolean')\n if (chrome?.titleLinks !== undefined && typeof chrome.titleLinks !== 'boolean') errors.push('site.chrome.titleLinks is not a boolean')\n if (chrome?.navLinks !== undefined && typeof chrome.navLinks !== 'boolean') errors.push('site.chrome.navLinks is not a boolean')\n const header = chrome?.header\n if (header !== undefined) {\n if (header === null || typeof header !== 'object' || Array.isArray(header)) errors.push('site.chrome.header is not an object')\n else {\n if (header.border !== undefined && header.border !== false) errors.push('site.chrome.header.border is not false')\n if (header.brandWeight !== undefined && !/^[1-9]00$/.test(header.brandWeight)) errors.push(`site.chrome.header.brandWeight ${header.brandWeight} is not a font weight`)\n if (header.brandTracking !== undefined && !LETTER_SPACING.test(header.brandTracking)) errors.push(`site.chrome.header.brandTracking ${header.brandTracking} is not a letter spacing`)\n for (const key of ['offsetTop', 'padTop', 'padBottom'] as const) {\n if (header[key] !== undefined && !CSS_LENGTH.test(header[key]!)) errors.push(`site.chrome.header.${key} ${header[key]} is not a CSS size`)\n }\n }\n }\n if (chrome?.footerAlign !== undefined && !['start', 'center'].includes(chrome.footerAlign)) errors.push(`site.chrome.footerAlign ${chrome.footerAlign} is not start or center`)\n for (const key of ['frame', 'listFrame', 'postFrame'] as const) {\n const steps = chrome?.[key]\n if (steps === undefined) continue\n if (!Array.isArray(steps) || steps.length === 0 || steps.length > 8) errors.push(`site.chrome.${key} is not a list of 1–8 steps`)\n else if (steps.some((step, i) => !Number.isSafeInteger(step?.min) || !Number.isSafeInteger(step?.width) || step.min < 1 || step.width < 1 || step.min > 10_000 || step.width > 10_000 || (i > 0 && step.min <= steps[i - 1]!.min))) errors.push(`site.chrome.${key} steps are not whole pixel widths in ascending order of min`)\n }\n if (chrome?.listColumns !== undefined) {\n const steps = chrome.listColumns\n if (!Array.isArray(steps) || steps.length === 0 || steps.length > 8) errors.push('site.chrome.listColumns is not a list of 1–8 steps')\n else if (steps.some((step, i) => !Number.isSafeInteger(step?.min) || !Number.isSafeInteger(step?.columns) || step.min < 1 || step.min > 10_000 || step.columns < 1 || step.columns > 6 || (i > 0 && step.min <= steps[i - 1]!.min))) errors.push('site.chrome.listColumns steps are not a whole pixel min in ascending order and 1–6 columns')\n }\n if (chrome?.footerColumns !== undefined) {\n const { gap, size, narrowGap } = chrome.footerColumns\n if (!pxWithin(gap, 400) || (size !== undefined && !(pxWithin(size, 100) && size > 0)) || (narrowGap !== undefined && !pxWithin(narrowGap, 400))) errors.push('site.chrome.footerColumns is not a gap (0–400 px), a link size (1–100 px) and a narrow gap (0–400 px)')\n }\n if (chrome?.copyright !== undefined && (typeof chrome.copyright !== 'string' || !chrome.copyright.trim() || chrome.copyright.length > 200 || /[<>]/.test(chrome.copyright))) errors.push('site.chrome.copyright is not a line of plain text (1–200 characters, no markup)')\n const footer = site?.menus?.footer\n if (typeof footer === 'string') {\n if (footer === '') errors.push('site.menus.footer has an empty menu slug')\n } else if (!Array.isArray(footer)) errors.push('site.menus.footer is not a menu slug or a list of them')\n else {\n if (footer.length > 4) errors.push(`site.menus.footer has ${footer.length} menus; the footer takes at most 4`)\n if (footer.some(slug => typeof slug !== 'string' || slug === '')) errors.push('site.menus.footer has an empty menu slug')\n if (new Set(footer).size !== footer.length) errors.push('site.menus.footer names a menu twice')\n }\n for (const [from, to] of Object.entries(site?.redirects ?? {})) {\n if (!from.startsWith('/')) errors.push(`redirect ${from} does not start with \"/\"`)\n const status = typeof to === 'string' ? 301 : to.status\n if (![301, 302, 307, 308, 410].includes(status)) errors.push(`redirect ${from} has status ${status}`)\n }\n for (const role of Object.keys(site?.tokens?.roles ?? {})) {\n if (!(PLAN_TOKEN_ROLES as readonly string[]).includes(role)) errors.push(`tokens.roles.${role} is not a kit role`)\n }\n for (const role of ['spacing-title', 'container-inset'] as const) {\n const value = site?.tokens?.roles?.[role]\n if (value !== undefined && !CSS_LENGTH.test(value)) errors.push(`tokens.roles.${role} ${value} is not a CSS size`)\n }\n\n const models = new Map<string, PlanModel>()\n for (const m of plan.models ?? []) {\n if (models.has(m.id)) errors.push(`model ${m.id} is declared twice`)\n models.set(m.id, m)\n if (m.origin === 'import' && !IMPORTED_MODELS.has(m.id)) warnings.push(`model ${m.id} is marked imported but wp-import does not write it`)\n if (m.origin === 'plan') {\n if (!m.fields) errors.push(`plan model ${m.id} has no fields`)\n if (!m.name || !m.domain) errors.push(`plan model ${m.id} needs name and domain`)\n // A singleton is titled too when it says so: a page built from sections names a section's heading.\n if (m.kind === 'collection' || m.kind === 'document' || (m.kind === 'singleton' && m.title_field)) {\n const title = m.title_field ? titleFieldTarget(m.fields, m.title_field) : undefined\n if (!m.title_field || !title) errors.push(`plan model ${m.id} has no title_field`)\n else if (title.kind === 'too-deep') errors.push(`plan model ${m.id}: title_field ${m.title_field} reaches more than one object deep`)\n else if (title.kind === 'not-object') errors.push(`plan model ${m.id}: title_field ${m.title_field} goes through ${title.at} (${title.type}), not an object field`)\n else if (title.kind === 'missing') errors.push(`plan model ${m.id}: title_field ${m.title_field} is not one of its fields`)\n else if (!isTitleFieldType(title.def.type)) errors.push(`plan model ${m.id}: title_field ${m.title_field} is ${title.def.type}, not a text-like type`)\n }\n if (!m.extract?.length && !m.form) warnings.push(`plan model ${m.id} has no extraction — its entries start empty`)\n }\n if (m.form) {\n if (m.origin !== 'plan' || m.kind !== 'collection') errors.push(`model ${m.id}: a form model must be a plan collection`)\n // Unset means localized for a Contentrain model; submissions have no locale to be translated into.\n if (m.i18n !== false) errors.push(`model ${m.id}: a form model must be i18n: false (Studio writes submissions in the default locale)`)\n if (!m.form.exposedFields.length) errors.push(`model ${m.id}: form exposes no fields`)\n for (const field of [...m.form.exposedFields, ...Object.keys(m.form.requiredOverrides ?? {})]) {\n if (m.fields && !m.fields[field]) errors.push(`model ${m.id}: form field ${field} is not a model field`)\n }\n if (m.form.captcha !== undefined && m.form.captcha !== null && m.form.captcha !== 'turnstile') errors.push(`model ${m.id}: form captcha ${String(m.form.captcha)} is not turnstile`)\n }\n for (const x of m.extract ?? []) {\n if (!x.rule && !x.fields) errors.push(`model ${m.id} extraction from ${x.from} has neither rule nor fields`)\n if (x.field !== undefined && m.fields && m.fields[x.field]?.type !== 'object') errors.push(`model ${m.id} extraction from ${x.from} fills ${x.field}, which is not an object field`)\n if (x.rule && !/^(gutenberg|elementor|divi|classic):[\\w./:-]+$/.test(x.rule)) errors.push(`model ${m.id} extraction rule ${x.rule} is not <builder>:<match>`)\n const target = x.field !== undefined ? m.fields?.[x.field]?.fields : m.fields\n for (const [field, path] of Object.entries(x.fields ?? {})) {\n if (target && !target[field]) errors.push(`model ${m.id} extraction fills unknown field ${field}`)\n if (!/^attr:[\\w.-]+$|^[\\d.]+\\|(text|href|src|alt|datetime)$|^(?:dom|img|link|html):[^@]*(?:@[\\w-]+)?$/.test(path)) errors.push(`model ${m.id} field ${field}: path ${path} is not \"<path>|<prop>\", \"attr:<name>\" or an element expression`)\n }\n }\n }\n const knownModel = (id: string) => models.has(id) || IMPORTED_MODELS.has(id)\n\n const components = new Map<string, PlanComponent>()\n for (const c of plan.components ?? []) {\n if (components.has(c.id)) errors.push(`component ${c.id} is declared twice`)\n components.set(c.id, c)\n if (!/^[A-Z][A-Za-z0-9]*$/.test(c.id)) errors.push(`component ${c.id} is not a PascalCase name`)\n if (c.origin === 'kit' && !c.kit?.id) errors.push(`kit component ${c.id} names no kit id`)\n if (c.origin === 'site' && !c.props) errors.push(`site component ${c.id} declares no props`)\n if (c.origin === 'site' && !c.brief) warnings.push(`site component ${c.id} has no brief for the writer`)\n }\n\n const placement = (s: PlanPlacement, at: string, route?: PlanRoute) => {\n if (s.width !== undefined && s.width !== 'content' && s.width !== 'wide') errors.push(`${at}: width ${s.width as string} is not content or wide`)\n const c = components.get(s.component)\n if (!c) { errors.push(`${at}: component ${s.component} is not declared`); return }\n const b = s.bind\n if ('model' in b && !knownModel(b.model)) errors.push(`${at}: model ${b.model} is not declared`)\n if (b.kind === 'entry' && !route?.source) errors.push(`${at}: binds the route entry but ${route ? 'the route has no source' : 'layout has no entry'}`)\n if (s.id !== undefined && !/^[a-z][a-z0-9-]*$/.test(s.id)) errors.push(`${at}: id ${s.id} is not kebab-case`)\n if (s.rule !== undefined && !PLAN_SECTION_RULE.test(s.rule)) errors.push(`${at}: rule ${s.rule} is not <builder>:section:<id>, <builder>:element:<match>, opus or prose`)\n if (b.kind === 'section') {\n const m = models.get(b.model)\n const field = m?.fields?.[b.field]\n // A template route's sections read the route's own entry: its page collection is the route source.\n const ownEntry = route?.page === b.model && route.source?.model === b.model\n if (m && m.origin === 'plan' && m.kind !== 'singleton' && b.entry === undefined && !ownEntry) errors.push(`${at}: section of ${b.model} names no entry and ${b.model} is neither a singleton nor this route's page`)\n if (m?.fields && !field) errors.push(`${at}: ${b.model} has no field ${b.field}`)\n else if (field && field.type !== 'object') errors.push(`${at}: ${b.model}.${b.field} is ${field.type}, not an object`)\n else if (field && fieldDepth(field) > 2) errors.push(`${at}: ${b.model}.${b.field} nests deeper than 2 (an object inside a list item's object)`)\n const fieldOf = new Map(Object.keys(c.props ?? {}).map(prop => [sectionFieldName(prop), prop]))\n if (c.props && field?.fields) for (const key of Object.keys(field.fields)) if (!fieldOf.has(key)) errors.push(`${at}: ${c.id} has no prop for field ${b.model}.${b.field}.${key} (fields are the props in snake_case)`)\n }\n const values: Record<string, PlanValue> = { ...('props' in b ? b.props : {}), ...(b.kind === 'collection' && !b.into ? b.item : {}) }\n for (const [prop, value] of Object.entries({ ...values, ...(b.kind === 'collection' ? b.item : {}) })) {\n if (!PLAN_VALUE_PATTERN.test(value)) errors.push(`${at}: ${prop} = ${value} is not a plan value (field: media: ref: href: term: ui: site: menu: page: const:)`)\n }\n // A kit component's props are the catalog's; the caller checks them. A site component's are here.\n if (c.props) {\n const byField = new Map(Object.keys(c.props).map(prop => [sectionFieldName(prop), prop]))\n const spread = b.kind === 'section' ? Object.keys(models.get(b.model)?.fields?.[b.field]?.fields ?? {}).flatMap(key => byField.get(key) ?? []) : []\n const bound = new Set([...Object.keys(values), ...spread, ...Object.keys(s.labels ?? {}), ...('into' in b && b.into ? [b.into] : b.kind === 'menu' ? ['items'] : [])])\n for (const prop of bound) if (!c.props[prop]) errors.push(`${at}: ${c.id} has no prop ${prop}`)\n for (const [prop, def] of Object.entries(c.props)) if (def.required && !bound.has(prop)) errors.push(`${at}: required prop ${c.id}.${prop} is not bound`)\n }\n }\n if (plan.layout?.header) placement(plan.layout.header, 'layout header')\n if (plan.layout?.footer) placement(plan.layout.footer, 'layout footer')\n\n const routeIds = new Set<string>()\n const byModel = new Map<string, PlanRoute[]>()\n for (const r of plan.routes ?? []) {\n if (routeIds.has(r.id)) errors.push(`route ${r.id} is declared twice`)\n routeIds.add(r.id)\n if (!PERMALINK.test(r.pattern)) errors.push(`route ${r.id}: pattern ${r.pattern} must start and end with \"/\"`)\n if (r.source) {\n if (!knownModel(r.source.model)) errors.push(`route ${r.id}: source model ${r.source.model} is not declared`)\n ;(byModel.get(r.source.model) ?? byModel.set(r.source.model, []).get(r.source.model)!).push(r)\n }\n if (r.body === 'composed' && !r.sections.length) warnings.push(`route ${r.id} is composed but has no sections`)\n if (r.page !== undefined) {\n const page = models.get(r.page)\n if (!page) errors.push(`route ${r.id}: page ${r.page} is not declared`)\n // A collection of pages sharing one layout is a template route: each entry is one page.\n else if (page.kind === 'collection' ? r.source?.model !== r.page : page.kind !== 'singleton') errors.push(`route ${r.id}: page ${r.page} is a ${page.kind}; a page is a singleton, or a collection that is the route's source`)\n if (r.body !== 'composed') errors.push(`route ${r.id}: a page model needs body composed`)\n }\n const sectionIds = new Set<string>()\n for (const s of r.sections) {\n if (s.id === undefined) continue\n if (sectionIds.has(s.id)) errors.push(`route ${r.id}: section id ${s.id} is used twice`)\n sectionIds.add(s.id)\n }\n r.sections.forEach((s, i) => placement(s, `route ${r.id} section ${i}`, r))\n }\n // Two routes on one pattern build one address twice, unless they split one model's entries by `where`.\n const byPattern = new Map<string, PlanRoute[]>()\n for (const r of plan.routes ?? []) (byPattern.get(r.pattern) ?? byPattern.set(r.pattern, []).get(r.pattern)!).push(r)\n for (const [pattern, routes] of byPattern) {\n if (routes.length < 2) continue\n const sources = new Set(routes.map(r => r.source?.model ?? ''))\n const split = sources.size === 1 && !sources.has('') && routes.filter(r => !r.source?.where).length <= 1\n if (!split) warnings.push(`routes ${routes.map(r => r.id).join(', ')} share the pattern ${pattern}`)\n }\n // One address per entry: routes over one model split it by disjoint wp_id sets plus at most one catch-all.\n for (const [model, routes] of byModel) {\n if (routes.length < 2) continue\n const catchAll = routes.filter(r => !r.source?.where || Object.keys(r.source.where).length === 0)\n if (catchAll.length > 1) errors.push(`model ${model}: routes ${catchAll.map(r => r.id).join(', ')} are all catch-alls`)\n const seen = new Map<unknown, string>()\n for (const r of routes) {\n const ids = r.source?.where?.wp_id\n if (r.source?.where && ids === undefined) warnings.push(`route ${r.id}: where without wp_id — overlap with other ${model} routes cannot be checked`)\n for (const id of Array.isArray(ids) ? ids : ids === undefined ? [] : [ids]) {\n const other = seen.get(id)\n if (other) errors.push(`model ${model}: entry ${String(id)} is in routes ${other} and ${r.id}`)\n else seen.set(id, r.id)\n }\n }\n }\n if (!plan.routes?.some(r => r.kind === 'home')) errors.push('no home route')\n\n const behaviorIds = new Set<string>()\n for (const b of plan.behaviors ?? []) {\n const at = `behavior ${b.fact}`\n if (behaviorIds.has(b.fact)) errors.push(`${at} is planned twice`)\n behaviorIds.add(b.fact)\n if (b.outcome === 'component') {\n if (!b.component) errors.push(`${at}: outcome component names no component`)\n else if (!components.has(b.component)) errors.push(`${at}: component ${b.component} is not declared`)\n }\n if (b.outcome === 'starter' && !(PLAN_STARTER_FEATURES as readonly string[]).includes(b.feature ?? '')) errors.push(`${at}: starter feature ${String(b.feature)} is not one of ${PLAN_STARTER_FEATURES.join(', ')}`)\n if ((b.outcome === 'drop' || b.outcome === 'needs_review') && !PLAN_BEHAVIOR_REASON.test(b.reason ?? '')) errors.push(`${at}: ${b.outcome} needs a reason \"<code>: <sentence>\"`)\n if (b.model) {\n const m = models.get(b.model)\n if (!m) errors.push(`${at}: model ${b.model} is not declared`)\n else if (!m.form) errors.push(`${at}: model ${b.model} is not a form model`)\n }\n }\n return { errors, warnings }\n}\n","import type { SyncResult } from './index.js'\n\n// ─── Repository Provider Contracts ───\n//\n// Shared interfaces for the provider-agnostic content repository model used\n// by @contentrain/mcp. They live in @contentrain/types so third-party tools\n// can implement a custom RepoProvider (e.g. for a private git host, an\n// internal service, a mock in a test suite) without depending on MCP\n// internals.\n//\n// @contentrain/mcp re-exports every symbol here from\n// @contentrain/mcp/core/contracts so existing consumers do not have to\n// migrate imports.\n\n// ─── File change ───\n\n/**\n * A single file change within a plan.\n *\n * - `content: string` — write or overwrite the file with this UTF-8 content.\n * - `content: null` — delete the file.\n *\n * Paths are content-root relative, use forward slashes, and must not contain\n * `..` segments or absolute anchors. Providers are responsible for resolving\n * paths against their backing store (worktree, git tree, etc.).\n */\nexport interface FileChange {\n path: string\n content: string | null\n}\n\n// ─── Capabilities ───\n\n/**\n * Capabilities describe what a provider can and cannot do. Operations check\n * required capabilities before running. Normalize extract needs `sourceRead`;\n * normalize reuse needs `sourceWrite`; submit needs `pushRemote`; AST scans\n * need `astScan` (which implies a local working tree).\n *\n * The consistent position is: all git hosts are commodity MIT providers; the\n * distinction between providers is operational (how they read/write), not\n * commercial. Enterprise features live in Studio, not in capability gates.\n */\nexport interface ProviderCapabilities {\n /** Provider backs onto a local worktree and can selectively sync changes into the developer's working tree. */\n localWorktree: boolean\n /** Provider can read arbitrary source files outside `.contentrain/`. Required for normalize extract. */\n sourceRead: boolean\n /** Provider can write arbitrary source files outside `.contentrain/`. Required for normalize reuse. */\n sourceWrite: boolean\n /** Provider can push commits to a remote. Required for submit. */\n pushRemote: boolean\n /** Provider detects branch protection rules on the remote. */\n branchProtection: boolean\n /** Provider can open a pull request as a merge fallback when branch protection blocks direct merge. */\n pullRequestFallback: boolean\n /** Provider can execute AST scanners against source files. Implies local disk access. */\n astScan: boolean\n /**\n * Provider can write a two-parent merge commit (see\n * `RepoProvider.createMergeCommit`). Absent = false. Optional so that\n * existing external `implements` code keeps compiling: a required member\n * here would be a breaking change for every custom provider.\n */\n mergeCommit?: boolean\n}\n\n/** Capability set for the LocalProvider (simple-git + worktree). */\nexport const LOCAL_CAPABILITIES: ProviderCapabilities = {\n localWorktree: true,\n sourceRead: true,\n sourceWrite: true,\n pushRemote: true,\n branchProtection: false,\n pullRequestFallback: false,\n astScan: true,\n mergeCommit: true,\n}\n\n// ─── Reader ───\n\n/**\n * Read-only interface to a content repository.\n *\n * Paths are relative to the repository's content root (e.g.\n * `.contentrain/config.json`). The `ref` parameter is a branch name, tag,\n * or commit SHA. Providers that operate on a single working tree\n * (LocalReader) ignore `ref`; API-backed providers use it to resolve the\n * correct revision.\n *\n * `readFile` and `listDirectory` deliberately have different error semantics:\n * - `readFile` THROWS when the file is missing so callers must opt into\n * tolerance explicitly (typically with a try/catch returning a default).\n * - `listDirectory` returns `[]` for a missing directory because the empty\n * case is the common, uninteresting one.\n */\n/**\n * Where a provider read `.contentrain/` from (see `RepoProvider.contentSource`).\n * `ref`: the content branch at `commit`, because `checked_out` is a branch\n * content writes do not update.\n */\nexport type ContentReadSource =\n | { source: 'working_tree' }\n | { source: 'ref', ref: string, commit: string, checked_out: string }\n\nexport interface RepoReader {\n /**\n * Read a file's contents as UTF-8.\n * @throws when the file does not exist or cannot be read.\n */\n readFile(path: string, ref?: string): Promise<string>\n\n /**\n * List file and directory names directly under `path`. Does not recurse.\n * Returns an empty array when the directory does not exist.\n */\n listDirectory(path: string, ref?: string): Promise<string[]>\n\n /** Check whether a file or directory exists at `path`. */\n fileExists(path: string, ref?: string): Promise<boolean>\n}\n\n// ─── Writer ───\n\nexport interface CommitAuthor {\n name: string\n email: string\n}\n\nexport interface Commit {\n sha: string\n message: string\n author: CommitAuthor\n timestamp: string\n /**\n * What the provider did with the branch. Providers that own the merge\n * decision (a local worktree honouring the project's `workflow` setting)\n * report it here; providers that hand the branch off to an external\n * orchestrator leave it undefined, and the caller treats that as\n * `pending-review`.\n */\n workflowAction?: 'auto-merged' | 'pending-review'\n /**\n * Selective-sync bookkeeping — which files were copied into the developer's\n * working tree and which were skipped because of local edits. Only\n * providers backed by a local worktree populate it.\n */\n sync?: SyncResult\n /** Non-fatal problem encountered while completing the write. */\n warning?: string\n /**\n * Whether the base branch advanced to the contentrain tip after an\n * auto-merge. Only providers that own the base advance (a local worktree)\n * report it. `blocked_diverged` is a partial success: the content is on\n * the contentrain branch; only the fast-forward is pending.\n */\n base_advance?: BaseAdvance\n /** Outcome of pushing the contentrain branch, when the provider pushes. */\n remote_push?: RemotePush\n}\n\n/**\n * Input to `applyPlan`. Represents a single atomic commit: all changes land\n * in one commit on `branch`, created from `base` if it does not yet exist.\n */\nexport interface ApplyPlanInput {\n /** Branch name to commit to. Created from `base` if missing. */\n branch: string\n /** File additions, modifications and deletions to apply in a single commit. */\n changes: FileChange[]\n /** Commit message. */\n message: string\n /** Commit author. */\n author: CommitAuthor\n /**\n * Optional base branch. Defaults to the Contentrain content-tracking\n * branch (`CONTENTRAIN_BRANCH` — the `contentrain` ref) — NOT the\n * repository's default branch. This is the single source of truth for\n * content state; every feature branch forks from it. Pass an explicit\n * `base` only when you know you want to bypass the invariant.\n *\n * The GitHub provider also takes a full 40-hex commit SHA here, as a\n * compare-and-set: a missing `branch` forks from that commit, and an\n * existing one must still point at it or the write is refused with a\n * 409 (`PROVIDER_CONFLICT`).\n */\n base?: string\n /**\n * Describes the operation that produced these changes. A provider that\n * regenerates `.contentrain/context.json` itself (the local worktree flow\n * does, after merge, single-threaded) uses it; others ignore it. It is\n * never committed on the feature branch — context.json embeds timestamps,\n * so two branches forked from the same commit would always conflict on it.\n */\n context?: { tool: string; model: string; locale?: string; entries?: string[] }\n /** Override the project's configured workflow for this write. */\n workflowOverride?: 'auto-merge' | 'review'\n}\n\n/**\n * Write-side interface. Providers implement this to persist a set of file\n * changes as a single atomic commit. LocalProvider writes through a worktree\n * and `git commit`; API-backed providers post to the Git Data API or\n * equivalent.\n */\nexport interface RepoWriter {\n applyPlan(input: ApplyPlanInput): Promise<Commit>\n}\n\n// ─── Branch / diff / merge ───\n\nexport interface Branch {\n name: string\n sha: string\n protected?: boolean\n}\n\nexport interface FileDiff {\n path: string\n status: 'added' | 'modified' | 'removed'\n before: string | null\n after: string | null\n}\n\nexport interface MergeResult {\n merged: boolean\n sha: string | null\n pullRequestUrl: string | null\n /**\n * Selective-sync bookkeeping — only populated by providers that back onto\n * a local worktree (LocalProvider). Remote-API providers (GitHub, GitLab,\n * etc.) omit it because they do not touch a developer's working tree.\n */\n sync?: SyncResult\n /**\n * Outcome of the post-merge source-branch cleanup on the remote.\n * `deleted: false` with `skipped` is an expected no-op (cleanup disabled,\n * no remote, ref already gone); `warning` is a real failure (offline,\n * auth) that did NOT fail the merge itself.\n */\n remote?: {\n deleted: boolean\n skipped?: string\n warning?: string\n }\n}\n\n// ─── Base-branch advance / remote push outcomes ───\n\n/**\n * What happened to the base branch after content landed on the\n * content-tracking branch.\n *\n * `advanced` — the base branch fast-forwarded to the contentrain tip.\n * `blocked_diverged` — the base branch holds commits that are not in\n * contentrain, so fast-forwarding was impossible. The content itself is\n * safe on the contentrain branch; only the advance is pending until the\n * branches are reconciled.\n *\n * These two values are shared vocabulary across the ecosystem — Studio\n * reports the same enum for its main-advance state. A pull request is an\n * attachment (`pullRequestUrl`), never a third state: \"PR opened\" is\n * `blocked_diverged` with a non-null URL.\n */\nexport type BaseAdvance = 'advanced' | 'blocked_diverged'\n\n/**\n * Outcome of pushing the content-tracking branch to the configured remote.\n *\n * `pushed` — the remote accepted the push (possibly after one\n * fetch-merge-retry). `rejected` — the push failed even after the retry;\n * local and remote have diverged and need reconciling. `no-remote` — the\n * repository has no configured remote, so nothing was attempted.\n */\nexport type RemotePush = 'pushed' | 'rejected' | 'no-remote'\n\n// ─── Reconcile / conflicts ───\n\n/**\n * Machine-readable conflict kinds produced by the content-aware three-way\n * reconcile planner. CLOSED SET: consumers (Studio) key their localized\n * editor questions on these values, so a free string would drift. Adding a\n * value here is a minor version bump and MUST carry a changelog entry;\n * renaming or removing one is breaking.\n */\nexport type ConflictCode =\n | 'field_value_conflict' // collection/singleton: same field changed differently on both sides\n | 'dictionary_value_conflict' // same dictionary key, two different values\n | 'vocabulary_value_conflict' // same term + locale, two different translations\n | 'model_key_conflict' // same model schema key changed differently\n | 'meta_status_conflict' // both sides moved an entry's publish status differently\n | 'document_body_conflict' // both sides edited a document body (never text-merged)\n | 'frontmatter_value_conflict' // same frontmatter key, two different values\n | 'delete_edit_conflict' // one side deleted what the other edited\n | 'file_conflict' // unrecognized file changed on both sides — resolved by choosing a side\n\n/**\n * One surviving conflict from a three-way reconcile: base (merge-base),\n * ours (content branch), theirs (base branch) disagree in a way the policy\n * table cannot resolve mechanically. Everything mechanical has already been\n * merged; only these items need a decision from an editor or agent.\n *\n * `id` is derived from the position AND the three values (see\n * `conflictId`), so a resolution made against a stale dry-run no longer\n * matches once any side changes — compare-and-set for free.\n */\nexport interface ConflictItem {\n id: string\n /** Content-root-relative path of the file the conflict lives in. */\n path: string\n model?: string\n kind: 'collection' | 'singleton' | 'document' | 'dictionary'\n | 'vocabulary' | 'model' | 'meta' | 'file'\n /** Entry ID, dictionary key, vocabulary term, or model schema key. */\n key?: string\n /** Field inside the entry / frontmatter key, when the conflict is field-level. */\n field?: string\n locale?: string\n /** The three values in question — for display; omitted for `kind: 'file'`. */\n base?: unknown\n ours?: unknown\n theirs?: unknown\n code: ConflictCode\n /** Ready-to-display English sentence naming the conflict. */\n message: string\n /**\n * Policy-table suggestion, never auto-applied. Present only where the\n * approved policy names a preferred side (model schema keys → `theirs`).\n */\n suggested?: 'ours' | 'theirs'\n}\n\n/**\n * A decision for one conflict, keyed by `ConflictItem.id`. `choose` picks a\n * side (absence of the item on that side means deletion); `value` supplies\n * a hand-authored replacement. A resolution whose `id` no longer matches a\n * live conflict is dropped and the conflict re-reported with a fresh id.\n */\nexport type ConflictResolution =\n | { id: string; choose: 'ours' | 'theirs' }\n | { id: string; value: unknown }\n\n// ─── Media (optional provider facet) ───\n\n/**\n * One media asset as the provider's media stack reports it. `path` is the\n * repo-relative storage path content fields reference (`media/...`); `url`\n * is the absolute delivery URL when the provider can resolve one (see\n * `RepoProvider.mediaBaseUrl`). `meta` carries provider-defined extras\n * (dimensions, blurhash, variants) without widening this contract.\n */\nexport interface MediaAsset {\n id: string\n path: string\n url?: string\n mime?: string\n size?: number\n alt?: string\n tags?: string[]\n createdAt?: string\n meta?: Record<string, unknown>\n}\n\nexport interface MediaListOptions {\n /** Substring match on filename/path/alt. */\n search?: string\n /** Filter to assets carrying this tag. */\n tag?: string\n /** Page size — implementations may clamp. */\n limit?: number\n /** Opaque cursor from a previous `MediaListResult.nextCursor`. */\n cursor?: string\n}\n\nexport interface MediaListResult {\n assets: MediaAsset[]\n nextCursor?: string\n total?: number\n}\n\n/**\n * URL-based ingest — MCP has no binary channel, so the source is always a\n * URL the provider fetches server-side. Implementations MUST enforce their\n * own SSRF, MIME, and size policies before fetching; MCP passes the input\n * through verbatim and never fetches the URL itself.\n */\nexport interface MediaIngestInput {\n url: string\n filename?: string\n alt?: string\n tags?: string[]\n}\n\nexport interface MediaUpdateInput {\n alt?: string\n tags?: string[]\n filename?: string\n}\n\n/**\n * Optional media facet of a provider. When present on `RepoProvider.media`,\n * the MCP server registers the `contentrain_media_*` tools; when absent\n * (LocalProvider, plain GitHub/GitLab providers), those tools are not\n * listed at all. Hosted providers (Studio MCP Cloud) implement this by\n * delegating to their media stack — deterministic passthrough, no content\n * decisions in MCP.\n */\nexport interface MediaProvider {\n list(opts?: MediaListOptions): Promise<MediaListResult>\n get(id: string): Promise<MediaAsset | null>\n ingest(input: MediaIngestInput): Promise<MediaAsset>\n update(id: string, patch: MediaUpdateInput): Promise<MediaAsset>\n delete(id: string): Promise<void>\n}\n\n// ─── Provider (full surface) ───\n\n/**\n * A content repository provider — the unified surface that MCP tools drive.\n *\n * Implementations wrap a git backend:\n *\n * - `LocalProvider` — simple-git + temp worktree + selective sync\n * - `GitHubProvider` — Octokit Git Data API (no clone)\n * - `GitLabProvider` — gitbeaker REST client (no clone)\n * - `BitbucketProvider` — planned, coming soon\n *\n * Providers are commodity and all live in MIT.\n */\n/** Result of {@link RepoProvider.checkWriteReadiness}. */\nexport interface WriteReadiness {\n blocked: boolean\n /** Human-readable reason, present when `blocked`. */\n message?: string\n}\n\nexport interface RepoProvider extends RepoReader, RepoWriter {\n readonly capabilities: ProviderCapabilities\n\n /**\n * Per-project public media delivery base, e.g.\n * `https://studio.example/api/cdn/v1/{projectId}` — already includes the\n * project segment, so the content-write path only joins `{base}/{path}`.\n *\n * Set by hosted providers (Studio's MCP Cloud loopback resolves it from a\n * request header and attaches it here). When present, content writes\n * normalize relative `media/...` references — in media/image/file fields and\n * markdown bodies — to absolute delivery URLs, so external-agent writes\n * render anywhere without an SDK. Undefined for local/CLI providers, where\n * media stays a relative path (the OSS file model). Never affects reads.\n */\n readonly mediaBaseUrl?: string\n\n /**\n * Optional media facet. Present only on providers whose backend exposes a\n * media stack (Studio MCP Cloud); drives the `contentrain_media_*` MCP\n * tools, which are not registered when this is absent.\n */\n readonly media?: MediaProvider\n\n /**\n * Whether the provider will accept a write right now.\n *\n * A local worktree accumulates unmerged `cr/*` branches and eventually has\n * to refuse new ones; a hosted provider manages that server-side. Asking the\n * provider keeps the tool layer out of the business of counting git branches\n * — it previously reached through an `instanceof` check to a `projectRoot`\n * only one implementation has.\n *\n * Optional: a provider that omits it is always ready.\n */\n checkWriteReadiness?(): Promise<WriteReadiness>\n\n /**\n * Where this provider's `.contentrain/` reads come from right now. A local\n * provider on a checked-out feature branch reads content from the\n * `contentrain` ref rather than the working tree, which content writes do\n * not update there; read tools report that. Optional: a provider that\n * omits it reads one fixed source.\n */\n contentSource?(): Promise<ContentReadSource>\n\n listBranches(prefix?: string): Promise<Branch[]>\n createBranch(name: string, fromRef?: string): Promise<void>\n deleteBranch(name: string): Promise<void>\n getBranchDiff(branch: string, base?: string): Promise<FileDiff[]>\n /**\n * Merge `branch` into `into`. Like `git merge` and the platform merge APIs,\n * the source branch is left in place by default. Pass\n * `opts.removeSourceBranch: true` to also delete it after a successful merge\n * (best-effort — reported via `MergeResult.remote`, never a thrown error).\n * Even when opted in, a long-lived branch is never deleted: not `into`, not\n * the `contentrain` content branch, and not the repo's default branch.\n * LocalProvider ignores the option: its cleanup is governed by\n * `config.remoteBranchCleanup` and its own `cr/*`-only guard.\n */\n mergeBranch(branch: string, into: string, opts?: { removeSourceBranch?: boolean }): Promise<MergeResult>\n isMerged(branch: string, into?: string): Promise<boolean>\n getDefaultBranch(): Promise<string>\n\n /**\n * The merge-base commit of two refs — the `base` input of a three-way\n * reconcile.\n *\n * Reconciling a diverged content branch needs the last common ancestor,\n * and every backend can name it (git `merge-base`, GitHub's compare API,\n * GitLab's merge_base endpoint) — but not every existing implementation\n * does yet, and demanding it would break external `implements` code.\n *\n * Optional: a provider that omits it cannot drive a reconcile; the caller\n * falls back to a pull-request flow. Returns `null` when the refs share\n * no history.\n */\n getMergeBase?(refA: string, refB: string): Promise<string | null>\n\n /**\n * Write a set of resolved changes as a TWO-PARENT merge commit on\n * `branch`, joining `ours` and `theirs` so that git history records the\n * reconcile — after it, `theirs` is an ancestor of `branch` and a plain\n * fast-forward advance works again. `changes` are applied on top of the\n * `ours` tree; for content-owned paths the planner's output is\n * authoritative, never a textual auto-merge.\n *\n * Optional, mirrored by `capabilities.mergeCommit`: a backend whose\n * commits API cannot express two parents (GitLab) omits it and the\n * caller falls back to a merge-request flow.\n */\n createMergeCommit?(input: {\n /** Branch whose tip becomes the merge commit (the content branch). */\n branch: string\n /** First parent — the current tip of `branch`. */\n ours: string\n /** Second parent — the ref being reconciled in. */\n theirs: string\n changes: FileChange[]\n message: string\n author: CommitAuthor\n }): Promise<Commit>\n}\n","import type { FieldDef, FieldType } from './index.js'\n\n/**\n * Media path → delivery URL rewriting. The single source of truth for how a\n * stored `media/...` reference becomes an absolute delivery URL — every\n * reader of Contentrain content that resolves this at read or write time\n * shares this module, so the rules can only be defined once.\n *\n * Two independent consumers need the exact same answer: `@contentrain/mcp`'s\n * `planContentSave` normalizes on write, for hosted/cloud mode, so a\n * git-committed value is ready to use with no SDK; `@contentrain/query`\n * resolves the same relative paths at read time, for the local-file model\n * (the generated client, the Astro loader, raw markdown) where nothing\n * upstream rewrote them. A second, hand-rolled implementation on either side\n * drifts the moment one of the rules below changes — titled markdown embeds,\n * an HTML `src`/`href`, or a media field nested inside an `object`/`array`\n * were all missed by an earlier, separate read-side implementation before\n * this module existed.\n *\n * Only image/video/file fields (resolved via the model schema, including those\n * nested in object/array fields) and `media/...` src/href targets in\n * markdown/HTML are rewritten. External URLs (`http(s)://`, `//`, `data:`) and\n * already-absolute delivery URLs pass through untouched, so every entry point\n * is idempotent and safe to call repeatedly. No base means no-op — the\n * relative path is kept verbatim, which is the OSS local-file model's default.\n */\n\nconst MEDIA_FIELD_TYPES = new Set<FieldType>(['image', 'video', 'file'])\n\n/**\n * Whether a stored field value is a relative media-storage path (`media/...`)\n * rather than an already-absolute URL or external link. Only these are\n * rewritten to delivery URLs — `http(s)://`, `//`, and `data:` are left as-is.\n */\nexport function isStoredMediaPath(value: unknown): value is string {\n return typeof value === 'string' && value.startsWith('media/')\n}\n\n/** Join a relative media path onto the per-project delivery base. */\nexport function toDeliveryUrl(base: string, path: string): string {\n return `${base.replace(/\\/+$/, '')}/${path}`\n}\n\n/**\n * Rewrite a stored media path to its absolute delivery URL. Non-media values\n * (external URLs, empty, non-strings) pass through untouched, so this is safe\n * to call on any field value.\n */\nexport function rewriteMediaUrl(base: string, value: unknown): unknown {\n return isStoredMediaPath(value) ? toDeliveryUrl(base, value) : value\n}\n\n/**\n * Rewrite media paths within a single field value, guided by its FieldDef so\n * only media fields — including those nested inside object/array fields — are\n * touched. Non-media values pass through.\n */\nexport function rewriteFieldMedia(value: unknown, field: FieldDef, base: string): unknown {\n if (value == null) return value\n\n if (MEDIA_FIELD_TYPES.has(field.type))\n return rewriteMediaUrl(base, value)\n\n if (field.type === 'object' && field.fields && typeof value === 'object' && !Array.isArray(value))\n return rewriteEntryMedia(value as Record<string, unknown>, field.fields, base)\n\n if (field.type === 'array' && Array.isArray(value)) {\n const itemDef: FieldDef | null = typeof field.items === 'string'\n ? { type: field.items as FieldType }\n : field.items ?? null\n if (!itemDef) return value\n return value.map(v => rewriteFieldMedia(v, itemDef, base))\n }\n\n return value\n}\n\n/**\n * Rewrite media paths across one entry/frontmatter object using a field map.\n * Returns a shallow copy (the input is never mutated); a no-op when the object\n * has no media fields.\n */\nexport function rewriteEntryMedia(\n entry: Record<string, unknown>,\n fields: Record<string, FieldDef>,\n base: string,\n): Record<string, unknown> {\n const out = { ...entry }\n for (const [fieldId, field] of Object.entries(fields)) {\n if (fieldId in out)\n out[fieldId] = rewriteFieldMedia(out[fieldId], field, base)\n }\n return out\n}\n\n/** Rewrite `media/...` markdown image/link targets and inline src/href attrs. */\nexport function rewriteMarkdownMedia(body: string, base: string): string {\n return body\n // markdown image/link target: ](media/...) — stops at whitespace, ) or \"\n .replace(/(\\]\\()(media\\/[^)\\s\"]+)/g, (_m, open, p) => `${open}${toDeliveryUrl(base, p)}`)\n // inline HTML src=/href=\"media/...\"\n .replace(/(\\s(?:src|href)=)([\"'])(media\\/[^\"']+)\\2/gi,\n (_m, attr, quote, p) => `${attr}${quote}${toDeliveryUrl(base, p)}${quote}`)\n}\n","import { sortKeys } from './canonical.js'\n\n// ─── Type System ───\n\nexport type FieldType =\n | 'string' | 'text' | 'email' | 'url' | 'slug' | 'color' | 'phone' | 'code' | 'icon'\n | 'markdown' | 'richtext'\n | 'number' | 'integer' | 'decimal' | 'percent' | 'rating'\n | 'boolean'\n | 'date' | 'datetime'\n | 'image' | 'video' | 'file'\n | 'relation' | 'relations'\n | 'select' | 'array' | 'object'\n\nexport type ModelKind = 'singleton' | 'collection' | 'document' | 'dictionary'\nexport type ContentStatus = 'draft' | 'in_review' | 'published' | 'rejected' | 'archived'\nexport type ContentSource = 'agent' | 'human' | 'import'\nexport type WorkflowMode = 'auto-merge' | 'review'\n// Meta-frameworks\nexport type StackType =\n | 'nuxt' | 'next' | 'astro' | 'sveltekit' | 'remix' | 'analog'\n // Plain frameworks\n | 'vue' | 'react' | 'svelte' | 'solid' | 'angular'\n // Mobile\n | 'react-native' | 'expo' | 'flutter'\n // Backend\n | 'node' | 'express' | 'fastify' | 'nestjs' | 'django' | 'rails' | 'laravel' | 'go' | 'rust' | 'dotnet'\n // Static site generators\n | 'hugo' | 'jekyll' | 'eleventy'\n // Desktop\n | 'electron' | 'tauri'\n // Catch-all\n | 'other'\n\nexport type Platform = 'web' | 'mobile' | 'api' | 'desktop' | 'static' | 'other'\nexport type ContextSource = 'mcp-local' | 'mcp-studio' | 'studio-ui'\nexport type CollectionRuntimeFormat = 'map' | 'array'\n\n// ─── Field Definition ───\n\nexport interface FieldDef {\n type: FieldType\n required?: boolean\n unique?: boolean\n default?: unknown\n min?: number\n max?: number\n pattern?: string\n options?: string[]\n model?: string | string[]\n items?: string | FieldDef\n fields?: Record<string, FieldDef>\n accept?: string\n maxSize?: number\n description?: string\n /**\n * Human-readable name for this field in an editing UI.\n *\n * A plain string is one label for every locale; an object keyed by locale\n * carries a translation per locale. The union exists now rather than later\n * because widening `string` to it afterwards would break every consumer\n * that treats the value as a string.\n *\n * Absent means the field name itself is the label — which is why an editor\n * shows `body_public` and `is_category_hero` today.\n */\n label?: string | Record<string, string>\n /**\n * Display position in an editing UI, ascending.\n *\n * Fields are stored in canonical alphabetical order, so without this an\n * editor shows them alphabetically: on a 16-field article model `author`\n * comes first and `title` fifteenth. Fractional values are allowed so a\n * field can be inserted between two others without renumbering the rest.\n *\n * Fields without one sort after every field that has one, alphabetically\n * among themselves — so a model that has not been given an order behaves\n * exactly as it does today.\n */\n order?: number\n}\n\n// ─── Model Definition ───\n\nexport type LocaleStrategy = 'file' | 'suffix' | 'directory' | 'none'\n\nexport interface ModelDefinition {\n id: string\n name: string\n kind: ModelKind\n domain: string\n i18n: boolean\n /**\n * The project locales this model's content actually covers — a subset of\n * `config.locales.supported`.\n *\n * Absent means every project locale, which is the behaviour every model had\n * before this field existed: parity is checked against the whole supported\n * list. Present, it narrows that check to the locales named here, so a\n * partially-translated site can declare the truth instead of failing.\n *\n * A locale not in `config.locales.supported` is a validation error — see\n * {@link validateModelLocales}. Meaningless on a non-i18n model, which has\n * only the default locale.\n *\n * It narrows validation; it never grants a locale. Writing content in a\n * locale outside this list is still governed by `config.locales.supported`.\n */\n locales?: string[]\n /**\n * Name of the field whose value is shown as an entry's title — in a Studio row,\n * a picker, a relation reference. Declared, never guessed: a consumer that infers\n * the title from field order or length picks the icon over the headline.\n *\n * Must name a field that exists on this model and whose type can render as text\n * (see `isTitleFieldType`). Dictionary models have no fields, so their only legal\n * value is `DICTIONARY_TITLE_FIELD` — the entry key is the title. A dotted path\n * (`hero.heading`) names a field one level inside an `object` field — a page\n * singleton built from sections is titled by a section's heading\n * (`titleFieldTarget`, `titleFieldValue`).\n */\n title_field: string\n description?: string\n fields?: Record<string, FieldDef>\n /** Framework-relative path for content files (e.g. \"content/blog\", \"locales\"). When set, content is written here instead of .contentrain/content/ */\n content_path?: string\n /** How locale is encoded in file names. Default: \"file\" ({dir}/{locale}.json or {dir}/{slug}/{locale}.md) */\n locale_strategy?: LocaleStrategy\n /**\n * Public form configuration for this model — which fields a visitor-facing\n * form exposes, honeypot, captcha, auto-approve. Owned and interpreted by the\n * runtime provider (Studio writes it through its model PATCH); the content\n * engine carries it verbatim and never reads it. A model edit that rebuilds\n * the definition must keep it, or the site's form silently stops working.\n */\n form?: Record<string, unknown>\n /** Public comments configuration for this model — same ownership and handling as `form`. */\n comments?: Record<string, unknown>\n}\n\n/** Model keys the content engine carries verbatim for the runtime provider — see `ModelDefinition.form`. */\nexport const MODEL_EXTENSION_KEYS = ['form', 'comments'] as const satisfies readonly (keyof ModelDefinition)[]\n\n// ─── Config ───\n\nexport interface ContentrainConfig {\n version: number\n platform?: Platform\n stack: StackType\n workflow: WorkflowMode\n repository?: {\n /**\n * Git host backing this project's remote. Widens as new providers ship\n * in @contentrain/mcp — `github` and `gitlab` are currently supported.\n * The value is informational for tooling: the concrete provider the\n * MCP server talks to is chosen at server construction time, not from\n * this config. Optional, like `owner`/`name`: `init` records\n * `default_branch` alone when it knows no remote host.\n */\n provider?: 'github' | 'gitlab'\n owner?: string\n name?: string\n /**\n * Not informational: local writes advance this branch. A local\n * auto-merge merges it into `contentrain` before each write, then\n * fast-forwards it to the `contentrain` tip and pushes it. The\n * `CONTENTRAIN_BRANCH` env overrides it; without either, the base is\n * the remote's default branch, then `main`, then `master` — never\n * merely the checked-out branch. `init` writes it once, resolved that\n * way, and never overwrites an existing value.\n */\n default_branch: string\n }\n locales: {\n default: string\n supported: string[]\n }\n domains: string[]\n assets_path?: string\n /**\n * Public media delivery base for resolving relative `media/...` references to\n * absolute URLs (e.g. a deployed CDN delivery endpoint). When set,\n * `@contentrain/query`'s `generate` bakes a `media()` resolver into the\n * generated client. The base may already include any project segment — the\n * resolver only joins `{cdn.url}/{path}`. Distinct from `assets_path` (the\n * local on-disk asset directory).\n */\n cdn?: {\n url?: string\n }\n /** Days a merged `cr/*` branch is kept before lazy cleanup. Default: 30. */\n branchRetention?: number\n /** Unmerged `cr/*` branch count that triggers a warning. Default: 50. */\n branchWarnLimit?: number\n /** Unmerged `cr/*` branch count that blocks new writes. Default: 80. */\n branchBlockLimit?: number\n /**\n * Delete the remote copy of a `cr/*` branch when it is merged or deleted\n * locally, and allow lazy pruning of already-merged remote branches.\n * Read-only remote visibility (doctor, branch listing) is not affected.\n * Default: true.\n */\n remoteBranchCleanup?: boolean\n}\n\n// ─── Vocabulary ───\n\nexport interface Vocabulary {\n version: number\n terms: Record<string, Record<string, string>>\n}\n\n// ─── Metadata ───\n\nexport interface EntryMeta {\n status: ContentStatus\n source: ContentSource\n updated_by: string\n /**\n * When this entry was last written, ISO 8601 UTC.\n *\n * Optional, and deliberately not backfilled. It is a fact about the past,\n * and an entry written before the field existed has no recoverable value —\n * a fabricated timestamp would be worse than an absent one, because it\n * would sort. Absent means unknown; the first write mints the real value.\n */\n updated_at?: string\n approved_by?: string | null\n version?: string\n publish_at?: string\n expire_at?: string\n}\n\nexport type SingletonMeta = EntryMeta\nexport type CollectionMeta = Record<string, EntryMeta>\nexport type DocumentMeta = EntryMeta\nexport type DictionaryMeta = EntryMeta\n\n// ─── Asset ───\n\nexport interface AssetEntry {\n path: string\n type: string\n size: number\n alt?: string\n}\n\n// ─── Validation ───\n\nexport interface ValidationError {\n severity: 'error' | 'warning' | 'notice'\n model?: string\n locale?: string\n entry?: string\n slug?: string\n field?: string\n message: string\n}\n\nexport interface ValidationResult {\n valid: boolean\n errors: ValidationError[]\n}\n\n// ─── Content Types (Storage vs Output) ───\n\n// Storage formats — how content is written to disk (canonical JSON)\nexport type SingletonContentFile = Record<string, unknown>\nexport type CollectionContentFile = Record<string, Record<string, unknown>>\nexport type DictionaryContentFile = Record<string, string>\n// Document storage = markdown file (frontmatter + body), not JSON\n\n// Output formats — how MCP/SDK returns content to consumers\nexport type CollectionEntry = { id: string } & Record<string, unknown>\nexport type CollectionContentOutput = CollectionEntry[]\n\n/** Document entry as returned by MCP/SDK — parsed markdown with frontmatter */\nexport interface DocumentEntry {\n slug: string\n frontmatter: Record<string, unknown>\n body: string\n}\n\nexport type DocumentContentOutput = DocumentEntry[]\n\n/** Polymorphic relation storage — used when model field is string[] (multiple targets) */\nexport interface PolymorphicRelationRef {\n model: string\n ref: string\n}\n\n// ─── Model Summary ───\n\n/** Lightweight model info for listing and status operations */\nexport interface ModelSummary {\n id: string\n kind: ModelKind\n domain: string\n i18n: boolean\n fields: number\n}\n\n// ─── File Framework ───\n\n/** Source file framework — detected from extension for normalize/apply operations */\nexport type FileFramework = 'vue' | 'svelte' | 'jsx' | 'astro' | 'script'\n\n// ─── Path Conventions ───\n\n/** Root directory name for all Contentrain data */\nexport const CONTENTRAIN_DIR = '.contentrain' as const\n\n/**\n * Default path patterns for Contentrain projects.\n * Variables: {domain}, {modelId}, {locale}, {slug}\n *\n * When a model has `content_path` set, content files are written to\n * `{projectRoot}/{content_path}/` instead — but meta files always\n * remain under `.contentrain/meta/`.\n */\n/**\n * Where each kind of file lives. These are the paths a reader can rely on and\n * a writer must produce; `@contentrain/mcp` resolves exactly these, and a\n * parity test there holds the two together.\n *\n * They show the default `locale_strategy: 'file'`. The `suffix`, `directory`\n * and `none` strategies move the locale within the same directory — see\n * `LocaleStrategy` — and `content_path` replaces the\n * `.contentrain/content/{domain}/{modelId}` prefix entirely.\n *\n * Meta always carries a locale in its file name: a non-i18n model keeps one\n * meta record, pinned to the project's default locale.\n */\nexport const PATH_PATTERNS = {\n config: '.contentrain/config.json',\n context: '.contentrain/context.json',\n vocabulary: '.contentrain/vocabulary.json',\n model: '.contentrain/models/{modelId}.json',\n /** Detected source capabilities and the provider chosen for each — `CapabilityManifest`. */\n capabilities: '.contentrain/capabilities.json',\n /** Scheduled and event-driven agent work — `AutomationDefinition[]`. */\n automations: '.contentrain/automations.json',\n /** Which operations need whose approval — `ApprovalPolicyFile`. */\n approvalPolicies: '.contentrain/approval-policies.json',\n /** Source→destination URL map, the output of a migration's `moved` entries. */\n redirects: '.contentrain/redirects.json',\n content: {\n singleton: '.contentrain/content/{domain}/{modelId}/{locale}.json',\n collection: '.contentrain/content/{domain}/{modelId}/{locale}.json',\n document: '.contentrain/content/{domain}/{modelId}/{slug}/{locale}.md',\n dictionary: '.contentrain/content/{domain}/{modelId}/{locale}.json',\n /** Non-i18n JSON content (i18n: false) */\n noLocale: '.contentrain/content/{domain}/{modelId}/data.json',\n /** Non-i18n document (i18n: false) */\n noLocaleDocument: '.contentrain/content/{domain}/{modelId}/{slug}.md',\n },\n meta: {\n singleton: '.contentrain/meta/{modelId}/{locale}.json',\n collection: '.contentrain/meta/{modelId}/{locale}.json',\n document: '.contentrain/meta/{modelId}/{slug}/{locale}.json',\n dictionary: '.contentrain/meta/{modelId}/{locale}.json',\n },\n} as const\n\n/**\n * Paths this repository reserves but does not yet write.\n *\n * Reserving them is the point. `.contentrain/` is a shared namespace: the\n * migration engine, Studio and a customer's own tooling all write into it. A\n * name claimed here cannot later be taken for something else, and — more\n * usefully — the tools that walk the directory know these four are *expected*\n * rather than stray.\n *\n * The contract for them is deliberately narrow and must stay that way until a\n * tool actually owns one:\n *\n * - `contentrain_doctor` and `contentrain_validate` ignore them. They are not\n * orphans, not broken content, and not the validator's business.\n * - `contentrain_reconcile` treats each as one opaque file: it takes the side\n * that changed, and reports `file_conflict` when both sides did. It never\n * merges their interiors, because it does not know their interiors.\n *\n * Both behaviours are pinned by tests in `@contentrain/mcp`, so a future\n * change to directory walking cannot quietly start flagging or auto-merging\n * these files.\n */\nexport const RESERVED_PATHS = [\n PATH_PATTERNS.capabilities,\n PATH_PATTERNS.automations,\n PATH_PATTERNS.approvalPolicies,\n PATH_PATTERNS.redirects,\n] as const\n\nexport function isReservedPath(path: string): boolean {\n return (RESERVED_PATHS as readonly string[]).includes(path)\n}\n\n// ─── Validation Patterns ───\n\n/**\n * Slug: lowercase letters and digits of any script, joined by single hyphens. A letter's combining marks belong to\n * it (Hindi, Thai and Arabic words carry them), so `\\p{M}` is part of a word. ASCII slugs are exactly what they were;\n * `validateSlug` also requires NFC, so one address has one spelling. A slug is the address of a page: it is kept as\n * the source wrote it, not transliterated.\n */\nexport const SLUG_PATTERN = /^[\\p{Ll}\\p{Lo}\\p{Lm}\\p{N}\\p{M}]+(?:-[\\p{Ll}\\p{Lo}\\p{Lm}\\p{N}\\p{M}]+)*$/u\n/** Entry ID: alphanumeric, 1-40 chars, starts with alphanumeric */\nexport const ENTRY_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,39}$/\n/** Locale: ISO 639-1 with optional region code */\nexport const LOCALE_PATTERN = /^[a-z]{2}(?:-[A-Z]{2})?$/\n\n// ─── Canonical Serialization ───\n\n/** Rules for deterministic JSON output — ensures stable git diffs */\nexport const CANONICAL_JSON = {\n indent: 2,\n encoding: 'utf-8',\n trailingNewline: true,\n omitNull: true,\n omitDefaults: true,\n sortKeys: true,\n} as const\n\n/**\n * Key order for canonical model JSON — the required keys first, then the optional\n * ones, then `fields` last so a reader sees the model's identity before scrolling\n * past its schema.\n *\n * This lives here, and only here, because `canonicalStringify` falls back to\n * alphabetical for any key absent from the order: a forgotten entry does not error,\n * it silently relocates. `satisfies` catches a key that is not on `ModelDefinition`;\n * the `keyof` assertion in index.test.ts catches a `ModelDefinition` key missing\n * from this array. Between them, drift fails `tsc`, which runs on every commit.\n */\nexport const MODEL_FIELD_ORDER = [\n 'id',\n 'name',\n 'kind',\n 'domain',\n 'i18n',\n 'locales',\n 'title_field',\n 'description',\n 'content_path',\n 'locale_strategy',\n 'fields',\n 'form',\n 'comments',\n] as const satisfies readonly (keyof ModelDefinition)[]\n\n// ─── Scaffold ───\n\nexport interface ScaffoldTemplate {\n id: string\n models: ModelDefinition[]\n sample_content?: Record<string, Record<string, unknown>>\n vocabulary?: Record<string, Record<string, string>>\n}\n\n// ─── Scan Types ───\n\nexport type StringContext =\n | 'jsx_text' | 'jsx_attribute'\n | 'template_text' | 'template_attribute'\n | 'variable_assignment' | 'function_argument'\n | 'object_value' | 'other'\n\nexport type FileCategory = 'page' | 'component' | 'layout' | 'other'\n\nexport interface ScanCandidate {\n file: string\n line: number\n column: number\n value: string\n context: StringContext\n surrounding: string\n /** Content confidence score (0-1). Higher = more likely user-visible content. */\n contentScore: number\n /** All locations where this value appears (populated when dedup is enabled). */\n occurrences: Array<{ file: string; line: number }>\n}\n\nexport interface DuplicateGroup {\n value: string\n count: number\n occurrences: Array<{ file: string; line: number }>\n}\n\nexport interface GraphNode {\n file: string\n category: FileCategory\n components?: string[]\n imports: string[]\n used_by: string[]\n strings: number\n}\n\nexport interface ProjectGraph {\n pages: GraphNode[]\n components: GraphNode[]\n layouts: GraphNode[]\n orphan_files: string[]\n stats: {\n total_files: number\n total_components: number\n total_pages: number\n total_strings_estimate: number\n }\n}\n\nexport interface ScanCandidatesResult {\n candidates: ScanCandidate[]\n duplicates: DuplicateGroup[]\n stats: {\n files_scanned: number\n raw_strings_found: number\n skipped: number\n low_confidence: number\n unique_candidates: number\n candidates_returned: number\n has_more: boolean\n skip_reasons: Record<string, number>\n }\n}\n\nexport interface ScanSummaryResult {\n total_files: number\n total_candidates_estimate: number\n by_directory: Record<string, { files: number; candidates: number }>\n top_repeated: Array<{ value: string; count: number }>\n sampling_note?: string\n file_types: Record<string, number>\n}\n\n// ─── Normalize Plan ───\n\nexport interface NormalizePlan {\n version: number\n status: 'pending' | 'approved' | 'rejected'\n created_at: string\n approved_at?: string\n agent: string\n scan_stats: {\n files_scanned: number\n raw_strings: number\n candidates_sent: number\n extracted: number\n skipped: number\n }\n models: NormalizePlanModel[]\n extractions: NormalizePlanExtraction[]\n patches: NormalizePlanPatch[]\n approved_models?: string[]\n}\n\nexport interface NormalizePlanModel {\n id: string\n kind: ModelKind\n domain: string\n i18n?: boolean\n fields: Record<string, FieldDef>\n}\n\nexport interface NormalizePlanExtraction {\n value: string\n file: string\n line: number\n model: string\n field: string\n locale?: string\n}\n\nexport interface NormalizePlanPatch {\n file: string\n line: number\n old_value: string\n new_expression: string\n import_statement?: string\n}\n\n// ─── Context ───\n\nexport interface ContextJson {\n version: string\n lastOperation: {\n tool: string\n model: string\n locale: string\n entries?: string[]\n timestamp: string\n source: ContextSource\n }\n stats: {\n models: number\n entries: number\n locales: string[]\n lastSync: string\n }\n}\n\n// ─── Git Transaction Types ───\n\n/** Standard name for the dedicated content branch */\nexport const CONTENTRAIN_BRANCH = 'contentrain' as const\n\n/** Result of selective file sync to developer's working tree */\nexport interface SyncResult {\n /** Files successfully synced to working tree */\n synced: string[]\n /** Files skipped due to uncommitted local changes */\n skipped: string[]\n /** Human-readable warning if files were skipped */\n warning?: string\n}\n\n/** Structured error for git operations with actionable guidance */\nexport interface ContentrainError {\n /** Machine-readable error code */\n code: string\n /** Human-readable error message */\n message: string\n /** Guidance for AI agents on how to handle this error */\n agent_hint: string\n /** Command or action the developer should take */\n developer_action: string\n}\n\n// ─── Validate: Pure Functions ───\n\n/**\n * Patterns for detecting a credential that has been pasted into content.\n *\n * Each one matches a credential-shaped *value*, not prose that mentions\n * credentials. That distinction is the whole design: the earlier set matched\n * bare prefixes, so `task_id` and `risk_level` tripped `sk_`, every page\n * documenting `Authorization: Bearer <token>` tripped `Bearer `, and\n * `postgres://localhost:5432/mydb` in a getting-started guide tripped the\n * connection-string rule. On this repository — which stores its own\n * documentation as content — that was 7 of 12 validation errors, and the real\n * findings were buried under them.\n *\n * A check that fires on the wrong thing is not a strict check; it is a check\n * people learn to scroll past.\n *\n * Every pattern here carries a provider-specific shape — a fixed prefix, a\n * credential-only URL form, a PEM header — and is trusted on its own. The one\n * generic rule, `api_key = …`, has no such shape and lives in\n * {@link API_KEY_ASSIGNMENT}, where what it captures is checked before it fires.\n */\nexport const SECRET_PATTERNS: ReadonlyArray<RegExp> = [\n // Stripe-style keys: the prefix must start a word and carry a long body,\n // so `task_id` and `risk_level` no longer match.\n /\\b[sp]k_(?:live|test)_[A-Za-z0-9]{6,}/,\n // GitHub tokens are a fixed prefix plus a long body.\n /\\bgh[pousr]_[A-Za-z0-9]{10,}/,\n // A bearer token, not the word. Placeholders — <token>, $TOKEN, {{token}},\n // YOUR_TOKEN — are how documentation writes it, and are not secrets.\n /\\bBearer\\s+(?![<${{]|YOUR[_-]|your[_-]|xxx|XXX|\\.{3})[A-Za-z0-9._~+/-]{20,}/,\n /\\baws_secret[_a-z]*\\s*[:=]\\s*['\"`]?[A-Za-z0-9/+=]{20,}/i,\n // AWS access key IDs have a fixed shape.\n /\\bAKIA[0-9A-Z]{16}\\b/,\n // A connection string only carries a secret when it carries credentials;\n // `postgres://localhost:5432/mydb` in a guide does not.\n /\\b(?:postgres(?:ql)?|mysql|mongodb)(?:\\+srv)?:\\/\\/[^\\s:@/]+:[^\\s@/]+@/,\n // A pasted private key is unambiguous.\n /-----BEGIN [A-Z ]*PRIVATE KEY-----/,\n]\n\n/** Validate a slug — returns error message or null if valid */\nexport function validateSlug(slug: string): string | null {\n if (!slug) return 'Slug is required'\n if (!SLUG_PATTERN.test(slug)) return `Invalid slug \"${slug}\": must be kebab-case letters and digits (lowercase, hyphens between words)`\n if (slug !== slug.normalize('NFC')) return `Invalid slug \"${slug}\": must be Unicode NFC`\n // A slug is also a file name (`<slug>.md`): keep it within what file systems take (255 bytes, with the extension).\n if (new TextEncoder().encode(slug).length > 200) return `Invalid slug \"${slug}\": longer than 200 bytes`\n if (slug.startsWith('.') || slug.includes('..')) return `Invalid slug \"${slug}\": path traversal not allowed`\n return null\n}\n\n/** Validate an entry ID — returns error message or null if valid */\nexport function validateEntryId(id: string): string | null {\n if (!ENTRY_ID_PATTERN.test(id)) return `Invalid entry ID \"${id}\": must be 1-40 alphanumeric characters (hyphens and underscores allowed)`\n return null\n}\n\n/** Validate a locale code against pattern and config — returns error message or null if valid */\nexport function validateLocale(locale: string, config: ContentrainConfig): string | null {\n if (!LOCALE_PATTERN.test(locale)) return `Invalid locale \"${locale}\": must be ISO 639-1 format (e.g. \"en\", \"tr\", \"pt-BR\")`\n if (!config.locales.supported.includes(locale)) {\n return `Locale \"${locale}\" is not in supported locales [${config.locales.supported.join(', ')}]. Update config first.`\n }\n return null\n}\n\n/**\n * The locales one model's content is checked against, and where that list came\n * from — `'model'` when the model narrowed it with `locales`, `'project'` when\n * it is the project-wide list (or, for a non-i18n model, the default locale).\n *\n * The provenance travels with the list because every message derived from it\n * has to say which list it evaluated: \"missing tr\" means something different\n * when tr is one of four project locales than when the model declares it.\n */\nexport interface ModelLocaleScope {\n locales: string[]\n source: 'model' | 'project'\n}\n\n/**\n * The locales a model's content is expected to cover.\n *\n * A non-i18n model stores content locale-agnostically, so its scope is the\n * default locale alone — unchanged, and `locales` never applies to it. An i18n\n * model narrows to `model.locales` when it declares one, otherwise it covers\n * every supported locale, which is what every model did before the field\n * existed.\n *\n * Locales are returned in `config.locales.supported` order, not the order the\n * model happened to list them: a message that names the list should read the\n * same for two models that declare the same locales differently. Anything the\n * model names that the project does not support is dropped here and reported\n * by {@link validateModelLocales} — resolution never invents coverage.\n */\nexport function resolveModelLocales(\n model: Pick<ModelDefinition, 'i18n' | 'locales'>,\n config: ContentrainConfig,\n): ModelLocaleScope {\n if (!model.i18n) return { locales: [config.locales.default], source: 'project' }\n const declared = model.locales\n if (!Array.isArray(declared) || declared.length === 0) {\n return { locales: [...config.locales.supported], source: 'project' }\n }\n const narrowed = config.locales.supported.filter(l => declared.includes(l))\n if (narrowed.length === 0) return { locales: [...config.locales.supported], source: 'project' }\n return { locales: narrowed, source: 'model' }\n}\n\n/** Name a locale scope the way a validation message should quote it. */\nexport function describeModelLocaleScope(scope: ModelLocaleScope): string {\n const list = `[${scope.locales.join(', ')}]`\n return scope.source === 'model'\n ? `the model's own locales ${list}`\n : `the project's supported locales ${list}`\n}\n\n/**\n * Check a model's `locales` declaration against the project config.\n *\n * Absent is valid and means every supported locale, so this returns nothing for\n * the models that predate the field. What it rejects is a declaration that\n * cannot be honoured: a locale the project does not support (the coverage would\n * be unreachable), an empty list (a model that covers nothing is a deleted\n * model, not a narrowed one), a duplicate, or a non-string entry.\n *\n * Declaring `locales` on a non-i18n model is a warning, not an error: the field\n * has no effect there, but the model is still readable and its content still\n * validates, so failing the project over it would be out of proportion.\n */\nexport function validateModelLocales(\n model: Pick<ModelDefinition, 'i18n' | 'locales'>,\n config: ContentrainConfig,\n): ValidationError[] {\n if (model.locales === undefined) return []\n\n const errors: ValidationError[] = []\n const err = (message: string): void => { errors.push({ severity: 'error', field: 'locales', message }) }\n\n if (!Array.isArray(model.locales)) {\n err(`Invalid \"locales\": must be an array of locale codes, got ${typeof model.locales}.`)\n return errors\n }\n if (model.locales.length === 0) {\n err('Invalid \"locales\": an empty list covers nothing. Omit the field to cover every supported locale.')\n return errors\n }\n\n const seen = new Set<string>()\n for (const locale of model.locales) {\n if (typeof locale !== 'string') {\n err(`Invalid \"locales\": every entry must be a locale code, got ${typeof locale}.`)\n continue\n }\n if (seen.has(locale)) {\n err(`Invalid \"locales\": locale \"${locale}\" is listed more than once.`)\n continue\n }\n seen.add(locale)\n if (!config.locales.supported.includes(locale)) {\n err(\n `Invalid \"locales\": locale \"${locale}\" is not in the project's supported locales `\n + `[${config.locales.supported.join(', ')}]. A model's locales must be a subset — `\n + `add it to config.json first, or drop it here.`,\n )\n }\n }\n\n if (errors.length === 0 && !model.i18n) {\n errors.push({\n severity: 'warning',\n field: 'locales',\n message: '\"locales\" has no effect on a model with i18n:false — such a model stores one locale-agnostic copy. Remove it, or set i18n:true.',\n })\n }\n\n return errors\n}\n\n/**\n * An API key being assigned a value: `api_key = <tail>`.\n *\n * Generic by nature — there is no provider prefix, so the word plus a separator\n * is the only structural signal — and therefore the one rule that also has to\n * look at what it captured before it fires; see {@link looksLikeCredential}.\n * Without that, `api_key = your_project_api_key_here` in a setup guide was a\n * blocking error, and once `contentrain validate` exited non-zero, a permanent\n * CI failure for any project whose content discusses API keys — which, for a\n * CMS, is ordinary content. Placeholders documentation uses (`<key>`, `$KEY`,\n * `{{key}}`, `YOUR_KEY`, `xxx`, `…`) are excluded up front, as for `Bearer`.\n */\nconst API_KEY_ASSIGNMENT = /\\bapi[_-]?key\\s*[:=]\\s*['\"`]?(?![<${{]|YOUR[_-]|your[_-]|xxx|XXX|\\.{3})([A-Za-z0-9_-]{16,})/gi\n\n/**\n * Does a value assigned to an API-key setting look like a credential rather\n * than prose about one?\n *\n * Two shapes are documentation, not secrets: a tail with no digit at all\n * (`your_project_api_key_here`, `xxxxxxxxxxxxxxxxxxxx`), and a tail whose\n * `_`/`-` separated tokens are each purely letters or purely digits\n * (`my_api_key_2024`, `REPLACE-WITH-KEY-1`) — words with a version number. An\n * issued key mixes letters and digits inside one token (`8f14e45fceea167a`,\n * `k1p8-q7x2m9v4z3w6`), which no sentence does by accident.\n */\nexport function looksLikeCredential(tail: string): boolean {\n if (!/\\d/.test(tail)) return false\n const tokens = tail.split(/[_-]+/).filter(Boolean)\n if (tokens.length > 1 && tokens.every(t => /^[A-Za-z]+$/.test(t) || /^\\d+$/.test(t))) return false\n return true\n}\n\n/** Detect potential secrets in a value — returns validation errors if found */\nexport function detectSecrets(value: unknown): ValidationError[] {\n if (typeof value !== 'string') return []\n const found = SECRET_PATTERNS.some(p => p.test(value))\n || [...value.matchAll(API_KEY_ASSIGNMENT)].some(m => looksLikeCredential(m[1]!))\n if (found) {\n return [{ severity: 'error', message: 'Potential secret detected in value' }]\n }\n return []\n}\n\n/** The models a relation field may point into. */\nfunction relationTargets(fieldDef: FieldDef): string[] {\n if (Array.isArray(fieldDef.model)) return fieldDef.model\n return fieldDef.model ? [fieldDef.model] : []\n}\n\n/**\n * A reference into one of several models: `{ model, ref }`.\n *\n * A relation with more than one target model cannot store a bare id — the id\n * alone does not say which model's entry it is — so it stores the pair. With a\n * single target the id is enough and stays a string. A one-element `model`\n * array is a single target. This is the storage rule the schema documents and\n * the MCP entry validator enforces; a type check that knew only the string form\n * rejected every correctly stored polymorphic reference.\n */\nfunction isPolymorphicRef(value: unknown): value is { model: string, ref: string } {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n && typeof (value as { model?: unknown }).model === 'string'\n && typeof (value as { ref?: unknown }).ref === 'string'\n}\n\nfunction fieldTypeMatches(value: unknown, fieldDef: FieldDef): boolean {\n if (value === null || value === undefined) return true\n\n switch (fieldDef.type) {\n case 'string':\n case 'text':\n case 'email':\n case 'url':\n case 'slug':\n case 'color':\n case 'phone':\n case 'code':\n case 'icon':\n case 'markdown':\n case 'richtext':\n case 'date':\n case 'datetime':\n case 'image':\n case 'video':\n case 'file':\n case 'select':\n return typeof value === 'string'\n\n case 'number':\n case 'integer':\n case 'decimal':\n case 'percent':\n case 'rating':\n return typeof value === 'number'\n\n case 'boolean':\n return typeof value === 'boolean'\n\n case 'relation':\n return relationTargets(fieldDef).length > 1 ? isPolymorphicRef(value) : typeof value === 'string'\n\n case 'relations':\n case 'array':\n return Array.isArray(value)\n\n case 'object':\n return typeof value === 'object' && !Array.isArray(value)\n\n default:\n return true\n }\n}\n\nfunction checkMinMax(value: unknown, fieldDef: FieldDef): string | null {\n if (fieldDef.min !== undefined) {\n if (typeof value === 'number' && value < fieldDef.min) {\n return `Value ${value} is below minimum ${fieldDef.min}`\n }\n if (typeof value === 'string' && value.length < fieldDef.min) {\n return `String length ${value.length} is below minimum ${fieldDef.min}`\n }\n if (Array.isArray(value) && value.length < fieldDef.min) {\n return `Array length ${value.length} is below minimum ${fieldDef.min}`\n }\n }\n if (fieldDef.max !== undefined) {\n if (typeof value === 'number' && value > fieldDef.max) {\n return `Value ${value} is above maximum ${fieldDef.max}`\n }\n if (typeof value === 'string' && value.length > fieldDef.max) {\n return `String length ${value.length} is above maximum ${fieldDef.max}`\n }\n if (Array.isArray(value) && value.length > fieldDef.max) {\n return `Array length ${value.length} is above maximum ${fieldDef.max}`\n }\n }\n return null\n}\n\n// ─── Semantic type validation ───\n\n/** Hex (#rgb/#rrggbb/#rrggbbaa), rgb()/rgba(), hsl()/hsla(), or a bare CSS keyword. */\nconst COLOR_PATTERN = /^(#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|(?:rgb|hsl)a?\\([^)]*\\)|[a-zA-Z]+)$/\n/** Deliberately loose: digits, spaces, and the usual separators, 4-20 digits total. */\nconst PHONE_PATTERN = /^\\+?[\\d\\s().-]{4,}$/\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\nconst DATE_PATTERN = /^\\d{4}-\\d{2}-\\d{2}$/\n\nfunction isParsableDate(value: string): boolean {\n return !Number.isNaN(new Date(value).getTime())\n}\n\n/**\n * Rules a field type implies beyond its runtime `typeof`.\n *\n * Severity follows one rule: a check that is *definitional* is an error — a `slug`\n * that does not match `SLUG_PATTERN` is not a slug. A check that is a *heuristic* is\n * a warning, because the pattern is an approximation and a legitimate value may sit\n * outside it (international phone formats, CSS colour keywords).\n *\n * `rating` is deliberately absent: its scale is never declared, so any range here\n * would be invented. Declare `min`/`max` on the field instead.\n *\n * Returns `[]` for types whose only contract is their `typeof` (`string`, `text`,\n * `code`, `icon`, `markdown`, `richtext`, `number`, `decimal`, `boolean`, media).\n */\nexport function validateSemanticType(value: unknown, type: FieldType): ValidationError[] {\n if (typeof value === 'number') {\n if (type === 'integer' && !Number.isInteger(value)) {\n return [{ severity: 'error', message: `Value ${value} is not an integer` }]\n }\n if (type === 'percent' && (value < 0 || value > 100)) {\n return [{ severity: 'error', message: `Percent ${value} is outside 0-100` }]\n }\n return []\n }\n\n if (typeof value !== 'string' || value === '') return []\n\n switch (type) {\n case 'slug': {\n const err = validateSlug(value)\n return err ? [{ severity: 'error', message: err }] : []\n }\n case 'date':\n return DATE_PATTERN.test(value) && isParsableDate(value)\n ? []\n : [{ severity: 'error', message: `Invalid date \"${value}\": must be YYYY-MM-DD` }]\n case 'datetime':\n return isParsableDate(value)\n ? []\n : [{ severity: 'error', message: `Invalid datetime \"${value}\": must be an ISO 8601 date-time` }]\n case 'email':\n return EMAIL_PATTERN.test(value)\n ? []\n : [{ severity: 'warning', message: `\"${value}\" may not be a valid email` }]\n case 'url':\n return /^https?:\\/\\/.+/.test(value) || value.startsWith('/')\n ? []\n : [{ severity: 'warning', message: `\"${value}\" may not be a valid URL` }]\n case 'color':\n return COLOR_PATTERN.test(value)\n ? []\n : [{ severity: 'warning', message: `\"${value}\" may not be a valid color (expected hex, rgb(), hsl() or a keyword)` }]\n case 'phone':\n return PHONE_PATTERN.test(value) && (value.match(/\\d/g)?.length ?? 0) >= 4\n ? []\n : [{ severity: 'warning', message: `\"${value}\" may not be a valid phone number` }]\n default:\n return []\n }\n}\n\n/** Media field types — the ones whose value is a path/URL to an asset. */\nconst MEDIA_TYPES = new Set<FieldType>(['image', 'video', 'file'])\n\nexport function isMediaType(type: FieldType): boolean {\n return MEDIA_TYPES.has(type)\n}\n\n// ─── Field presentation ───\n\n/**\n * Resolve a field's label for a locale.\n *\n * Falls back in the order a reader would expect: the requested locale, the\n * default locale, any locale present, then the field name. A field with no\n * label resolves to its own name, which is what an editor shows today.\n */\nexport function resolveFieldLabel(\n fieldName: string,\n field: Pick<FieldDef, 'label'>,\n locale?: string,\n defaultLocale?: string,\n): string {\n const { label } = field\n if (typeof label === 'string') return label\n if (label && typeof label === 'object') {\n const byLocale = (locale && label[locale])\n ?? (defaultLocale && label[defaultLocale])\n ?? Object.values(label)[0]\n if (byLocale) return byLocale\n }\n return fieldName\n}\n\n/**\n * Field names in display order.\n *\n * Exported so every consumer orders identically instead of each reimplementing\n * the fallback. Ascending `order`; fields without one come last, alphabetically\n * among themselves, which is the behaviour a model that has no `order` already\n * has.\n */\nexport function orderedFieldNames(fields: Record<string, FieldDef> | undefined): string[] {\n if (!fields) return []\n return Object.keys(fields).toSorted((a, b) => {\n const oa = fields[a]?.order\n const ob = fields[b]?.order\n if (oa !== undefined && ob !== undefined && oa !== ob) return oa - ob\n if (oa !== undefined && ob === undefined) return -1\n if (oa === undefined && ob !== undefined) return 1\n return a.localeCompare(b, 'en')\n })\n}\n\n// ─── Title field ───\n\n/**\n * The reserved `title_field` value for dictionary models. A dictionary has no\n * fields — it is a flat key→string map — so there is nothing to point at. Its key\n * *is* the title, and this names that fact rather than leaving the property blank.\n * Only dictionaries may use it; on any other kind `title_field` must name a real field.\n */\nexport const DICTIONARY_TITLE_FIELD = 'key'\n\n/**\n * Field types whose value can stand in as a human-readable title.\n *\n * Narrower than \"types that hold a string\": `color`, `phone`, `icon`, `select`,\n * `date`, `datetime`, `relation` and the media types all store strings, but a row\n * labelled `#ff0000` or `f3a81c09d24e` is exactly the failure this contract exists\n * to prevent.\n */\nexport const TITLE_FIELD_TYPES = [\n 'string', 'text', 'slug', 'email', 'url', 'code', 'markdown', 'richtext',\n] as const satisfies readonly FieldType[]\n\nconst TITLE_FIELD_TYPE_SET = new Set<FieldType>(TITLE_FIELD_TYPES)\n\n/** Whether a field of this type may be named as a model's `title_field`. */\nexport function isTitleFieldType(type: FieldType): boolean {\n return TITLE_FIELD_TYPE_SET.has(type)\n}\n\n/**\n * What a `title_field` value points at. A plain name is a top-level field. A dotted\n * path `hero.heading` reaches one level into an `object` field: a page built from\n * sections is a singleton whose top level holds only section objects, and its title\n * is a section's heading — a copied top-level `title` would be a field no page prints.\n *\n * - `field`: the field the path names, and the object it sits in for a dotted path\n * - `missing`: the path names no field (`at` is the segment that failed)\n * - `not-object`: the first segment names a field that is not an `object`\n * - `too-deep`: more than one dot — a title reaches at most into one object\n */\nexport type TitleFieldTarget =\n | { kind: 'field', def: FieldDef, parent?: FieldDef }\n | { kind: 'missing', at: string }\n | { kind: 'not-object', at: string, type: FieldType }\n | { kind: 'too-deep' }\n\n/** Resolve a `title_field` path against a model's fields. A field named exactly `path` wins over splitting it. */\nexport function titleFieldTarget(fields: Record<string, FieldDef> | undefined, path: string): TitleFieldTarget {\n // A field whose own name holds a dot predates dotted paths; it keeps meaning itself.\n if (fields && Object.hasOwn(fields, path)) return { kind: 'field', def: fields[path]! }\n const segments = path.split('.')\n if (segments.length > 2) return { kind: 'too-deep' }\n const [head, leaf] = segments as [string, string | undefined]\n const def = fields && Object.hasOwn(fields, head) ? fields[head] : undefined\n if (!def) return { kind: 'missing', at: head }\n if (leaf === undefined) return { kind: 'field', def }\n if (def.type !== 'object') return { kind: 'not-object', at: head, type: def.type }\n const child = def.fields && Object.hasOwn(def.fields, leaf) ? def.fields[leaf] : undefined\n if (!child) return { kind: 'missing', at: path }\n return { kind: 'field', def: child, parent: def }\n}\n\n/** An entry's title value: `data[title_field]`, or one level into an object for a dotted path. */\nexport function titleFieldValue(data: Record<string, unknown> | null | undefined, path: string): unknown {\n if (data && Object.hasOwn(data, path)) return data[path]\n const [head, leaf, ...rest] = path.split('.')\n if (!data || head === undefined || rest.length > 0) return undefined\n const value = data[head]\n if (leaf === undefined) return value\n return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>)[leaf] : undefined\n}\n\n/** Extensions we can name a MIME type for. Deliberately small — this is a sniff. */\nconst EXTENSION_MIME: Record<string, string> = {\n jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',\n webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml', ico: 'image/x-icon',\n mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',\n pdf: 'application/pdf', zip: 'application/zip', json: 'application/json',\n csv: 'text/csv', txt: 'text/plain',\n}\n\n/**\n * Check a media value's file extension against an `accept` list.\n *\n * This is an approximation and says so: the value is a path or URL, so the real\n * MIME type is only knowable where the bytes are — the provider, at ingest\n * (`provider.ts:MediaProvider.ingest` owns that policy). An extension can lie, and\n * an unknown extension is not evidence of a violation. Hence: warning, never error,\n * and silence when we cannot tell.\n *\n * `accept` follows the HTML input syntax: `image/jpeg`, `image/*`, `.jpg`, comma-separated.\n */\nexport function validateAccept(value: string, accept: string): ValidationError[] {\n const path = value.split('?')[0]?.split('#')[0] ?? value\n const ext = path.split('.').pop()?.toLowerCase()\n if (!ext || ext === path.toLowerCase()) return []\n\n const mime = EXTENSION_MIME[ext]\n const patterns = accept.split(',').map(a => a.trim().toLowerCase()).filter(Boolean)\n if (patterns.length === 0) return []\n\n const matches = patterns.some((p) => {\n if (p.startsWith('.')) return p.slice(1) === ext\n if (p.endsWith('/*')) return mime ? mime.startsWith(p.slice(0, -1)) : false\n return mime === p\n })\n if (matches) return []\n\n // An extension we have no MIME for is not evidence of a violation.\n if (!mime && !patterns.some(p => p.startsWith('.'))) return []\n\n return [{\n severity: 'warning',\n message: `File extension \".${ext}\" does not match accept \"${accept}\" (extension check — the provider enforces the real MIME type at ingest)`,\n }]\n}\n\n/**\n * Validate a field value against its FieldDef schema.\n * Checks: required, type match, semantic type rules, min/max, pattern, select\n * options, and `accept` on media fields (by extension).\n * Does NOT check: secrets (use detectSecrets), uniqueness (stateful), relations (I/O),\n * or `maxSize` — byte length is not knowable from a stored path.\n */\nexport function validateFieldValue(value: unknown, fieldDef: FieldDef): ValidationError[] {\n const issues: ValidationError[] = []\n\n if (fieldDef.required && (value === undefined || value === null || value === '')) {\n issues.push({ severity: 'error', message: 'Required field is missing or empty' })\n return issues\n }\n\n if (value === undefined || value === null) return issues\n\n if (!fieldTypeMatches(value, fieldDef)) {\n issues.push({ severity: 'error', message: `Type mismatch: expected ${fieldDef.type}, got ${typeof value}` })\n return issues\n }\n\n issues.push(...validateSemanticType(value, fieldDef.type))\n\n if (fieldDef.type === 'relation' && isPolymorphicRef(value)) {\n const targets = relationTargets(fieldDef)\n if (!targets.includes(value.model)) {\n issues.push({ severity: 'error', message: `Relation target model \"${value.model}\" is not one of: ${targets.join(', ')}` })\n }\n }\n\n if (fieldDef.accept && isMediaType(fieldDef.type) && typeof value === 'string') {\n issues.push(...validateAccept(value, fieldDef.accept))\n }\n\n const minMaxErr = checkMinMax(value, fieldDef)\n if (minMaxErr) {\n issues.push({ severity: 'error', message: minMaxErr })\n }\n\n if (fieldDef.pattern && typeof value === 'string') {\n try {\n const regex = new RegExp(fieldDef.pattern)\n if (!regex.test(value)) {\n issues.push({ severity: 'error', message: `Value \"${value}\" does not match pattern /${fieldDef.pattern}/` })\n }\n } catch {\n issues.push({ severity: 'warning', message: `Invalid pattern regex: ${fieldDef.pattern}` })\n }\n }\n\n if (fieldDef.type === 'select' && fieldDef.options && typeof value === 'string') {\n if (!fieldDef.options.includes(value)) {\n issues.push({ severity: 'error', message: `Value \"${value}\" is not in allowed options: [${fieldDef.options.join(', ')}]` })\n }\n }\n\n return issues\n}\n\n// ─── Serialize: Pure Functions ───\n//\n// Implemented in `canonical.ts` and re-exported here: `execution.ts` hashes\n// plans with `canonicalStringify`, and this module re-exports `execution.ts`,\n// so the implementation has to sit outside the cycle.\n\nexport { canonicalStringify, sortKeys } from './canonical.js'\n\n/** Generate a 12-character hex entry ID */\nexport function generateEntryId(): string {\n const hex = '0123456789abcdef'\n let id = ''\n for (let i = 0; i < 12; i++) {\n id += hex[Math.floor(Math.random() * 16)]\n }\n return id\n}\n\n// ─── Reconcile: conflict id derivation ───\n\n/**\n * FNV-1a 64-bit over the input's UTF-8 code points, returned as 16 hex\n * chars. Deliberately dependency-free (no `node:crypto` — this package has\n * zero runtime imports and runs anywhere). Not cryptographic; the use case\n * is change detection, not tamper resistance.\n */\nexport function stableHash(input: string): string {\n const PRIME = 0x100000001B3n\n const MASK = 0xFFFFFFFFFFFFFFFFn\n let hash = 0xCBF29CE484222325n\n const bytes = new TextEncoder().encode(input)\n for (const byte of bytes) {\n hash ^= BigInt(byte)\n hash = (hash * PRIME) & MASK\n }\n return hash.toString(16).padStart(16, '0')\n}\n\n/**\n * Identity of a reconcile conflict: position AND values. Hashing the three\n * values into the id makes every resolution a compare-and-set — if any side\n * changes between the dry-run that produced the conflict and the apply that\n * carries the decision, the id no longer matches, the stale resolution is\n * dropped, and the conflict is re-reported with a fresh id. Values are\n * normalized through `sortKeys` first so semantically equal objects hash\n * equal regardless of key order; the positional array keeps absent and\n * null distinguishable per slot.\n */\nexport function conflictId(conflict: {\n path: string\n key?: string\n field?: string\n locale?: string\n base?: unknown\n ours?: unknown\n theirs?: unknown\n}): string {\n return stableHash(JSON.stringify([\n conflict.path,\n conflict.key ?? null,\n conflict.field ?? null,\n conflict.locale ?? null,\n sortKeys(conflict.base) ?? null,\n sortKeys(conflict.ours) ?? null,\n sortKeys(conflict.theirs) ?? null,\n ]))\n}\n\n// ─── Markdown Frontmatter ───\n\n/**\n * Decode the interior of a double-quoted scalar, or `undefined` when the text\n * is not one scalar after all — an unescaped `\"` inside it, or a backslash with\n * nothing after it.\n *\n * Returning `undefined` rather than guessing matters: `\"a\" and \"b\"` starts and\n * ends with a quote without being a quoted scalar, and slicing the ends off it\n * would silently corrupt the value.\n *\n * An unrecognised escape keeps its backslash instead of erroring. Our own\n * serializer only ever emits `\\\\`, `\\\"`, `\\n`, `\\r` and `\\t`, so round-tripping is\n * unaffected either way — but hand-written frontmatter says `\"C:\\Users\"` and\n * losing that to strictness would be a worse trade than keeping the bytes.\n */\nfunction decodeDoubleQuoted(inner: string): string | undefined {\n let out = ''\n for (let i = 0; i < inner.length; i += 1) {\n const char = inner[i]!\n if (char === '\"') return undefined\n if (char !== '\\\\') {\n out += char\n continue\n }\n const next = inner[i + 1]\n if (next === undefined) return undefined\n i += 1\n if (next === 'n') out += '\\n'\n else if (next === 'r') out += '\\r'\n else if (next === 't') out += '\\t'\n else if (next === '\\\\' || next === '\"' || next === '/') out += next\n else if (next === 'u' && /^[0-9a-fA-F]{4}$/.test(inner.slice(i + 1, i + 5))) {\n out += String.fromCharCode(parseInt(inner.slice(i + 1, i + 5), 16))\n i += 4\n } else out += `\\\\${next}`\n }\n return out\n}\n\n/** Decode a single-quoted scalar, where YAML escapes a quote by doubling it. */\nfunction decodeSingleQuoted(inner: string): string | undefined {\n let out = ''\n for (let i = 0; i < inner.length; i += 1) {\n const char = inner[i]!\n if (char !== \"'\") {\n out += char\n continue\n }\n if (inner[i + 1] !== \"'\") return undefined\n out += \"'\"\n i += 1\n }\n return out\n}\n\n/**\n * Read one frontmatter scalar: booleans, null, numbers, quoted strings with\n * their escapes decoded, or the text as written.\n *\n * Exported because it is a **contract**, not a utility. Two readers open these\n * documents — this package (through `parseMarkdownFrontmatter`, which\n * `@contentrain/mcp` re-exports) and `@contentrain/query`'s generator and Astro\n * loader. When each had its own copy they disagreed about what `\"He said\n * \\\"Hi\\\"\"` says, which means the generated client and the content engine\n * disagreed about the same file. One implementation, imported by both.\n */\nexport function parseFrontmatterScalar(raw: string): unknown {\n if (raw === 'true') return true\n if (raw === 'false') return false\n if (raw === 'null') return null\n if (/^-?\\d+$/.test(raw)) return parseInt(raw, 10)\n if (/^-?\\d+\\.\\d+$/.test(raw)) return parseFloat(raw)\n // Quoted scalars carry escapes. Stripping the quotes without decoding them is\n // how `He said \\\"Hi\\\"` came back with its backslashes, and how a value with a\n // backslash in it doubled them on every export.\n if (raw.length >= 2 && raw.startsWith('\"') && raw.endsWith('\"')) {\n return decodeDoubleQuoted(raw.slice(1, -1)) ?? raw\n }\n if (raw.length >= 2 && raw.startsWith(\"'\") && raw.endsWith(\"'\")) {\n return decodeSingleQuoted(raw.slice(1, -1)) ?? raw\n }\n return raw\n}\n\n/**\n * A scalar read as a string: quoting and escapes are resolved, but a value that\n * merely looks like a number or a boolean stays the text it was. Array items\n * use this, and so does any reader that knows from the model that a field is\n * string-typed — a SKU of `\"007\"` must not become the number 7.\n */\nexport function parseFrontmatterScalarString(raw: string): string {\n const value = parseFrontmatterScalar(raw)\n return typeof value === 'string' ? value : raw\n}\n\n/**\n * Split an inline array's interior on commas that are not inside a quoted\n * item — `[\"a, b\", c]` is two items, not three. Exported for the same reason\n * as {@link parseFrontmatterScalar}: both readers must split it the same way.\n */\nexport function splitFrontmatterList(inner: string): string[] {\n const items: string[] = []\n let current = ''\n let quote: '\"' | \"'\" | undefined\n for (let i = 0; i < inner.length; i += 1) {\n const char = inner[i]!\n if (quote) {\n if (char === '\\\\' && quote === '\"' && i + 1 < inner.length) {\n current += char + inner[i + 1]!\n i += 1\n continue\n }\n current += char\n if (char === quote) quote = undefined\n continue\n }\n if (char === '\"' || char === \"'\") {\n quote = char\n current += char\n continue\n }\n if (char === ',') {\n items.push(current)\n current = ''\n continue\n }\n current += char\n }\n items.push(current)\n return items\n}\n\nfunction yamlScalar(value: unknown): string {\n const str = String(value)\n // A newline, a tab or edge whitespace cannot survive a bare scalar: the\n // second line of a multi-line value was being written straight into the\n // frontmatter, where the reader saw a line that is not `key: value` and\n // skipped it — the value was silently truncated at its first newline. Edge\n // whitespace was lost to the reader's `trim()` for the same reason.\n if (str.includes(':') || str.includes('#') || str.includes('{') || str.includes('}')\n || str.includes('[') || str.includes(']') || str.includes('*') || str.includes('&')\n || str.includes('!') || str.includes('|') || str.includes('>') || str.includes(\"'\")\n || str.includes('\"') || str.includes('%') || str.includes('@') || str.includes('`')\n || str.includes('\\\\') || str.includes('\\n') || str.includes('\\r') || str.includes('\\t')\n || str.startsWith('-') || str.startsWith('?') || str !== str.trim()\n || str === ''\n // A string whose bare form would read back as something else. `'42'` was\n // written bare and returned as the number 42 — a slug, a version or a title\n // that happens to be digits changed type on every save. Asking the reader\n // is exact where a list of shapes drifts away from it.\n //\n // The type check is the other half. The old rule quoted anything spelled\n // `true`, so a real boolean was written as `\"true\"` and came back a string.\n // Only a *string* that would be misread needs the quotes; a boolean, a\n // number or a null is written bare precisely so it reads back as itself.\n // `yes`/`no` are not keywords to this reader but are to others, so a string\n // spelled that way is quoted for the benefit of whoever else opens the file.\n || (typeof value === 'string' && (parseFrontmatterScalar(str) !== str || str === 'yes' || str === 'no'))) {\n return `\"${str\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/\"/g, '\\\\\"')\n .replace(/\\n/g, '\\\\n')\n .replace(/\\r/g, '\\\\r')\n .replace(/\\t/g, '\\\\t')}\"`\n }\n return str\n}\n\nfunction serializeYamlValue(value: unknown, indent: number): string[] {\n const pad = ' '.repeat(indent)\n if (value === null || value === undefined) return []\n if (typeof value === 'object' && !Array.isArray(value)) {\n const lines: string[] = []\n for (const [k, v] of Object.entries(value as Record<string, unknown>)) {\n if (v === null || v === undefined) continue\n if (typeof v === 'object' && !Array.isArray(v)) {\n lines.push(`${pad}${k}:`)\n lines.push(...serializeYamlValue(v, indent + 1))\n } else if (Array.isArray(v)) {\n lines.push(`${pad}${k}:`)\n for (const item of v) {\n if (typeof item === 'object' && item !== null && !Array.isArray(item)) {\n const nested = serializeYamlValue(item, indent + 2)\n if (nested.length > 0) {\n lines.push(`${pad} - ${nested[0]!.trimStart()}`)\n lines.push(...nested.slice(1))\n }\n } else {\n lines.push(`${pad} - ${yamlScalar(item)}`)\n }\n }\n } else {\n lines.push(`${pad}${k}: ${yamlScalar(v)}`)\n }\n }\n return lines\n }\n if (Array.isArray(value)) {\n const lines: string[] = []\n for (const item of value) {\n if (typeof item === 'object' && item !== null && !Array.isArray(item)) {\n const nested = serializeYamlValue(item, indent + 1)\n if (nested.length > 0) {\n lines.push(`${pad}- ${nested[0]!.trimStart()}`)\n lines.push(...nested.slice(1))\n }\n } else {\n lines.push(`${pad}- ${yamlScalar(item)}`)\n }\n }\n return lines\n }\n return [`${pad}${yamlScalar(value)}`]\n}\n\nfunction serializeYamlFields(data: Record<string, unknown>): string[] {\n const lines: string[] = []\n for (const [key, value] of Object.entries(data)) {\n if (key === 'body') continue\n if (value === null || value === undefined) continue\n if (typeof value === 'object' && !Array.isArray(value)) {\n lines.push(`${key}:`)\n lines.push(...serializeYamlValue(value, 1))\n } else if (Array.isArray(value)) {\n // A bare `key:` is genuinely ambiguous — empty array, empty object, or\n // null — and two readers guessed it differently. Writing `[]` says which\n // one it is instead of asking anyone to infer it from the next line.\n if (value.length === 0) {\n lines.push(`${key}: []`)\n continue\n }\n lines.push(`${key}:`)\n for (const item of value) {\n if (typeof item === 'object' && item !== null && !Array.isArray(item)) {\n const nested = serializeYamlValue(item, 2)\n if (nested.length > 0) {\n lines.push(` - ${nested[0]!.trimStart()}`)\n lines.push(...nested.slice(1))\n }\n } else {\n lines.push(` - ${yamlScalar(item)}`)\n }\n }\n } else {\n lines.push(`${key}: ${yamlScalar(value)}`)\n }\n }\n return lines\n}\n\n/** Parse markdown frontmatter — extracts YAML frontmatter and body from a markdown string */\nexport function parseMarkdownFrontmatter(content: string): { frontmatter: Record<string, unknown>; body: string } {\n const normalized = content.replace(/\\r\\n/g, '\\n')\n const match = normalized.match(/^---\\n([\\s\\S]*?)\\n---\\n?([\\s\\S]*)$/)\n if (!match) return { frontmatter: {}, body: normalized }\n\n const frontmatterStr = match[1]!\n const body = match[2]!.trim()\n const frontmatter: Record<string, unknown> = {}\n\n let currentKey: string | null = null\n let currentArray: string[] | null = null\n\n for (const line of frontmatterStr.split('\\n')) {\n if (/^\\s+-\\s+/.test(line) && currentKey) {\n const value = parseFrontmatterScalarString(line.replace(/^\\s+-\\s+/, '').trim())\n if (!currentArray) currentArray = []\n currentArray.push(value)\n continue\n }\n\n if (currentKey && currentArray) {\n frontmatter[currentKey] = currentArray\n currentKey = null\n currentArray = null\n }\n\n const kvMatch = line.match(/^([\\w][\\w.-]*)\\s*:\\s*(.*)$/)\n if (!kvMatch) continue\n\n const key = kvMatch[1]!\n const rawValue = kvMatch[2]!.trim()\n\n if (rawValue === '') {\n currentKey = key\n currentArray = []\n continue\n }\n\n if (rawValue.startsWith('[') && rawValue.endsWith(']')) {\n const inner = rawValue.slice(1, -1).trim()\n frontmatter[key] = inner === '' ? [] : splitFrontmatterList(inner).map(item => parseFrontmatterScalarString(item.trim()))\n continue\n }\n\n frontmatter[key] = parseFrontmatterScalar(rawValue)\n }\n\n if (currentKey && currentArray) {\n frontmatter[currentKey] = currentArray\n }\n\n return { frontmatter, body }\n}\n\n/** Serialize data + body into markdown frontmatter format */\nexport function serializeMarkdownFrontmatter(data: Record<string, unknown>, body: string): string {\n const trimmedBody = body.trimStart()\n if (trimmedBody.startsWith('---')) {\n const endIdx = trimmedBody.indexOf('---', 3)\n if (endIdx > 0) {\n const bodyFmContent = trimmedBody.slice(3, endIdx).trim()\n const afterFm = trimmedBody.slice(endIdx + 3)\n\n const lines: string[] = ['---']\n lines.push(...serializeYamlFields(data))\n if (bodyFmContent) {\n const modelKeys = new Set(Object.keys(data))\n for (const line of bodyFmContent.split('\\n')) {\n if (line.length > 0 && line[0] !== ' ' && line[0] !== '-') {\n const colonIdx = line.indexOf(':')\n const lineKey = colonIdx > 0 ? line.slice(0, colonIdx).trim() : ''\n if (lineKey && modelKeys.has(lineKey)) continue\n }\n lines.push(line)\n }\n }\n lines.push('---')\n if (afterFm.trim()) {\n lines.push(afterFm.trimStart())\n }\n lines.push('')\n return lines.join('\\n')\n }\n }\n\n const lines: string[] = ['---']\n lines.push(...serializeYamlFields(data))\n lines.push('---')\n lines.push('')\n if (body) {\n lines.push(body)\n lines.push('')\n }\n return lines.join('\\n')\n}\n\n// ─── Migration contracts ───\n\nexport * from './migration.js'\n\n// ─── Execution, approval and source-delta contracts ───\n\nexport * from './execution.js'\n\n// ─── Approval evaluator ───\n//\n// The decision procedure over the contracts above: risk + scope + policy →\n// what must be approved, and whether it has been. Pure and dependency-free, so\n// Studio, a CLI and a test all reach the same answer.\n\nexport * from './approval.js'\n\n// ─── Migrate → Studio claim ───\n//\n// The signed hand-off from a paid Migrate order to a Studio trial: payload,\n// constants and a pure validator. Signing and verification (EdDSA) live in\n// the two apps; both bind to this one shape.\n\nexport * from './migrate-claim.js'\n\n// ─── Project plan ───\n//\n// Migrate v3: facts → one plan (models, components, routes, bindings) → a\n// deterministic writer. Content is bound, never copied.\n\nexport * from './project-plan.js'\n\n// ─── Repository provider contracts ───\n//\n// Provider-agnostic engine contracts used by @contentrain/mcp. Exposed from\n// @contentrain/types so third-party tools can implement a custom\n// RepoProvider without taking a dependency on @contentrain/mcp.\nexport type {\n ApplyPlanInput,\n BaseAdvance,\n Branch,\n Commit,\n CommitAuthor,\n ConflictCode,\n ConflictItem,\n ConflictResolution,\n ContentReadSource,\n FileChange,\n FileDiff,\n MediaAsset,\n MediaIngestInput,\n MediaListOptions,\n MediaListResult,\n MediaProvider,\n MediaUpdateInput,\n MergeResult,\n ProviderCapabilities,\n RemotePush,\n RepoProvider,\n RepoReader,\n RepoWriter,\n WriteReadiness,\n} from './provider.js'\nexport { LOCAL_CAPABILITIES } from './provider.js'\n\n// Implemented in `media.ts` and re-exported here: both `@contentrain/mcp`\n// (write-time normalization) and `@contentrain/query` (read-time resolution)\n// share this single rewrite engine, so the rules can only be defined once.\nexport {\n isStoredMediaPath,\n rewriteEntryMedia,\n rewriteFieldMedia,\n rewriteMarkdownMedia,\n rewriteMediaUrl,\n toDeliveryUrl,\n} from './media.js'\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/canonical.ts","../src/migration.ts","../src/execution.ts","../src/approval.ts","../src/migrate-claim.ts","../src/project-plan.ts","../src/provider.ts","../src/media.ts","../src/index.ts"],"sourcesContent":["// ─── Canonical Serialization ───\n//\n// Deterministic JSON: the same data must produce the same bytes on every\n// machine, every run. Stable git diffs depend on it, and so does `plan_hash`\n// in `execution.ts` — which is why these two functions live in their own\n// module rather than in `index.ts`. `execution.ts` needs them and `index.ts`\n// re-exports `execution.ts`; keeping them here is what stops that from being\n// an import cycle.\n\n/** Recursively sort object keys — respects optional fieldOrder for top-level keys */\nexport function sortKeys(obj: unknown, fieldOrder?: readonly string[]): unknown {\n if (obj === null || obj === undefined) return undefined\n if (Array.isArray(obj)) return obj.map(item => sortKeys(item, fieldOrder))\n if (typeof obj !== 'object') return obj\n\n const record = obj as Record<string, unknown>\n const sorted: Record<string, unknown> = {}\n\n const keys = fieldOrder\n ? [...new Set([...fieldOrder, ...Object.keys(record).toSorted()])]\n : Object.keys(record).toSorted()\n\n for (const key of keys) {\n if (!(key in record)) continue\n const val = record[key]\n if (val === null || val === undefined) continue\n sorted[key] = sortKeys(val)\n }\n\n return sorted\n}\n\n/** Canonical JSON serialization — deterministic output for stable git diffs */\nexport function canonicalStringify(data: unknown, fieldOrder?: readonly string[]): string {\n const sorted = sortKeys(data, fieldOrder)\n return `${JSON.stringify(sorted, null, 2)}\\n`\n}\n","// ─── Migration Contracts ───\n//\n// Shared, MIT-licensed shapes for the WordPress → static-site migration pipeline.\n// Producers: the WordPress Bridge plugin (GPL, separate repo), REST/WXR importers.\n// Consumers: the migration engine, Studio, and the open Astro emitter.\n//\n// Every shape here is plain JSON — snake_case keys, no class instances, no\n// functions — because these documents cross process, repository, and license\n// boundaries. A GPL plugin writes RawIR; a proprietary service reads it; an MIT\n// emitter consumes ProjectIR. The contract is the only thing they share, so it\n// lives in the one package all of them may depend on.\n\n/**\n * Version stamped into every migration document (`version` field on each root).\n * The four contracts evolve together with this package; a reader that sees a\n * higher version than it knows should refuse rather than guess.\n */\nexport const MIGRATION_CONTRACT_VERSION = 1\n\n// ─── Source access ───\n\n/**\n * How the WordPress site's data was reached, ordered from least to most\n * complete. Field coverage rises with each rung — measured in August 2026 at\n * roughly 34% for public REST, 57% with an Application Password and 82% from a\n * WXR export; the Bridge plugin reads WordPress from inside and is meant to\n * reach every field — which is why provenance is recorded on every RawIR:\n * two documents for the same site are only comparable if their rungs are.\n */\nexport type SourceAccessKind = 'rest_public' | 'rest_auth' | 'wxr' | 'bridge'\n\n/** The ladder in ascending completeness order. */\nexport const SOURCE_ACCESS_LADDER = [\n 'rest_public',\n 'rest_auth',\n 'wxr',\n 'bridge',\n] as const satisfies readonly SourceAccessKind[]\n\n// ─── RawIR ───\n//\n// Source-faithful extraction of a WordPress site: what the source said, before\n// any interpretation. Unresolved references are kept and *marked* (`resolved`\n// flags) instead of dropped — deciding what a broken reference means is the\n// consumer's job, not the extractor's.\n\nexport interface RawProvenance {\n kind: SourceAccessKind\n /** ISO 8601 UTC time the extraction ran. */\n fetched_at?: string\n /** Name/version of the producing tool (e.g. \"wordpress-bridge/1.0\"). */\n tool?: string\n}\n\nexport interface RawSite {\n url: string\n title?: string\n description?: string\n /** WXR distinguishes the WP install URL from the public site URL. */\n base_site_url?: string\n base_blog_url?: string\n /** Site language as reported (e.g. \"en-US\"); multilingual detail lives in `language_pairs`. */\n language?: string | null\n /** Generator string (WordPress version) when the source exposed it. */\n generator?: string | null\n /** WXR export timestamp, when the source was a WXR file. */\n export_date?: string | null\n wxr_version?: string | null\n /** The site's time zone (`timezone_string`, IANA, e.g. `Europe/Istanbul`), when it names one. */\n timezone?: string | null\n /** The site's fixed UTC offset in hours (`gmt_offset`), for a site set to an offset rather than a zone. */\n gmt_offset?: number | null\n}\n\nexport interface RawAuthor {\n id: number | null\n login: string\n email?: string | null\n display_name: string\n first_name?: string | null\n last_name?: string | null\n}\n\nexport interface RawTerm {\n id: number | null\n taxonomy: string\n slug: string\n name: string\n /** Parent term slug within the same taxonomy, when hierarchical. */\n parent?: string | null\n /** Whether `parent` names a term present in this document. */\n parent_resolved?: boolean | null\n description?: string\n}\n\n/** A post's reference to a term — kept even when the term itself is absent. */\nexport interface RawTermRef {\n taxonomy: string\n slug: string\n name: string\n /** Whether the referenced term exists in this document's `terms`. */\n resolved: boolean\n}\n\n/** An ACF field value paired with its field-definition key (`field_…`). */\nexport interface RawAcfValue {\n value: unknown\n /** ACF field key (`field_…`), where the source carries it (WXR meta, Bridge). REST does not. */\n field_key?: string\n /**\n * ACF field type as the source states it (`text`, `repeater`, `relationship`, …): SCF's REST\n * `<name>_source.type`, a Bridge field schema. Absent = the reader infers the type from the value.\n */\n type?: string\n /** Field label as the source states it. */\n label?: string\n}\n\n/**\n * One ACF Options Page (bridge rung): site-wide fields that belong to no post.\n * Values are the default language's; a translation plugin's per-language copy\n * is not read. Secret fields are left out by the exporter, as on a post.\n */\nexport interface RawAcfOptionsPage {\n /** The page's menu slug, sanitized (`site-settings`). */\n slug: string\n /** The page title as the admin menu shows it. */\n title: string\n /** ACF storage id the values are read from — `options` unless the page sets its own. */\n post_id: string\n /** Field name → value, keyed like `RawPost.acf`. */\n fields: Record<string, RawAcfValue>\n}\n\nexport interface RawPost {\n id: number\n /** WordPress post type — \"post\", \"page\", or a custom post type slug. */\n type: string\n /** WordPress status verbatim (\"publish\", \"draft\", \"future\", \"pending\", …). */\n status: string\n slug: string\n title: string\n /** Public permalink, when known. Permalink *structure* is a site setting; the link is the fact. */\n link?: string | null\n guid?: string | null\n /** Author login; resolve against `RawIR.authors`. */\n author: string | null\n /** ISO 8601 UTC, null when the source carried no usable date. */\n date: string | null\n modified: string | null\n /** Rendered/exported HTML body, untransformed. */\n content: string\n excerpt: string\n parent?: number | null\n menu_order?: number\n sticky?: boolean\n /**\n * Set when the post is password-protected. Importers write the marker\n * `'[protected]'`, never the password itself; treat any non-empty value as\n * \"protected\".\n */\n password?: string | null\n comment_status?: string | null\n ping_status?: string | null\n terms: RawTermRef[]\n /**\n * Post meta, PHP-serialized values already decoded where possible.\n * Core WP keys (underscore-prefixed) are included: which keys matter is a\n * downstream decision.\n */\n meta: Record<string, unknown>\n /** Meta keys whose values were PHP-serialized in the source (decoded above). */\n serialized_keys?: string[]\n /** ACF fields recovered by pairing `foo` with `_foo = \"field_…\"`. */\n acf?: Record<string, RawAcfValue>\n /**\n * Language of this post as the multilingual plugin reports it — Polylang's\n * `lang` slug (`tr`), WPML's `wpml_current_locale` (`tr_TR`), or the WXR\n * `language` taxonomy term. Absent on monolingual sites. Which posts are\n * translations of each other is `RawIR.language_pairs`, not this field.\n */\n lang?: string | null\n}\n\nexport interface RawAttachment {\n id: number\n title: string\n slug: string\n /** Original file URL. Downloading/re-hosting is a consumer concern. */\n url: string | null\n alt?: string\n caption?: string\n description?: string\n /** Relative upload path (`_wp_attached_file`), when present. */\n file?: string | null\n /** `_wp_attachment_metadata` verbatim (sizes, EXIF, …). */\n image_meta?: unknown\n mime?: string | null\n parent?: number | null\n parent_resolved?: boolean | null\n /**\n * The attachment's own page (`/post-name/image-name/`, `/?attachment_id=12`), which WordPress serves for every\n * upload and search engines may have indexed. Equal to `url` where the site sends attachment pages to the file.\n */\n link?: string | null\n author?: string | null\n date?: string | null\n status?: string\n meta?: Record<string, unknown>\n}\n\n/** Where a menu item points. `resolved` says whether the target exists in this document. */\nexport type RawMenuTarget =\n | { kind: 'url'; url: string; resolved: true }\n | { kind: 'post'; post_type: string; id: number | null; slug: string | null; resolved: boolean }\n | { kind: 'term'; taxonomy: string; id: number | null; slug: string | null; resolved: boolean }\n | { kind: 'archive'; post_type: string; resolved: true }\n | { kind: 'unknown'; resolved: false }\n\nexport interface RawMenuItem {\n /** `nav_menu_item` post id. Negative for an item of a block navigation, which has no id of its own. */\n id: number\n title: string\n order?: number\n /** Parent menu-item id for nested menus. */\n parent?: number | null\n parent_unresolved?: boolean\n url?: string | null\n target: RawMenuTarget\n /** `target` attribute for the rendered link (e.g. \"_blank\"). */\n target_attr?: string | null\n classes?: string[]\n description?: string\n status?: string\n}\n\nexport interface RawMenu {\n /** Menu term id; for a block theme's navigation, the `wp_navigation` post id. */\n id: number | null\n slug: string\n name: string\n items: RawMenuItem[]\n /**\n * Where the site shows it: the theme locations a classic menu is assigned to (`primary`, `footer`,\n * `menu-1`, …), or the template-part areas (`header`, `footer`) whose navigation block shows a\n * block theme's navigation. Absent when the producer does not know.\n */\n locations?: string[]\n}\n\nexport interface RawComment {\n id: number\n /** Post id this comment belongs to. */\n post: number\n post_type?: string\n parent?: number | null\n parent_resolved?: boolean | null\n author: string\n email?: string | null\n url?: string | null\n /**\n * ISO 8601 UTC. Producers MUST normalize to UTC — comment import fidelity\n * (\"zero record and parent loss\") includes `created_at`, and a site-local\n * date silently corrupts it. When normalization is impossible, ship the\n * source's GMT column in `date_gmt` and leave `date` null.\n */\n date: string | null\n date_gmt?: string | null\n content: string\n /**\n * WordPress approval flag. Fixed vocabulary: `'1'` (approved), `'0'`\n * (pending), `'spam'`, `'trash'` — consumers map these; unknown strings\n * pass through for forward compatibility, never dropped.\n */\n approved?: '1' | '0' | 'spam' | 'trash' | (string & {})\n type?: string\n user_id?: number | null\n meta?: Record<string, unknown>\n}\n\n/**\n * How a redirect's `from` is compared with a request path. Only `url` without\n * `regex` is a plain one-to-one mapping; the others are patterns, and turning\n * one into a literal `from` produces the wrong redirect.\n */\nexport type RawRedirectMatch = 'url' | 'regex' | 'start' | 'contains' | 'end'\n\n/**\n * How a rule treats the request's query string (the Redirection plugin's own three modes):\n * `exact`: the query must match `from`'s; `ignore`: any query matches, and it is not carried to `to`;\n * `pass`: any query matches, and it is carried to `to`.\n */\nexport type RawRedirectQuery = 'exact' | 'ignore' | 'pass'\n\n/** A redirect rule the live site serves (e.g. from the Redirection plugin — visible from the authenticated rung up). */\nexport interface RawRedirect {\n from: string\n /** Where the rule leads; empty for a rule that answers \"gone\" (410, 451), which leads nowhere. */\n to: string\n /** The answer's code: a 3xx move, or 410/451 for an address the site says is gone. Absent means 301. */\n status?: number\n /** Which plugin/table produced the rule. */\n source?: string\n /** Stable id within the export, `<source>:<source-specific id>`. */\n id?: string\n /** How `from` matches; absent means `url`. */\n match?: RawRedirectMatch\n /** `from` is a regular expression in the source's own dialect. */\n regex?: boolean\n /** The source's own module/group serving it, when it has one (e.g. Redirection's \"Apache\"). */\n served_by?: string\n /** An adjustment the extractor made, stated rather than hidden (e.g. a non-redirect status read as 301). */\n status_note?: string\n /** How the request's query string is matched and carried; absent: the source did not say. */\n query?: RawRedirectQuery\n /** `from` matches without regard to letter case. */\n case_insensitive?: boolean\n /** `/a` and `/a/` are one address for this rule (`ignore`) or two (`exact`); absent: the source did not say. */\n trailing_slash?: 'exact' | 'ignore'\n}\n\n/**\n * A rule a source holds that the site does not serve as a plain redirect, with\n * the reason. Every rule lands in `RawIR.redirects` or here, so the two\n * together account for the source's whole table.\n */\nexport interface RawRedirectExcluded extends Omit<Partial<RawRedirect>, 'match'> {\n id: string\n source: string\n /** The source's own match type — may be a condition (`login`, `referrer`, …) rather than a {@link RawRedirectMatch}. */\n match?: RawRedirectMatch | (string & {})\n reason:\n | 'disabled'\n | 'source-inactive'\n | 'slug-reused'\n | 'no-slug-address'\n | `not-a-redirect:${string}`\n | `conditional-match:${string}`\n /** The condition a conditional rule depends on, verbatim. */\n condition?: unknown\n}\n\n// ─── SEO ───\n\nexport const SEO_PROVIDERS = ['yoast', 'rank_math', 'aioseo', 'seopress'] as const\nexport type SeoProvider = (typeof SEO_PROVIDERS)[number]\n\n/** Whether a provider runs on the site, and its version. */\nexport interface SeoProviderStatus {\n status: 'active' | 'inactive-with-data' | 'absent'\n version?: string\n}\n\n/** One page's SEO as one provider holds it. */\nexport interface RawSeoEntry {\n /** true: the running plugin rendered these values; false: stored values and templates only. */\n resolved?: boolean\n title?: string\n description?: string\n canonical?: string\n robots?: { index?: 'index' | 'noindex', follow?: 'follow' | 'nofollow', advanced?: string[] }\n /** The directives the page actually carries, after WordPress core and the plugin reconcile them. */\n robots_served?: string[]\n open_graph?: { title?: string, description?: string, type?: string, url?: string, image?: string, site_name?: string }\n twitter?: { card?: string, title?: string, description?: string, image?: string }\n focus_keyword?: string\n /** Schema.org types, and the JSON-LD graph when the plugin rendered it. */\n schema?: { types: string[], graph?: unknown }\n /** What the plugin stored for this page (templates such as `%%title%% %%sep%%`), secrets removed. */\n stored?: Record<string, unknown>\n /**\n * The page's SEO as the exporter rendered the plugin's templates itself,\n * for a plugin that is not the one serving the live head (`resolved` is\n * not true): final text, no template tokens. A template variable it could\n * not render is taken out of the string (and the separators it leaves\n * tidied) and named in `unresolved`.\n */\n rendered?: {\n title?: string\n description?: string\n canonical?: string\n robots?: { index?: 'index' | 'noindex', follow?: 'follow' | 'nofollow' }\n /** `image_width` / `image_height` when the image is a local attachment of known size. */\n open_graph?: { title?: string, description?: string, image?: string, image_width?: number, image_height?: number }\n twitter?: { title?: string, description?: string, image?: string, image_width?: number, image_height?: number }\n /** Schema nodes the plugin stored for the page (Rank Math), their variables rendered, the plugin's own `metadata` key removed. */\n schema?: { graph: object[] }\n }\n /** Who rendered `rendered`, e.g. `bridge`. */\n rendered_by?: string\n /** Where each rendered value's template came from: the page's own, its post type's, or the plugin default. */\n template_source?: Partial<Record<'title' | 'description' | 'robots', 'post' | 'post_type' | 'default'>>\n /** Template tokens `rendered` could not resolve (e.g. `%customfield(x)%`) — removed from its strings, listed for a person to see. */\n unresolved?: string[]\n}\n\n/** A provider's site-wide settings. */\nexport interface RawSeoSettings {\n separator?: string | null\n /** Keyed by context: `post`, `page`, `home`, `author`, `archive`, `tax:<taxonomy>`, `ptarchive-<type>`, … */\n title_templates: Record<string, string>\n description_templates: Record<string, string>\n noindex?: Record<string, boolean>\n social?: Record<string, unknown>\n /** Site verification codes — public meta values. */\n verification?: Record<string, string>\n /** Option name → cleaned value, verbatim otherwise. */\n raw: Record<string, unknown>\n}\n\n/**\n * The site's SEO layer: which plugin serves the head, its settings, and each\n * page's values. `status: 'none'` is an answer — no SEO plugin, and WordPress\n * core renders only the title — not a missing export.\n */\nexport interface RawSeo {\n /** Producer's format identifier, e.g. `contentrain-bridge-seo@1`. */\n format?: string\n status: 'present' | 'none'\n /** Whose output the live site serves. */\n serving: SeoProvider | 'wordpress-core'\n /** SEOPress is optional here: exports made before it was covered do not name it. */\n providers: Record<Exclude<SeoProvider, 'seopress'>, SeoProviderStatus> & Partial<Record<'seopress', SeoProviderStatus>>\n settings: Partial<Record<SeoProvider, RawSeoSettings>>\n /** `post:<id>` or `term:<taxonomy>:<id>` → one block per provider that has data for it. */\n entries: Record<string, Partial<Record<SeoProvider, RawSeoEntry>>>\n /**\n * The home page's SEO, one block per provider, when the home page lists\n * posts: it is no record, so it has no `post:<id>` entry. A static front\n * page is its page's `post:<id>`.\n */\n home?: Partial<Record<SeoProvider, RawSeoEntry>>\n /** Values deliberately not exported (e.g. a sensitive key), by address. */\n excluded?: { source: string, reason: string }[]\n}\n\n// ─── Routing ───\n\n/** A page WordPress routes to itself (front page, posts page). */\nexport interface RawRoutedPage {\n id: number\n slug: string\n /** Site-root-relative address. */\n path: string\n}\n\n/**\n * The site's URL rules — what `RawPost.link` holds as facts, stated as the\n * structure that produced them. Permastructs are site-root-relative with\n * `front` already applied where `with_front` asks for it.\n */\nexport interface RawRouting {\n /** Producer's format identifier, e.g. `contentrain-bridge-routing@1`. */\n format?: string\n /** The site's home URL. */\n home?: string\n permalink_structure: string\n /** Plain `?p=` permalinks — no structure at all. */\n plain: boolean\n trailing_slash: boolean\n front: string\n category_base: string\n tag_base: string\n pagination_base: string\n author_base: string\n search_base: string\n feed_base?: string\n comments_pagination_base?: string\n author_structure?: string | null\n date_structure?: string | null\n page_structure?: string | null\n /** `posts` or `page`. */\n show_on_front: string\n page_on_front?: RawRoutedPage | null\n page_for_posts?: RawRoutedPage | null\n posts_per_page: number\n post_types: {\n name: string\n hierarchical: boolean\n rewrite: { slug?: string, with_front?: boolean, feeds?: boolean, pages?: boolean } | false\n permastruct: string | null\n has_archive: boolean\n archive_path: string | null\n query_var: string | false\n }[]\n taxonomies: {\n name: string\n object_types: string[]\n hierarchical: boolean\n rewrite: { slug?: string, with_front?: boolean, hierarchical?: boolean } | false\n permastruct: string | null\n }[]\n}\n\n// ─── Hardcoded text ───\n//\n// Interface text that lives outside the content tables — theme templates,\n// scripts, widgets, menus, options, Customizer mods, and what the rendered\n// pages show. Every piece found gets exactly one outcome: transferred to a\n// named target, excluded with a reason, or — for a source that could not be\n// read — an error. Nothing is dropped silently.\n\n/** Where a candidate was found. */\nexport const TEXT_CANDIDATE_KINDS = [\n 'php-gettext',\n 'php-html',\n 'php-echo',\n 'html',\n 'js',\n 'widget',\n 'menu',\n 'option',\n 'customizer',\n 'render',\n] as const\n\nexport type RawTextCandidateKind = (typeof TEXT_CANDIDATE_KINDS)[number]\n\n/** Why a candidate is not transferred. `RawTextCandidate.reason` joins one or more with `,`. */\nexport const TEXT_EXCLUDE_REASONS = [\n 'empty',\n 'too-long',\n 'secret',\n 'code',\n 'url',\n 'number',\n 'no-letters',\n 'placeholder-only',\n 'dynamic',\n 'not-text',\n 'content',\n 'rendered-from-source',\n] as const\n\nexport type RawTextExcludeReason = (typeof TEXT_EXCLUDE_REASONS)[number]\n\n/** Where a transferred candidate lands in the store. */\nexport type RawTextTarget =\n | 'dictionary:ui-strings'\n | `theme-settings.${string}`\n | 'site.title'\n | 'site.description'\n | 'content:wp-menu-items'\n\n/** One place a candidate's text was found. */\nexport interface RawTextOccurrence {\n kind: RawTextCandidateKind\n /** A file path relative to `wp-content`, `render:<state>`, `nav_menu_item#<id>`, … */\n source: string\n /** 1-based line in a file; `0` for a source without lines (render, menu, option). */\n line: number\n}\n\n/**\n * One piece of interface text in one place. Candidates merge only when text,\n * locale and context are all equal: \"Read more\" in an `a` and in a `button`\n * are two candidates, and so are `_x('Post', 'noun')` and `_x('Post', 'verb')`.\n */\nexport interface RawTextCandidate {\n /** `sha256(text \\0 locale \\0 context)`, first 20 hex characters. */\n id: string\n /** The text; `[redacted]` when `reason` includes `secret`. */\n value: string\n locale: string\n /**\n * Where the text sits: an HTML tag (`a`, `input@placeholder`),\n * `gettext[:<ctx>][:plural]`, `js`, `widget:<tag>` / `widget:title`, `menu`,\n * `option:<name>`, `customizer:<mod>`, or for rendered text\n * `<landmark>><tag>` (`footer>p`, `nav>nav@aria-label`).\n */\n context: string\n kind: RawTextCandidateKind\n /** The first occurrence's `source` and `line`. */\n source: string\n line: number\n /** Every place the text was found, in the producer's listing order. */\n occurrences: RawTextOccurrence[]\n outcome: 'transfer' | 'exclude'\n /** Set exactly when `outcome` is `transfer`. */\n target?: RawTextTarget\n /** Set exactly when `outcome` is `exclude`: one or more `RawTextExcludeReason`s joined with `,`. */\n reason?: string\n /**\n * With `rendered-from-source`: ids of the source candidates this page text\n * came from. Page text found in source is excluded here and transferred\n * there, so the same words never land twice and contexts are not merged.\n */\n related?: string[]\n /**\n * The store key: `<group>.<context>.<words>-<sha256(text \\0 context)[0:6]>`,\n * or `theme-settings.<mod>` for a Customizer value. A function of text and\n * context only — moving a string to another file changes no key. A\n * collision fails the export rather than merging two texts.\n */\n key: string\n decision: 'review' | 'include' | 'exclude'\n}\n\n/**\n * Every piece of interface text a site shows, each with one outcome. Totals\n * close: `by_outcome.transfer + by_outcome.exclude = candidates`,\n * `by_outcome.error = errors.length`, and `occurrences` counts every\n * occurrence the scanners found — listed ones plus those past the per-file\n * listing cap, which are counted by reason instead.\n */\nexport interface RawHardcodedText {\n /** Producer's format identifier, e.g. `contentrain-bridge-hardcoded-text@1`. */\n format: string\n candidates: RawTextCandidate[]\n /**\n * Sources that could not be read — an outcome, not a silent gap. Reasons:\n * `source-over-2MiB`, `source-unreadable`, `render-fetch-failed: <code>`,\n * `render-http-<status>`, `render-no-body`.\n */\n errors: { source: string, reason: string }[]\n totals: {\n occurrences: number\n occurrences_listed: number\n /** Occurrences past the listing cap: excluded and counted, not listed. */\n unlisted_excluded: number\n unlisted_by_reason: Record<string, number>\n candidates: number\n by_outcome: { transfer: number, exclude: number, error: number }\n sources: { files: number, settings: number, render_states: number }\n }\n}\n\n// ─── Integrations ───\n//\n// Outside services the site is connected to — analytics, CRM, newsletter,\n// captcha, CDN, embeds. A static site cannot carry these connections over:\n// each one with an account must be connected again, with the new site's own\n// credentials. Credentials never leave WordPress; only whether one is set does.\n\nexport const INTEGRATION_CATEGORIES = ['analytics', 'crm', 'newsletter', 'ads', 'comments', 'captcha', 'cdn', 'other'] as const\n\nexport type IntegrationCategory = (typeof INTEGRATION_CATEGORIES)[number]\n\nexport const INTEGRATION_EVIDENCE_KINDS = ['plugin', 'option-key', 'script-domain', 'form-config', 'embed-domain'] as const\n\nexport type IntegrationEvidenceKind = (typeof INTEGRATION_EVIDENCE_KINDS)[number]\n\n/** One outside service the site uses, and why we believe it does. */\nexport interface RawIntegration {\n /** Catalog id: `mailchimp`, `hubspot`, `google-tag-manager`, `jetpack-stats`, `embed-youtube`, … */\n service: string\n /** Display name. */\n name: string\n category: IntegrationCategory\n /**\n * Never a value. `detail` names a plugin and its version, a setting by name\n * (`mc4wp (api_key)`), a script host and where it was seen\n * (`static.hotjar.com @ render:home`), a form's wiring, or an embed host.\n */\n evidence: { kind: IntegrationEvidenceKind, detail: string }[]\n /** An account must be connected again on the new site; `false` for an embed, which has no account. */\n reconnect_required: boolean\n /** A credential is configured on WordPress. Only this boolean is exported, never the value. */\n secret_present: boolean\n /** What to do, in words for the person receiving the site. */\n notes: string\n}\n\n/**\n * The document a bridge writes (`bridge/integrations.json`). `RawIR.integrations`\n * carries its `services`. An empty `services` with `scanned` beside it is an\n * answer — the site uses no outside service we recognise — not a missing scan.\n */\nexport interface RawIntegrationScan {\n /** Producer's format identifier, e.g. `contentrain-bridge-integrations@1`. */\n format: string\n /** How much was looked at: option and post counts, active plugins, script sources, and whether the home page was rendered. */\n scanned: { options: number, plugins: number, posts: number, rendered_home: boolean, script_sources: number }\n services: RawIntegration[]\n totals: { services: number, reconnect_required: number }\n}\n\n/**\n * The intake / handoff issue for services that must be connected again. Raised\n * once, listing every `RawIntegration` with `reconnect_required: true`; not\n * raised when there are none.\n */\nexport interface IntegrationReconnectRequiredIssue {\n code: 'integration_reconnect_required'\n services: Pick<RawIntegration, 'service' | 'name' | 'category' | 'secret_present'>[]\n}\n\n/**\n * Translation grouping for multilingual sites. The REST rungs read it from\n * Polylang's `translations` / WPML's `wpml_translations` post fields, WXR from\n * the `post_translations` taxonomy; the bridge rung from the plugin tables.\n * One pair per group; `translations` includes the post itself.\n */\nexport interface RawLanguagePair {\n post: number\n /** locale → post id of the translation. */\n translations: Record<string, number>\n}\n\n/**\n * The complete raw extraction of one WordPress site at one access rung.\n * This is the boundary document between extraction (Bridge/importers) and\n * everything downstream — content conversion, capability analysis, migration.\n */\nexport interface RawIR {\n version: number\n provenance: RawProvenance\n site: RawSite\n authors: RawAuthor[]\n terms: RawTerm[]\n posts: RawPost[]\n attachments: RawAttachment[]\n menus?: RawMenu[]\n comments?: RawComment[]\n redirects?: RawRedirect[]\n /** Rules the sources hold that the site does not serve as plain redirects. */\n redirects_excluded?: RawRedirectExcluded[]\n seo?: RawSeo\n routing?: RawRouting\n /** Interface text outside the content tables, each piece with one outcome. */\n hardcoded_text?: RawHardcodedText\n /** Outside services the site is connected to (`RawIntegrationScan.services`). */\n integrations?: RawIntegration[]\n language_pairs?: RawLanguagePair[]\n /** Site options (bridge rung), verbatim. */\n options?: Record<string, unknown>\n /** ACF Options Pages (bridge rung): site-wide fields, one entry per page. */\n acf_options?: RawAcfOptionsPage[]\n}\n\n// ─── CapabilityManifest ───\n//\n// What the site *uses*, as detected — the input for migration planning,\n// effort/pricing, and the \"what happens to X\" conversation with the user.\n// Detection is evidence-based and never certain; `present: true` with evidence\n// beats a silent guess.\n\nexport const CAPABILITY_KEYS = [\n 'seo',\n 'forms',\n 'comments',\n 'search',\n 'newsletter',\n 'analytics',\n 'consent',\n 'share',\n 'video',\n 'ads',\n 'builder',\n 'blocks',\n 'i18n',\n 'cache',\n 'security',\n 'acf',\n 'ecommerce',\n 'jetpack',\n 'redirects',\n 'membership',\n 'accessibility',\n 'media',\n 'scheduling',\n] as const\n\nexport type CapabilityKey = (typeof CAPABILITY_KEYS)[number]\n\nexport interface CapabilityDetection {\n present: boolean\n /** Identified plugin/provider, when known (e.g. \"yoast\", \"contact-form-7\"). */\n plugin?: string | null\n /** How it was detected: \"rest\", \"dom\", \"slug\", \"headers\", \"meta\", … */\n evidence?: string[]\n detail?: string\n}\n\nexport interface CapabilityManifest {\n version: number\n site_url: string\n access: {\n html_status?: number | null\n rest_status?: number | null\n /**\n * Highest source-access rung actually achieved for this site, or `null`\n * when none was — the site answered nothing a rung describes.\n *\n * Nullable rather than optional, and rather than a `'none'` rung. Optional\n * would let a producer omit the field and call that \"not measured\", which\n * is the failure this field exists to prevent: a manifest that reports\n * `rest_public` for every site makes the access ladder unreadable, and the\n * ladder is what the coverage figures are computed from. Required-and-\n * nullable makes \"I reached nothing\" a thing the producer has to say.\n *\n * A `'none'` rung was the other candidate and is worse: `SourceAccessKind`\n * is also `RawProvenance.kind`, where it answers \"how was this data\n * obtained\". A RawIR stamped `kind: 'none'` is a document that exists\n * without having been obtained — an impossible state made representable in\n * an unrelated contract. `SOURCE_ACCESS_LADDER` would stop being a ladder\n * too: it is an ordered array and coverage arithmetic indexes into it.\n *\n * `null` reads with `rest_status: null` beside it as its evidence. Coverage\n * arithmetic should drop these sites from the numerator, not score them\n * zero — a site behind an access wall was not measured, it did not fail.\n */\n achieved: SourceAccessKind | null\n }\n generator?: string | null\n theme?: string | null\n plugins?: string[]\n rest_namespaces?: string[]\n custom_post_types?: Array<{\n slug: string\n /** Whether the CPT is exposed in public REST (`rest: false` CPTs need a higher rung). */\n rest_visible: boolean\n count?: number | null\n }>\n /** Comments get first-class treatment: they are a launch-critical capability. */\n comments: {\n active: boolean\n form_status?: 'open' | 'closed' | 'mixed' | null\n rest_total?: number | null\n plugin?: string | null\n }\n capabilities: Partial<Record<CapabilityKey, CapabilityDetection>>\n /** Dynamic front-end behaviors observed in the DOM (\"carousel\", \"modal\", \"search-overlay\", …). */\n behaviors?: string[]\n languages?: string[]\n}\n\n// ─── ProjectIR ───\n//\n// The reproducible model of the site: not \"this page's HTML\" but the design\n// system, component architecture, route model, and content queries that let\n// *unseen* pages — tomorrow's posts, another category — be generated correctly.\n// Produced by analysis; consumed by the open emitter. Three layers are kept\n// together on purpose: visual structure (families/chrome/tokens), content\n// binding (slots), and business rules (routes/queries). A page family exists\n// per genuinely different behavior, not per page.\n\n/**\n * How the legacy CSS for a family was packaged.\n * - `purge_set`: rules kept if used by any page in the family's page set —\n * small file, safe against per-page rule loss (single-page purging is not).\n * - `localcss`: the site's stylesheets localized wholesale — larger, zero risk.\n */\nexport type CssStrategy = 'purge_set' | 'localcss'\n\n/**\n * Cascade layer name the emitter quarantines legacy CSS under, so a modern\n * utility layer (e.g. Tailwind) can coexist with pixel-faithful migrated pages.\n */\nexport const LEGACY_CSS_LAYER = 'legacy'\n\n/** Design tokens extracted from the source site, e.g. for a Tailwind `@theme` block. */\nexport interface DesignTokens {\n colors?: Record<string, string>\n font_families?: Record<string, string>\n font_sizes?: Record<string, string>\n spacing?: Record<string, string>\n breakpoints?: Record<string, string>\n}\n\nexport type RouteKind =\n | 'front'\n | 'single'\n | 'page'\n | 'archive'\n | 'term'\n | 'author'\n | 'date'\n | 'search'\n | 'not_found'\n | 'custom'\n\nexport interface RouteParamDef {\n name: string\n /**\n * Where the value comes from. The date parts exist because\n * `/%year%/%monthnum%/%day%/%postname%/` is a common WordPress permalink\n * structure: treating the template post's date as a fixed prefix sends every\n * other post to a wrong address, and SEO continuity is the point of a\n * migration. Posts carry their own values (`EmitPost.params`).\n */\n source:\n | 'post_slug'\n | 'post_id'\n | 'post_year'\n | 'post_month'\n | 'post_day'\n | 'term_slug'\n | 'author_slug'\n | 'page_number'\n | 'custom'\n}\n\n/** Selects a family/component variant when a route parameter matches. */\nexport interface RouteVariantRule {\n param: string\n in: string[]\n variant: string\n}\n\n/**\n * One route pattern → one layout family. Pagination is a parameter of a route\n * (`/news/page/2` is `/news` with `page_number = 2`), never a separate family —\n * across a 30-site corpus, page 2 never produced a new layout.\n */\nexport interface RouteModel {\n id: string\n /**\n * URL pattern with `:param` placeholders — `/category/:term`, `/news/page/:page`.\n *\n * A trailing `*` marks a **multi-segment (rest) parameter**: `/category/:term*`\n * matches `/category/about-cc/events/` with `term = \"about-cc/events\"`.\n * Hierarchical taxonomies and nested pages need this — a single-segment\n * parameter silently flattens `/category/about-cc/events/` to\n * `/category/events/`, breaking link continuity for every nested address.\n */\n pattern: string\n kind: RouteKind\n /** LayoutFamily id. */\n family: string\n params?: RouteParamDef[]\n /**\n * Content collection this route generates one page per entry from. `single`\n * routes default to `posts`; naming a collection makes any route\n * collection-driven — a site's pages and custom post types are per-entry\n * routes too, they just are not called \"single\".\n *\n * Without a name per route they would all write to (and overwrite) the same\n * collection.\n */\n collection?: string\n /** QueryBinding id feeding this route's list, when it renders one. */\n query?: string\n /**\n * Document title for routes whose pages have no per-page title of their own\n * (a static page, an archive without `QueryPage.title`).\n */\n title?: string\n /**\n * Locale this route serves. A multilingual site has a route per language\n * (`/:slug`, `/en/:slug`) with its own family and query; the emitted page's\n * `lang` comes from here rather than from the project default.\n */\n locale?: string\n variant_rules?: RouteVariantRule[]\n}\n\nexport type SlotKind =\n | 'body'\n | 'title'\n | 'date'\n | 'excerpt'\n | 'author_name'\n | 'author_link'\n | 'featured_image'\n | 'term_list'\n | 'self_link'\n | 'custom'\n\n/** Where a content field lands in a family's markup. */\nexport interface SlotBinding {\n kind: SlotKind\n /** The region is printed only on posts that have this slot filled — see `CHROME_IF_OPEN`. */\n optional?: boolean\n /** The region repeats per list item (terms, authors) — see `CHROME_REPEAT_OPEN`. */\n repeat?: boolean\n /** CSS selector of the slot's container, when one could be determined. */\n selector?: string\n /** Content-model field backing a `custom` slot. */\n field?: string\n /** Date rendering format for `date` slots, as observed on the source site. */\n date_format?: string\n}\n\n/**\n * Marks whose name ends in this suffix are inserted as raw HTML instead of\n * being escaped. Escaping stays the default — content-derived text must never\n * be able to break the page — but some themes render a post's full content or\n * a link-bearing excerpt inside a list card, and escaping those prints markup\n * as text (measured on one theme's category page: 48.6).\n */\nexport const RAW_MARK_SUFFIX = '_html'\n\n/**\n * Repeat block: `<!--@@repeat:NAME@@-->…<!--@@/repeat@@-->`, optionally with a\n * separator — `<!--@@repeat:NAME|, @@-->`. The inner fragment renders once per\n * item of the list named NAME, with per-item marks `item` / `item_index` (and\n * `item_<key>` for object items).\n *\n * Fixed marks (`term0…termN`) cannot express a list whose length varies per\n * post: a template built from a 3-term post leaves stray separators on a\n * 6-term one (measured symptoms: `\"Business,\"`, `\"Releases, Events,\"`,\n * `\"Automattic, ,\"`), and the same class shows up with multiple authors.\n */\nexport const CHROME_REPEAT_OPEN = '<!--@@repeat:'\nexport const CHROME_REPEAT_CLOSE = '<!--@@/repeat@@-->'\n\n/**\n * Conditional block: `<!--@@if:NAME@@-->…<!--@@/if@@-->`, negated with\n * `<!--@@if:!NAME@@-->`. The fragment survives only when NAME has a value\n * (non-empty string, non-empty list).\n *\n * Route-parameter variants (`RouteVariantRule`) cannot express this: two posts\n * on the SAME route differ by whether the theme printed a region at all\n * (measured: five posts of one family scored 3.5–84.5, the only difference\n * being a featured block that some posts render and others do not).\n */\nexport const CHROME_IF_OPEN = '<!--@@if:'\nexport const CHROME_IF_CLOSE = '<!--@@/if@@-->'\n\n/**\n * Marker a list section's wrapper carries where its items go —\n * `<div class=\"grid\"><!--@@items@@--></div>`.\n */\nexport const LIST_ITEMS_SLOT = '<!--@@items@@-->'\n\n/**\n * Marker a `body`-position chrome chunk carries where the page content goes.\n * A comment survives serialization, renders as nothing if ever left behind,\n * and — critically — can sit at ANY nesting depth: real themes put the content\n * container deep inside the chrome (`article > div.entry-content`), so the\n * chrome cannot be split into balanced before/after fragments. The emitter\n * splices content in at this marker and injects the result as ONE fragment.\n */\nexport const CHROME_BODY_SLOT = '<!--@@body@@-->'\n\n/**\n * Mount point of a component inside chrome: `<!--@@component:ID@@-->`, where\n * ID is a `ComponentDef.id`. The producer replaces the source region (the\n * theme's comment list and form, a contact form) with this marker; the emitter\n * renders the real component there and imports it into the layout, so a\n * runtime component is MOUNTED, not merely emitted as an unused file.\n *\n * Same shape as `CHROME_BODY_SLOT` for the same reasons: a comment survives\n * serialization, renders as nothing if left behind, and can sit at any depth.\n */\nexport const CHROME_COMPONENT_OPEN = '<!--@@component:'\nexport const CHROME_COMPONENT_CLOSE = '@@-->'\n\n/** The marker for one component id — `componentSlot('c-comments')`. */\nexport const componentSlot = (id: string): string => `${CHROME_COMPONENT_OPEN}${id}${CHROME_COMPONENT_CLOSE}`\n\n/**\n * A rendered, asset-rewritten chunk of site chrome the emitter injects verbatim.\n * Positions: `head` lands in `<head>`; `body` is the whole body chrome carrying\n * `CHROME_BODY_SLOT` where content goes (preferred — nesting-safe); the legacy\n * `before_body`/`after_body` pair is composed into a single body with the slot\n * between them (only correct when the content container is top-level).\n *\n * `header` and `footer` lift a region OUT of the body chrome into its own Astro\n * component, rendered as a sibling of the body fragment. They are for the two\n * regions a site actually shares — the masthead and the colophon — and they buy\n * two things one blob cannot: families that carry the same header emit ONE\n * component instead of N copies of the markup, and the nav lives at a single\n * address, which is where a jQuery-free menu replaces the theme's.\n *\n * The producer emits them ONLY after verifying both properties:\n *\n * 1. **Balanced.** The fragment closes every element it opens. Splitting chrome\n * at an arbitrary point produces halves the parser silently repairs — the\n * failure that cost a page 36 against 100 and the reason `body` exists.\n * 2. **Outside the content path.** The region sits wholly before (header) or\n * after (footer) `CHROME_BODY_SLOT` and is not an ancestor of it. A region\n * that wraps the content cannot be lifted; it belongs in `body`.\n *\n * When either is in doubt, keep everything in one `body` chunk: a single blob\n * is always correct, and this split is an optimisation on top of it.\n */\nexport interface ChromeChunk {\n id: string\n position: 'head' | 'body' | 'before_body' | 'after_body' | 'header' | 'footer'\n html: string\n /**\n * Shared component name for a `header`/`footer` chunk — `SiteHeader`,\n * `BlogFooter`. Chunks that carry the same name AND the same html collapse\n * into one emitted component; same name with different html gets a suffixed\n * name and a warning, never a silent swap. Default: `SiteHeader`/`SiteFooter`.\n */\n component?: string\n}\n\nexport interface ComponentPlacement {\n /** ComponentDef id. */\n component: string\n variant?: string\n selector?: string\n /**\n * `QueryBinding.id` whose results fill this region, instead of the cloned\n * markup that was there.\n *\n * The case this exists for: a theme's \"recent posts\" block sits in the\n * chrome of every article. Cloned, it freezes on the day of the migration —\n * it keeps listing the same posts forever, and nobody notices because it\n * still looks right. Bound to a query it stays current and becomes editable,\n * which is the whole difference between a copy of a site and a site.\n *\n * On the placement rather than on `ComponentDef`, because it is a per-mount\n * fact: one `related` component can be mounted by a post family filtered to\n * the post's category and by an author family filtered to the author. The\n * definition says what the region *is*; the placement says what it shows\n * here — the same split `variant` (card shape) and `selector` (region)\n * already follow.\n */\n query?: string\n}\n\nexport interface FamilyVariant {\n key: string\n description?: string\n}\n\n/**\n * Attributes on the document's root elements, carried verbatim from the source\n * page. Themes hang layout on them — WordPress writes `<body class=\"wp-singular\n * post-template-default single …\">` and scripts add `<html class=\"js wf-…\">`,\n * and the stylesheet's container rules key off exactly those classes. Dropping\n * them costs a correct-content page its entire layout (measured: 36.4 vs 100).\n *\n * Values may carry `@@mark@@` placeholders, so per-page classes (`postid-123`)\n * survive the same filling as chrome.\n *\n * Whether a site needs them is not knowable from one page: the same test that\n * moved 10up 36.4 → 100 moved wptavern 36 → 36, because that theme does not key\n * off body classes. Carry them always.\n */\nexport interface RootAttrs {\n html?: Record<string, string>\n body?: Record<string, string>\n}\n\nexport interface LayoutFamily {\n id: string\n name?: string\n kind?: RouteKind\n chrome?: ChromeChunk[]\n /** `<html>` / `<body>` attributes from the source page — see `RootAttrs`. */\n root_attrs?: RootAttrs\n slots?: SlotBinding[]\n components?: ComponentPlacement[]\n css: {\n strategy: CssStrategy\n /**\n * Stylesheets every page of the family loads.\n *\n * Page-builder sites emit CSS per page (`post-11368.css`,\n * `local/global-11368-frontend-*`), so one member's stylesheet set is not\n * the family's: building from a single template page scored 35.6, while\n * the UNION of the members' stylesheets scored 100. Producers put the\n * union here and the per-page remainder on the page itself.\n */\n files?: string[]\n }\n /** Observed column counts per viewport class (e.g. desktop 4 → mobile 1). */\n columns?: Partial<Record<'desktop' | 'mobile', number>>\n variants?: FamilyVariant[]\n /** Verification trail: which pages formed the family, held-out fidelity score. */\n evidence?: {\n pages?: string[]\n holdout_score?: number\n }\n}\n\n/**\n * Component semantic vocabulary. `source` records where its content can come\n * from: `rest` (derivable from content data — author box, term list),\n * `runtime` (needs a live service — comments form, search), `chrome` (carried\n * as rendered markup — nav, ads).\n */\nexport const COMPONENT_TYPES = [\n 'nav',\n 'related',\n 'comments',\n 'form',\n 'ads',\n 'chrome',\n 'taxonomy',\n 'author',\n 'share',\n 'breadcrumb',\n 'meta',\n 'pagination',\n 'card',\n 'custom',\n] as const\n\nexport type ComponentType = (typeof COMPONENT_TYPES)[number]\n\nexport type ComponentSource = 'rest' | 'runtime' | 'chrome'\n\nexport interface ComponentVariantDef {\n key: string\n conditions?: RouteVariantRule[]\n description?: string\n}\n\nexport interface ComponentDef {\n id: string\n type: ComponentType\n source: ComponentSource\n name?: string\n selector?: string\n /**\n * Content model a runtime component talks to — the collection a `form`\n * submits to. A `comments` component needs none: its thread is addressed by\n * the entry of the page it is mounted on.\n */\n model?: string\n /** e.g. a card component with `news`, `compact`, `featured` variants. */\n variants?: ComponentVariantDef[]\n}\n\nexport type ExcerptSource = 'excerpt' | 'content_first_paragraph' | 'none'\nexport type PaginationKind = 'numbered' | 'infinite' | 'none'\n\n/**\n * The query behind a rendered list: which content, filtered how, in what\n * order, how many. Inferred by matching rendered items against candidate\n * queries — order matters, so the sequence is part of the contract.\n */\nexport interface QueryBinding {\n id: string\n /** Content source: \"posts\", \"pages\", a CPT slug, or a content-model id. */\n source: string\n taxonomy?: {\n taxonomy: string\n /** Fixed term slug, or the route param carrying it. */\n term?: string\n term_param?: string\n }\n author?: {\n slug?: string\n param?: string\n }\n order: {\n by: 'date' | 'title' | 'menu_order' | 'custom'\n direction: 'asc' | 'desc'\n }\n per_page: number | null\n pagination: PaginationKind\n excerpt_source?: ExcerptSource\n /** WordPress image size name the list renders (e.g. \"medium_large\"). */\n image_size?: string\n /** Locale this query is filtered to, on a multilingual site. */\n locale?: string\n}\n\n/**\n * - `responsive`: one build, CSS handles viewports.\n * - `split`: desktop and mobile variants are produced separately and served\n * by device — for sites whose mobile DOM differs beyond CSS.\n */\nexport type ViewportStrategy = 'responsive' | 'split'\n\nexport interface ProjectIR {\n version: number\n site: {\n url: string\n title?: string\n locales?: string[]\n }\n routes: RouteModel[]\n families: LayoutFamily[]\n components?: ComponentDef[]\n queries?: QueryBinding[]\n tokens?: DesignTokens\n /** Default CSS packaging for families that don't override it. */\n css_default: CssStrategy\n viewport_strategy?: ViewportStrategy\n /** Content-model ids this project's queries and slots refer to. */\n content_models?: string[]\n}\n\n// ─── Comments export ───\n//\n// Comments cross one more boundary than the rest of RawIR: they leave the\n// generated static site entirely and land in a live service's database,\n// addressed by content entry — not by WordPress post id. This export is that\n// bridge: the source map translates WP ids to entry addresses, and the\n// comments ride along unmodified.\n\nexport const COMMENTS_EXPORT_FORMAT = 'contentrain-comments@1'\n\n/** Where one WordPress post's content ended up. */\nexport interface EntrySourceRef {\n model_id: string\n entry_id: string\n locale?: string\n}\n\n/**\n * WordPress post id (stringified) → content entry address. Only the tool that\n * wrote the content store can produce this; nothing downstream can recover it.\n */\nexport type EntrySourceMap = Record<string, EntrySourceRef>\n\nexport interface CommentsExport {\n version: number\n format: typeof COMMENTS_EXPORT_FORMAT\n source: RawProvenance\n site_url?: string\n /** ISO 8601 UTC. */\n generated_at: string\n entries: EntrySourceMap\n /** WP post ids whose comment form was closed — the receiving side opens those threads closed. */\n threads_closed?: number[]\n /**\n * Verbatim `RawComment`s; parents are re-linked via `RawComment.parent` in a second pass.\n *\n * An allowlist — what travels: comments on **public** entries (published, no post password) that\n * the RawIR holds, whose status is approved (`approved: '1'` or absent) or waiting for moderation\n * (`'0'` — the receiving side puts them in its moderation queue, so the site owner's queue survives\n * the move). Everything else stays behind: comments on drafts, private, scheduled or\n * password-protected entries, on entries the RawIR does not hold, and any other status (spam,\n * trash, WordPress's `post-trashed`, a plugin's own). A reply whose parent was left out keeps its\n * `parent` id; the receiver treats a parent it does not have as none.\n */\n comments: RawComment[]\n /** What was left out and why, counted — so a smaller export is explained, not suspicious. */\n excluded?: { non_public_entry?: number; unknown_entry?: number; spam?: number; trash?: number; other_status?: number }\n}\n\n/**\n * Where the full comments export is. A producer writes at most one of\n * `path`, `url` and `inline`; a consumer that meets more than one reads them\n * in that order (see `commentsExportSource`). With none of them the export\n * exists but has no reference yet (a large export with no repository, or\n * one not readable yet): a valid state, nothing to import.\n *\n * The file at `path` or `url` is the export as UTF-8 JSON; `bytes` and\n * `sha256` are taken over its raw bytes, as stored.\n */\nexport interface HandoffCommentsExport {\n format: typeof COMMENTS_EXPORT_FORMAT\n /**\n * The export as a file in the generated repository: a POSIX path relative\n * to the repository root, in normal form (`comments-export.json`,\n * `data/comments.json`; see `isRepoRelativePath`). The consumer reads it at\n * the same commit it read the handoff from — the commit SHA, not a branch\n * name, which can move between the two reads — with the access it already\n * has to that repository, so a large export in a private repository needs\n * no public URL. A consumer reading a local checkout checks the file is not\n * a symlink (lstat) before reading it.\n */\n path?: string\n /** Where the full export can be fetched… */\n url?: string\n /** …or the export itself, inline, for small sites. */\n inline?: CommentsExport\n /** Size of the file at `path` or `url` in bytes, so a consumer can refuse an oversized one before reading it. */\n bytes?: number\n /** SHA-256 of the file at `path` or `url`, lowercase hex, to check what was read. */\n sha256?: string\n}\n\n/** Comments summary + payload pointer on the handoff (capability counts are not enough for intake). */\nexport interface HandoffComments {\n total: number\n by_status?: Record<string, number>\n types?: Record<string, number>\n export?: HandoffCommentsExport\n threads_closed?: number[]\n unresolved?: Array<{ comment_id: number; post: number; reason: string }>\n /** Comments the export left out on purpose (`CommentsExport.excluded`). */\n excluded?: { non_public_entry?: number; unknown_entry?: number; spam?: number; trash?: number; other_status?: number }\n}\n\n/**\n * A repository-relative POSIX path in normal form: non-empty, relative (no\n * leading `/`), no `.` or `..` or empty segment (so no `./` prefix, no `//`,\n * no trailing `/`), no `.git` segment, no backslash, no scheme or drive (`:`\n * before the first `/`), no control character, and Unicode in NFC. The one\n * form accepted, so two producers cannot spell the same file two ways\n * (`çay` composed or decomposed), and a path can never leave the repository\n * or reach its git internals.\n */\nexport function isRepoRelativePath(path: unknown): path is string {\n if (typeof path !== 'string' || path === '') return false\n if (path.normalize('NFC') !== path) return false\n if (path.includes('\\\\') || [...path].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127)) return false\n if (/^[^/]*:/.test(path)) return false\n return path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..' && segment.toLowerCase() !== '.git')\n}\n\n/** The source a consumer reads the export from, after precedence and validation. */\nexport type CommentsExportSource =\n | { kind: 'path'; path: string }\n | { kind: 'url'; url: string }\n | { kind: 'inline'; export: CommentsExport }\n\n/**\n * Where to read the comments export from: `path`, else `url`, else `inline`.\n * `path` comes first because it is in the same repository and ref as\n * everything else the handoff describes and needs no public access; `url`\n * wins over `inline` as it always has. A `path` that is not a\n * repository-relative path in normal form is ignored — reading it could\n * leave the repository — and so is a `url` that is not http(s).\n */\nexport function commentsExportSource(exp: HandoffCommentsExport | undefined): CommentsExportSource | undefined {\n if (!exp) return undefined\n if (isRepoRelativePath(exp.path)) return { kind: 'path', path: exp.path }\n if (typeof exp.url === 'string' && /^https?:\\/\\//i.test(exp.url)) return { kind: 'url', url: exp.url }\n if (exp.inline && typeof exp.inline === 'object') return { kind: 'inline', export: exp.inline }\n return undefined\n}\n\n/** What `validateHandoffCommentsExport` found: `errors` make the pointer wrong, `warnings` weaker than it should be. */\nexport interface HandoffCommentsExportReport {\n errors: string[]\n warnings: string[]\n}\n\n/**\n * What is wrong with a handoff's export pointer, as short sentences. For a\n * producer's own check and a consumer's report — it does not decide what to\n * read (`commentsExportSource` does). A pointer with no source at all is\n * valid (the export has no reference yet) and reports nothing.\n */\nexport function validateHandoffCommentsExport(exp: HandoffCommentsExport): HandoffCommentsExportReport {\n const issues: string[] = []\n const warnings: string[] = []\n if (exp.format !== COMMENTS_EXPORT_FORMAT) issues.push(`format is not ${COMMENTS_EXPORT_FORMAT}`)\n const given = (['path', 'url', 'inline'] as const).filter((key) => exp[key] !== undefined)\n if (given.length > 1) issues.push(`more than one of path, url, inline is set (${given.join(', ')}); path is read first`)\n if (exp.path !== undefined && !isRepoRelativePath(exp.path)) issues.push('path is not a repository-relative POSIX path in normal form')\n if (exp.url !== undefined && !(typeof exp.url === 'string' && /^https?:\\/\\//i.test(exp.url))) issues.push('url is not an http(s) URL')\n if (exp.bytes !== undefined && !(Number.isSafeInteger(exp.bytes) && exp.bytes >= 0)) issues.push('bytes is not a non-negative integer')\n if (exp.sha256 !== undefined && !(typeof exp.sha256 === 'string' && /^[0-9a-f]{64}$/.test(exp.sha256))) issues.push('sha256 is not 64 lowercase hex characters')\n if ((exp.bytes !== undefined || exp.sha256 !== undefined) && exp.path === undefined && exp.url === undefined) issues.push('bytes and sha256 describe a file at path or url, and neither is set')\n if (exp.path !== undefined || exp.url !== undefined) {\n if (exp.sha256 === undefined) warnings.push('sha256 is missing — what is read cannot be checked')\n if (exp.bytes === undefined) warnings.push('bytes is missing — an oversized export cannot be refused before reading it')\n }\n return { errors: issues, warnings }\n}\n\n// ─── Runtime binding ───\n//\n// A runtime component (comments, forms) talks to a live service. The static\n// site only needs two facts to do that: where the public API lives and which\n// project it belongs to. Everything else — the model, the entry, the locale —\n// is already on the page that mounts the component.\n\n/**\n * Public API root of the runtime provider plus the project id. `base_url` is\n * the origin (`https://studio.contentrain.io`); components append their own\n * `/api/{forms,comments}/v1/{project_id}/…` paths. No credential travels with\n * it: the public endpoints are unauthenticated by design, and a browser page\n * cannot keep a secret.\n */\nexport interface RuntimeBinding {\n base_url: string\n project_id: string\n}\n\n// ─── MigrationHandoff ───\n//\n// What the migration hands the user: where the generated project lives, what\n// happened to each detected capability, and — for capabilities that need a\n// runtime — the offers. Offering is this document's job; fulfilling an offer\n// is the receiving product's. The migration itself is complete without any\n// offer being accepted.\n\nexport type CapabilityDisposition =\n | 'migrated_static'\n | 'archived'\n | 'needs_runtime'\n | 'external_adapter'\n | 'kept_on_wordpress'\n | 'dropped'\n\nexport interface HandoffCapability {\n key: CapabilityKey\n disposition: CapabilityDisposition\n detail?: string\n /** e.g. comments: { migrated: 412, spam_skipped: 60 } */\n counts?: Record<string, number>\n}\n\nexport type OfferProvider = 'studio_managed' | 'adapter' | 'keep_wordpress'\n\nexport interface CostEstimate {\n currency: string\n monthly: number\n assumptions?: string[]\n}\n\n/**\n * The comparison shown when a runtime capability is offered: what running the\n * old server costs (maintenance, security, hosting) against the managed\n * option — so declining is an informed choice, not a default.\n */\nexport interface CostComparison {\n self_host?: CostEstimate\n managed?: CostEstimate\n}\n\nexport interface HandoffOffer {\n capability: CapabilityKey\n provider: OfferProvider\n /** Adapter identifier when `provider` is `adapter`. */\n adapter?: string\n cost_comparison?: CostComparison\n /** e.g. \"keeping comments on WordPress means the WordPress server stays live\". */\n warning?: string\n}\n\n/**\n * What a standalone page's body is, as far as the migration can carry it.\n *\n * - `html` — markup written in the editor. It travels as content and stays\n * editable.\n * - `builder_html` — markup a page builder rendered (the builder's own data\n * is not HTML). It travels as it was rendered: the page looks right, but its\n * layout is no longer edited in the builder that made it.\n * - `none` — no body; the theme renders the page from other data. Nothing to\n * carry as content.\n *\n * Counted because they promise different things to the person receiving the\n * site, and a single \"pages migrated\" number hides which promise was made.\n */\nexport const PAGE_BODY_KINDS = ['html', 'builder_html', 'none'] as const\nexport type PageBodyKind = (typeof PAGE_BODY_KINDS)[number]\n\nexport interface MigrationHandoff {\n version: number\n site_url: string\n /** ISO 8601 UTC. */\n generated_at: string\n repository?: {\n provider: 'github' | 'gitlab'\n owner: string\n name: string\n default_branch: string\n }\n preview_url?: string\n content_summary?: {\n models: number\n entries: number\n locales?: string[]\n /**\n * Standalone pages by body kind — see `PageBodyKind`. Every kind is present\n * when the field is, so a zero is a count, not an omission.\n */\n body_kinds?: Record<PageBodyKind, number>\n }\n capabilities: HandoffCapability[]\n /** Present whenever the source had comments — see `HandoffComments`. */\n comments?: HandoffComments\n offers?: HandoffOffer[]\n /** Where the generated site's runtime components were bound, when an offer was fulfilled. */\n runtime?: RuntimeBinding\n notes?: string[]\n}\n","// ─── Execution, Approval and Source-Delta Contracts ───\n//\n// Shared, MIT-licensed shapes for operations that *do* something: an agent run,\n// a bulk edit, a deploy, a cutover. `migration.ts` describes a site; this file\n// describes an act upon one.\n//\n// Three repositories meet here and none of them may define these shapes for\n// itself. The migration engine (private) produces plans and receipts. Studio\n// (AGPL) renders the plan card, collects approvals and stores the run. The MCP\n// server (MIT) is where a plan's steps actually execute. If each wrote its own\n// `RiskClass`, a \"destructive\" operation would mean three different things and\n// the approval that guards it would be theatre.\n//\n// Everything here is plain JSON — snake_case keys, no class instances, no\n// functions — because these documents cross process, repository and license\n// boundaries, and because a plan must survive being written to disk, approved\n// hours later, and executed by a different process than the one that built it.\n\nimport { canonicalStringify } from './canonical.js'\n\n/**\n * Version stamped into every execution document (`version` field on each root).\n * A reader that sees a higher version than it knows should refuse rather than\n * guess — a misread plan is a plan executed with the wrong scope.\n */\nexport const EXECUTION_CONTRACT_VERSION = 1\n\n// ─── Actors ───\n\n/**\n * Who is acting. `kind` is load-bearing for approval: an agent may never\n * approve its own work, so the evaluator has to be able to tell an agent\n * from a person without consulting Studio's user table.\n */\nexport interface ActorRef {\n kind: 'human' | 'agent' | 'system'\n /** Stable identifier in the issuing system (Studio user id, agent name). */\n id: string\n /** Display name at the time of acting; advisory, never matched against. */\n name?: string\n /** Project role held at the time of acting (e.g. \"owner\", \"admin\", \"editor\"). */\n role?: string\n}\n\n// ─── Risk ───\n\n/**\n * What is at stake in an operation, in ascending severity.\n *\n * The order is the contract, not a convenience: `RISK_CLASSES` is a ladder and\n * a policy written for `bulk_content` is expected to also cover everything\n * above it. The seven rungs come from the approval model — read-only work is\n * automatic, content edits follow project policy, and anything that leaves the\n * repository (money, third parties, production) is a separate decision from\n * whether the content is correct.\n */\nexport const RISK_CLASSES = [\n /** Reads only; no file, no side effect. Automatic by default. */\n 'read_only',\n /** A bounded content edit — one entry, one locale. Project policy decides. */\n 'low_risk_content',\n /** Many entries at once: bulk status changes, mass field edits, imports. */\n 'bulk_content',\n /** Deletes content or changes a model's shape. Both plan and change approval. */\n 'destructive_schema',\n /** Reaches a third party: email, CRM, webhook, provider API. */\n 'external_effect',\n /** Spends money in a way worth a budget decision. */\n 'financially_material',\n /** Publishes, deploys, cuts over, or rewrites redirects. Release approval. */\n 'deployment',\n] as const\n\nexport type RiskClass = (typeof RISK_CLASSES)[number]\n\n/** Position on the ladder; higher is more severe. */\nexport function riskRank(risk: RiskClass): number {\n return RISK_CLASSES.indexOf(risk)\n}\n\n/**\n * The most severe of the given classes — the class a multi-step plan is\n * evaluated against. A plan is exactly as safe as its worst step, so a\n * read-only survey that ends in a deploy is a deploy.\n */\nexport function highestRisk(risks: readonly RiskClass[]): RiskClass {\n let worst: RiskClass = 'read_only'\n for (const risk of risks) {\n if (riskRank(risk) > riskRank(worst)) worst = risk\n }\n return worst\n}\n\n// ─── Approval ───\n\n/**\n * The three moments at which a decision is asked for. They are distinct\n * questions and collapsing them is the mistake this type exists to prevent:\n * approving *what will be done* is not approving *what was produced*, and\n * neither is permission to put it in front of the public.\n */\nexport const APPROVAL_GATES = [\n /** Before the work starts: scope, cost, risk, tools, affected resources. */\n 'plan',\n /** After the branch exists: semantic diff, schema change, validation output. */\n 'change',\n /** Before production effect: publish, deploy, cutover, redirects, bulk delete. */\n 'release',\n] as const\n\nexport type ApprovalGate = (typeof APPROVAL_GATES)[number]\n\n/**\n * How many people, and which, must say yes. Deliberately three modes: a\n * generic workflow canvas was considered and rejected — policy-based approval\n * covers every case the product actually has.\n */\nexport const APPROVAL_MODES = [\n /** No human decision needed. */\n 'auto',\n /** One authorised reviewer is enough. */\n 'single',\n /** A count, or a role combination, must be met. */\n 'quorum',\n] as const\n\nexport type ApprovalMode = (typeof APPROVAL_MODES)[number]\n\n/**\n * One rule in `.contentrain/approval-policies.json`: for operations at or\n * above `risk`, this gate is required in this mode.\n *\n * Rules are additive. Every matching rule contributes a requirement; nothing a\n * rule says can remove a requirement another rule created. A policy file can\n * therefore only ever make a project stricter, which is what makes it safe to\n * merge one from a template.\n */\nexport interface ApprovalRule {\n /** Applies to this class and — because the classes are a ladder — everything above it. */\n risk: RiskClass\n gate: ApprovalGate\n mode: ApprovalMode\n /** Quorum only: how many distinct approvers are needed. Ignored otherwise. */\n min_approvals?: number\n /** Roles that may satisfy this rule. Empty/absent = any authorised reviewer. */\n roles?: string[]\n /** Narrow the rule to certain models. Absent = every model. */\n models?: string[]\n /** Narrow the rule to certain locales. Absent = every locale. */\n locales?: string[]\n}\n\n/**\n * `.contentrain/approval-policies.json`. Lives in git, beside the content it\n * governs, so that the policy in force is the policy on the branch — a policy\n * stored only in a database can be changed after a plan is built and before it\n * is approved.\n */\nexport interface ApprovalPolicyFile {\n version: number\n /**\n * Applied at the `change` gate when no rule matches at all. Absent means no\n * rule matched and therefore nothing is required — the policy's silence is\n * taken literally rather than filled in with each consumer's idea of a\n * sensible default.\n */\n default_mode?: ApprovalMode\n rules: ApprovalRule[]\n /**\n * Whether an actor may approve a plan they themselves created. Defaults to\n * false; an agent can never self-approve regardless of this setting.\n */\n allow_self_approval?: boolean\n}\n\n/**\n * An outstanding or satisfied demand for a decision, derived from a rule.\n * `because` carries the rule's risk class so a UI can say *why* a gate\n * appeared without re-running the policy.\n */\nexport interface ApprovalRequirement {\n gate: ApprovalGate\n mode: ApprovalMode\n /** Distinct approvers still needed; 0 once satisfied. */\n min_approvals: number\n roles?: string[]\n /**\n * The risk class of the rule that produced this requirement: the rule's floor, which a heavier plan\n * climbs past, not the plan's own risk (that is `ApprovalDecision.risk`). When the policy's default mode\n * produced it, the plan's class.\n */\n because: RiskClass\n}\n\n/**\n * A decision that was actually given.\n *\n * A grant is bound to an exact `plan_hash`: if the plan changes, its grants no\n * longer apply and the gate reopens. That binding is the whole point — an\n * approval of \"publish these 12 posts\" must not carry over to a plan that\n * publishes 400.\n */\nexport interface ApprovalGrant {\n gate: ApprovalGate\n /** The plan this decision was given for. */\n plan_hash: string\n /** The branch tip reviewed, for `change` grants on a produced diff. */\n commit_sha?: string\n approver: ActorRef\n /** ISO 8601 UTC. */\n approved_at: string\n /** ISO 8601 UTC; a grant past this instant does not count. */\n expires_at?: string\n note?: string\n}\n\n// ─── Execution scope ───\n\n/**\n * What an operation touches. Used twice with the same shape: on a plan it is\n * the prediction, on a receipt it is what actually happened. Keeping one shape\n * for both is deliberate — the diff between them is the most useful thing a\n * receipt can show, and it is only computable if the two are comparable.\n *\n * Every field is optional and an absent field means \"none\", not \"unknown\": a\n * plan that cannot enumerate what it will touch is not a plan that should be\n * approved.\n */\nexport interface ExecutionScope {\n models?: string[]\n locales?: string[]\n /** Entry ids, or `model:id` where the model is not implied by `models`. */\n entries?: string[]\n /** Routes/URLs affected — cutovers and redirect rewrites report these. */\n routes?: string[]\n /** Repository-relative file paths. */\n files?: string[]\n /** Asset ids or paths. */\n assets?: string[]\n /** Provider identifiers reached (deployment, email, CRM). */\n providers?: string[]\n /** Hosts contacted outside the project — the evidence behind `external_effect`. */\n external_domains?: string[]\n}\n\n/** One tool invocation inside a plan. */\nexport interface ExecutionStep {\n id: string\n /** Tool name as the runner knows it (e.g. \"contentrain_bulk\"). */\n tool: string\n /** One line, written for the person approving, not for the log. */\n summary: string\n risk: RiskClass\n /** What this step alone touches; the plan's scope is the union. */\n scope?: ExecutionScope\n /** Opaque tool arguments. Hashed with the rest of the plan. */\n input?: Record<string, unknown>\n}\n\n/**\n * Predicted cost. Currency is ISO 4217; absent means the cost is not monetary.\n *\n * The non-monetary fields exist because cost is metered before it is priced:\n * a run's bill is assembled from tokens, compute time, bytes stored and bytes\n * transferred, and those are what a runner can actually measure at the moment\n * it finishes a step. `cost` is the conversion, and it is often computed later\n * from these.\n */\nexport interface ExecutionEstimate {\n currency?: string\n /** Money, in major units. */\n cost?: number\n /** Model tokens, when the step runs an agent. */\n tokens?: number\n duration_ms?: number\n /**\n * Items the operation expects to write — entries, files, assets. A count,\n * not a size: use `bytes_stored` for how much was written.\n */\n items?: number\n /**\n * Bytes the operation leaves at rest — a built artefact, an uploaded asset.\n * Object storage bills for this per unit time, so it is the field a storage\n * line on an invoice traces back to.\n */\n bytes_stored?: number\n /**\n * Bytes the operation moves across the network — egress, and fetches made on\n * the way. Billed separately from storage and by a different rate, which is\n * why it is a separate field rather than one `bytes`: a run that stores 40 MB\n * once and serves it a thousand times has one storage figure and a very\n * different transfer figure.\n */\n bytes_out?: number\n}\n\n/** Cost as measured. Same shape as the estimate so the two can be subtracted. */\nexport type ExecutionCost = ExecutionEstimate\n\n/**\n * How to undo the operation. A command, not a promise: if the runner cannot\n * state the undo, `available` is false and the approver learns that *before*\n * deciding rather than after failing.\n */\nexport interface RollbackPlan {\n available: boolean\n /** Shell command or tool call that reverses the effect. */\n command?: string\n /** Commit to reset the content branch to. */\n revert_to?: string\n /** Why it cannot be undone, when `available` is false. */\n reason?: string\n}\n\n/** The repository state a plan was built against. */\nexport interface ProjectRef {\n branch?: string\n commit_sha?: string\n /** Content branch tip, when it differs from the base branch. */\n content_commit_sha?: string\n}\n\n/**\n * An operation, fully described, before it runs.\n *\n * `plan_hash` is the identity of *what will happen*. It covers the steps,\n * scope, risk, estimate and rollback, and excludes who built the plan and\n * when — regenerating the same operation must produce the same hash, or\n * idempotency and approval binding both break. Compute it with\n * {@link computePlanHash}; never hand-roll it, because a hash computed two\n * different ways is a gate that silently opens.\n */\nexport interface ExecutionPlan {\n version: number\n id: string\n /** SHA-256 of the plan's semantic payload — see {@link computePlanHash}. */\n plan_hash: string\n /** What this achieves, in the requester's words. */\n intent: string\n /** The plan's class: the highest risk among its steps. */\n risk: RiskClass\n steps: ExecutionStep[]\n /** Union of the steps' scopes, stated once so an approver reads one list. */\n scope: ExecutionScope\n estimate?: ExecutionEstimate\n rollback?: RollbackPlan\n /** Repository state the plan assumes; re-checked at execution time. */\n project?: ProjectRef\n /** ISO 8601 UTC. Excluded from the hash. */\n created_at?: string\n /** Excluded from the hash, but checked for self-approval. */\n created_by?: ActorRef\n /** ISO 8601 UTC; a plan past this instant must be rebuilt, not run. */\n expires_at?: string\n /**\n * Caller-supplied key that makes a re-submission a no-op rather than a\n * second run. Excluded from the hash: two runs of the same work differ by\n * this key alone and must still share a plan hash.\n */\n idempotency_key?: string\n}\n\n// ─── Receipt ───\n\n/**\n * Run lifecycle. The main line is `draft → planned → awaiting_approval →\n * approved → scheduled → queued → running → verifying → completed`; the rest\n * are the states a run can sit in when something interrupts it.\n */\nexport const RUN_STATUSES = [\n 'draft',\n 'planned',\n 'awaiting_approval',\n 'approved',\n 'scheduled',\n 'queued',\n 'running',\n 'verifying',\n 'completed',\n 'paused',\n 'needs_attention',\n 'retrying',\n 'failed',\n 'cancelled',\n 'expired',\n 'rolled_back',\n] as const\n\nexport type RunStatus = (typeof RUN_STATUSES)[number]\n\n/** Statuses from which a run will not proceed on its own. */\nexport const TERMINAL_RUN_STATUSES = [\n 'completed',\n 'failed',\n 'cancelled',\n 'expired',\n 'rolled_back',\n] as const satisfies readonly RunStatus[]\n\nexport function isTerminalRunStatus(status: RunStatus): boolean {\n return (TERMINAL_RUN_STATUSES as readonly RunStatus[]).includes(status)\n}\n\n/**\n * A step boundary the runner survived. Checkpoints are what make a resumed\n * run skip work it already did instead of repeating it.\n */\nexport interface Checkpoint {\n step_id: string\n status: 'completed' | 'skipped' | 'failed'\n /** ISO 8601 UTC. */\n at: string\n /** What this step actually touched. */\n scope?: ExecutionScope\n /** Commit written by this step, when it wrote one. */\n commit_sha?: string\n message?: string\n}\n\n/** One post-run assertion — a build, a link check, a parity score. */\nexport interface VerificationCheck {\n id: string\n /** One line naming what was asserted. */\n name: string\n passed: boolean\n /** Measured value, when the check produced one. */\n value?: number | string\n /** Threshold the value was compared against. */\n expected?: number | string\n detail?: string\n}\n\n/**\n * What happened, after the fact. The receipt is the durable record: it\n * survives the run, is exported for audit, and is the only artefact that can\n * answer \"who allowed this and what did it actually change?\".\n *\n * Release approvers are the entries of `approvals` whose `gate` is `release`\n * — there is deliberately no separate `release_approved_by` field, because two\n * places recording the same fact is two places that can disagree. Read them\n * with {@link approversFor}.\n */\nexport interface ExecutionReceipt {\n version: number\n id: string\n plan_id: string\n /** The plan that ran. Must match the approvals' `plan_hash`. */\n plan_hash: string\n status: RunStatus\n actor: ActorRef\n /** Every decision that permitted this run, plan/change/release alike. */\n approvals?: ApprovalGrant[]\n /** ISO 8601 UTC. */\n started_at: string\n finished_at?: string\n /** What was actually touched — compare against the plan's `scope`. */\n applied: ExecutionScope\n checkpoints?: Checkpoint[]\n verification?: VerificationCheck[]\n /** Measured cost — compare against the plan's `estimate`. */\n cost?: ExecutionCost\n /** The undo, as it stands now that the run has happened. */\n rollback?: RollbackPlan\n /** Repository state the run left behind. */\n project?: ProjectRef\n /** Set when `status` is `failed` or `needs_attention`. */\n error?: { code: string, message: string, step_id?: string }\n}\n\n/** Approvers recorded on a receipt for one gate, in grant order. */\nexport function approversFor(receipt: ExecutionReceipt, gate: ApprovalGate): ActorRef[] {\n return (receipt.approvals ?? []).filter(grant => grant.gate === gate).map(grant => grant.approver)\n}\n\n// ─── Deployment ───\n\n/**\n * Where a build is published. Carries a *reference* to a secret, never a\n * secret: this document is written to git, and a token in git is a token\n * leaked. The reference is resolved at execution time by whoever holds the\n * vault.\n */\nexport interface DeploymentTarget {\n id: string\n /** Provider identity (e.g. \"netlify\", \"vercel\", \"cloudflare-pages\"). */\n provider: string\n /** Provider-side site/project identifier. */\n site_id?: string\n branch?: string\n /** Key naming the credential in the executing side's secret store. */\n secret_ref?: string\n /** Non-secret provider settings. */\n config?: Record<string, string>\n}\n\n// ─── Automation (type reservation) ───\n//\n// Reserved now so that the file `.contentrain/automations.json` has a declared\n// shape before anything writes one. No tool in this repository reads these\n// yet; the shape exists so that the first writer does not invent a fourth\n// vocabulary for schedules.\n\nexport const SCHEDULE_KINDS = [\n 'once',\n 'recurring',\n 'content_event',\n 'git_event',\n 'runtime_event',\n 'external_webhook',\n 'manual',\n] as const\n\nexport type ScheduleKind = (typeof SCHEDULE_KINDS)[number]\n\n/** What to do with an occurrence that was missed while the runner was down. */\nexport type MisfirePolicy = 'skip' | 'run_once' | 'run_all'\n\nexport interface AutomationSchedule {\n kind: ScheduleKind\n /** ISO 8601 UTC, for `once`. */\n at?: string\n /** Cron expression, for `recurring`. */\n cron?: string\n /** IANA zone (e.g. \"Europe/Istanbul\"); cron without one is ambiguous twice a year. */\n timezone?: string\n /** Event name, for the event kinds. */\n event?: string\n misfire?: MisfirePolicy\n}\n\nexport interface AutomationDefinition {\n version: number\n id: string\n name: string\n enabled: boolean\n schedule: AutomationSchedule\n /** What the run should do, in the requester's words. */\n intent: string\n /** Risk the automation declares; the policy is re-evaluated at fire time. */\n risk?: RiskClass\n /** Budget ceiling per occurrence. */\n budget?: ExecutionEstimate\n /** Deduplication key for the occurrence. */\n idempotency_key?: string\n /** Bounded retry; absent means no retry. */\n max_retries?: number\n}\n\n// ─── SourceDeltaPlan ───\n//\n// The WordPress→repo delta layer. This is NOT `contentrain_reconcile`: that\n// tool merges two git branches through their common ancestor and knows\n// nothing about WordPress. A source delta is the other axis — what changed at\n// the origin since the snapshot the repository was built from — and it must be\n// written to the repository *before* reconcile runs on the git side.\n//\n// The trap this shape exists to close: `modified_after` is a filter on changed\n// records, not a deletion feed. A post deleted in WordPress simply stops\n// appearing, and a delta built from \"what changed\" will never notice. Hence\n// explicit tombstones, produced by comparing inventories rather than by\n// reading a feed.\n\n/** The origin state a delta is measured from. */\nexport interface SourceCursor {\n kind: 'rest_modified_after' | 'wxr_export' | 'bridge_inventory'\n /** ISO 8601 UTC, for `rest_modified_after`. */\n modified_after?: string\n /** Export identifier, for `wxr_export`. */\n export_id?: string\n /** Inventory hash, for `bridge_inventory` — the only kind that can prove deletions. */\n inventory_hash?: string\n /** ISO 8601 UTC time the cursor was taken (T0). */\n taken_at: string\n}\n\nexport const SOURCE_DELTA_OPS = [\n /** Present at the origin, absent in the repository. */\n 'created',\n /** Present in both, origin's fingerprint changed. */\n 'updated',\n /** Absent at the origin, present in the repository — a tombstone. */\n 'deleted',\n /** Same record, different slug/permalink. Drives redirect generation. */\n 'moved',\n] as const\n\nexport type SourceDeltaOp = (typeof SOURCE_DELTA_OPS)[number]\n\n/**\n * One record's delta.\n *\n * `conflict` is the case that cannot be resolved mechanically: the same record\n * changed at the origin *and* in the repository since the cursor. Taking\n * either side silently loses an edit, so the plan reports it and a human\n * decides.\n */\nexport interface SourceDeltaEntry {\n op: SourceDeltaOp\n /** Origin identifier (WordPress post/term/attachment id). */\n wp_id: number\n /** Origin object type (e.g. \"post\", \"page\", \"attachment\", \"category\"). */\n wp_type?: string\n /** Where it lives in the store, when it is already mapped. */\n model?: string\n entry_id?: string\n locale?: string\n /** Origin content fingerprint at cursor time and now. */\n fingerprint_before?: string\n fingerprint_after?: string\n /** For `moved`: the slugs, in that order. */\n slug_before?: string\n slug_after?: string\n /**\n * For `moved`: the site-root-relative addresses, in that order. A slug\n * cannot say that a page changed parent, a term base changed or a dated\n * permalink moved; the path can, and redirects are generated from it.\n */\n path_before?: string\n path_after?: string\n /**\n * For `deleted`: `trashed` is still at the origin and can come back;\n * `purged` is gone from it. A draft or private record is `updated`, never\n * `deleted` — unpublishing is the planner's decision.\n */\n deleted_kind?: 'trashed' | 'purged'\n /** True when the repository also changed this record since the cursor. */\n conflict?: boolean\n /**\n * The repository-side write behind `conflict`: the entry's meta when it was\n * last written by something other than the importer (Studio, an agent, a\n * person). `updated_at` is absent when the store never recorded it — an\n * unknown time is still a conflict.\n */\n repo_edit?: { updated_by: string, updated_at?: string, source: string }\n /**\n * Why the planner could not place this record in the store:\n * `not-in-source-map` (a post-type record the source map does not list),\n * `no-model-for-type` (no store model holds this origin type),\n * `entry-not-found` (the model exists, the entry does not).\n */\n unmapped?: 'not-in-source-map' | 'no-model-for-type' | 'entry-not-found'\n /**\n * For `updated` and `moved`: the fields whose stored value differs from the\n * incoming export's, by field name (`body` for a document's body). Computed\n * against the repository as it is now, so a repository-side edit shows here\n * too — which is what `conflict` is for.\n */\n fields_changed?: string[]\n /** For `moved`: the entry id at the new address, when the store derives ids from slugs and the id changes. */\n entry_id_after?: string\n /** Why this entry is here, for the person reading the plan. */\n detail?: string\n}\n\n/**\n * A one-way transfer plan from the WordPress origin to the repository.\n *\n * Not a sync: there is no path back. The plan is produced, written, and only\n * then does `contentrain_reconcile` merge the result on the git side.\n */\nexport interface SourceDeltaPlan {\n version: number\n /** ISO 8601 UTC. */\n generated_at: string\n /** Origin state this delta is measured from. */\n cursor: SourceCursor\n /** Origin state this delta brings the repository up to. */\n next_cursor?: SourceCursor\n entries: SourceDeltaEntry[]\n /**\n * Whether deletions could be determined at all. A `rest_modified_after`\n * cursor cannot see them; a plan that says `false` must not be read as\n * \"nothing was deleted\".\n */\n deletions_detectable: boolean\n /**\n * Origin types whose deletions could not be determined even though the plan\n * as a whole could — a type that left the inventory's scope (its plugin was\n * deactivated) is not a deletion of every record in it.\n */\n deletions_undetectable_types?: string[]\n /** Redirects implied by the `moved` entries. */\n redirects?: { from: string, to: string, status: 301 | 302 | 307 | 308 }[]\n /** Human-facing notes: truncation, skipped types, coverage limits. */\n warnings?: string[]\n}\n\n// ─── Plan hash ───\n\n/**\n * The exact bytes `plan_hash` is computed over. Exposed because a hash is only\n * useful if a disagreement can be investigated: when two sides compute\n * different hashes for the same plan, comparing payloads shows which field\n * differs, and comparing hashes shows nothing.\n *\n * Excluded: `plan_hash` itself (it cannot cover itself), and `id`,\n * `created_at`, `created_by` and `idempotency_key` — who built a plan, when,\n * under which run id, and with which deduplication key do not change what the\n * plan will do. Everything else is covered, so a changed step, a widened\n * scope, a raised estimate or a withdrawn rollback all invalidate every\n * approval the plan had collected.\n */\nexport function planHashPayload(plan: ExecutionPlan): string {\n const { plan_hash: _hash, id: _id, created_at: _at, created_by: _by, idempotency_key: _key, ...semantic } = plan\n return canonicalStringify(semantic)\n}\n\n/**\n * SHA-256 of {@link planHashPayload}, lowercase hex.\n *\n * Async because it uses Web Crypto (`globalThis.crypto.subtle`), which is\n * available in Node 18+, Deno, Bun, workers and browsers alike — this package\n * is consumed in all of them and must not reach for `node:crypto`. A non-\n * cryptographic hash was considered and rejected: approvals are pinned to this\n * value, so a collision is an approval bypass, not a cache miss.\n */\nexport async function computePlanHash(plan: ExecutionPlan): Promise<string> {\n const bytes = new TextEncoder().encode(planHashPayload(plan))\n const digest = await crypto.subtle.digest('SHA-256', bytes)\n return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('')\n}\n\n/** One origin record as the inventory saw it. */\nexport interface SourceInventoryRecord {\n /** Origin object type (\"post\", \"page\", \"attachment\", or a taxonomy name for a term). */\n wp_type: string\n /** Origin id — unique only together with `wp_type`: post 5 and category 5 are different records. */\n wp_id: number\n /** Origin status (`publish`, `draft`, `trash`, …); a term has none. */\n status?: string\n /**\n * Password-protected at the origin. The record is inventoried like any other\n * — so removing the password is an `updated`, not a `created` — but has no\n * public address.\n */\n protected?: boolean\n slug?: string\n /** Site-root-relative public address. A change here is a `moved`. */\n path?: string\n /** Origin id of the parent record, when it has one. */\n parent?: number\n /** ISO 8601 UTC, from the origin — a hint only: a meta-only edit does not change it. */\n modified_at?: string\n /**\n * Hash of the record as mapped, volatile fields excluded. `updated` is\n * decided on this, never on `modified_at`.\n */\n fingerprint: string\n /** Slugs the origin remembers for this record (WordPress `_wp_old_slug`). Corroborates a move; never the authority. */\n old_slugs?: string[]\n /** Locale of the record, when the origin is multilingual. */\n locale?: string\n}\n\n/**\n * Every record in scope at one moment — what a `bridge_inventory` cursor's\n * `inventory_hash` identifies.\n *\n * Deletions and moves are proven by comparing two inventories, not by\n * reading a feed; a `modified_after` query only narrows which bodies to fetch\n * again. A type in the earlier inventory's `scope` but not in this one's left\n * the scope — it was not deleted.\n */\nexport interface SourceInventory {\n /** Inventory format identifier, e.g. `contentrain-bridge-inventory@2`. */\n format: string\n /** ISO 8601 UTC time the inventory was taken. */\n taken_at: string\n /** What was enumerated. */\n scope: {\n post_types: string[]\n taxonomies?: string[]\n }\n records: SourceInventoryRecord[]\n /**\n * The value a cursor's `inventory_hash` carries: lowercase hex SHA-256 of the\n * records sorted by `wp_type` (byte order) then `wp_id` (numeric), each\n * serialized as the JSON array `[wp_type, wp_id, fingerprint, path ?? null,\n * status ?? null]` — no spaces, slashes and non-ASCII unescaped (what\n * `JSON.stringify` prints) — joined by `\\n`, no trailing newline. Other fields\n * do not enter it; a change they make shows through `fingerprint`.\n */\n inventory_hash: string\n}\n","// ─── Approval Evaluator ───\n//\n// The decision procedure behind `execution.ts`'s approval types: given a plan,\n// a policy and the decisions collected so far, may this proceed?\n//\n// It replaces a role check. Studio's `shouldAutoMerge` asks \"is this person an\n// owner?\" — which cannot express \"a bulk publish needs a second pair of eyes\n// even from the owner\", and cannot distinguish fixing a typo from cutting over\n// a domain. This asks about the action instead: its risk, its scope, and what\n// the project's own policy says about that combination.\n//\n// Pure and dependency-free, so the same answer comes out in Studio's server,\n// in a CLI, and in a test. Nothing here reads a file, a clock or a user table:\n// every input is passed in, `now` included, because a decision that silently\n// depends on the wall clock cannot be reproduced when someone asks why a run\n// was blocked.\n\nimport type {\n ActorRef,\n ApprovalGrant,\n ApprovalMode,\n ApprovalPolicyFile,\n ApprovalRequirement,\n ApprovalRule,\n ExecutionPlan,\n RiskClass,\n} from './execution.js'\nimport { APPROVAL_GATES, highestRisk, riskRank } from './execution.js'\n\n/**\n * Used when a project has no `.contentrain/approval-policies.json`.\n *\n * Read-only work proceeds; everything else wants one reviewer on the produced\n * diff. Deliberately stated as a value rather than buried in the evaluator so\n * a project can inspect it, extend it, or reject it — and so \"what happens\n * without a policy file\" has one answer instead of one per caller.\n *\n * Note this is a *default*, not the only gate in the system: whether a project\n * consults the evaluator at all is still governed by its `workflow` setting.\n */\nexport const DEFAULT_APPROVAL_POLICY: ApprovalPolicyFile = {\n version: 1,\n rules: [\n { risk: 'low_risk_content', gate: 'change', mode: 'single' },\n ],\n}\n\n/** Ascending strictness — used to keep the stricter of two modes. */\nconst MODE_RANK: Record<ApprovalMode, number> = { auto: 0, single: 1, quorum: 2 }\n\n/** How many distinct approvers a mode asks for. */\nfunction approvalsNeeded(rule: ApprovalRule): number {\n if (rule.mode === 'auto') return 0\n if (rule.mode === 'single') return 1\n return Math.max(1, rule.min_approvals ?? 2)\n}\n\n/**\n * Why a grant did not count. Machine-readable because the useful question is\n * never \"is it blocked\" but \"I approved this, why is it still blocked\".\n */\nexport type GrantRejection =\n /** Given for a different plan — the plan changed after the decision. */\n | 'plan_hash_mismatch'\n /** A `change` grant reviewed a different branch tip than the one presented. */\n | 'commit_mismatch'\n /** Past its `expires_at`. */\n | 'expired'\n /** An agent cannot approve. */\n | 'agent_approver'\n /** The approver authored the plan and the policy forbids self-approval. */\n | 'self_approval'\n /** The approver's role is not one the rule accepts. */\n | 'role_not_permitted'\n /** This approver already counted toward the requirement. */\n | 'duplicate_approver'\n /** No requirement at this gate — nothing for the grant to satisfy. */\n | 'no_matching_requirement'\n\nexport interface RejectedGrant {\n grant: ApprovalGrant\n reason: GrantRejection\n}\n\n/** A requirement plus the decisions that have accumulated against it. */\nexport interface ApprovalStatus extends ApprovalRequirement {\n /** Distinct approvers counted so far, in grant order. */\n approvers: ActorRef[]\n /** Still needed; 0 means satisfied. */\n remaining: number\n}\n\nexport interface ApprovalDecision {\n /** Every requirement is satisfied and the plan has not expired. */\n allowed: boolean\n /** The class the plan was evaluated at — its own, or its worst step's. */\n risk: RiskClass\n /** Every requirement the policy produced, satisfied or not. */\n requirements: ApprovalStatus[]\n /** The subset still needing approvers, in gate order. */\n outstanding: ApprovalStatus[]\n /** Decisions that were supplied but did not count, each with a reason. */\n rejected_grants: RejectedGrant[]\n /** One line per reason the plan cannot proceed; empty when it can. */\n reasons: string[]\n}\n\nexport interface ApprovalEvaluation {\n plan: ExecutionPlan\n /** Absent falls back to {@link DEFAULT_APPROVAL_POLICY}. */\n policy?: ApprovalPolicyFile\n /** Decisions collected so far. */\n grants?: readonly ApprovalGrant[]\n /**\n * The branch tip being presented for a `change` decision. When given, a\n * `change` grant must name this commit: a review of an older tip is not a\n * review of what is about to merge. When absent, `change` grants are matched\n * on `plan_hash` alone.\n */\n commit_sha?: string\n /** ISO 8601 UTC. Passed in, never read from the clock. */\n now?: string\n}\n\n/** Whether a rule's narrowing clauses match what the plan touches. */\nfunction ruleMatchesScope(rule: ApprovalRule, plan: ExecutionPlan): boolean {\n if (rule.models && rule.models.length > 0) {\n const touched = plan.scope.models ?? []\n if (!rule.models.some(model => touched.includes(model))) return false\n }\n if (rule.locales && rule.locales.length > 0) {\n const touched = plan.scope.locales ?? []\n if (!rule.locales.some(locale => touched.includes(locale))) return false\n }\n return true\n}\n\n/**\n * The class a plan is judged at: its declared risk, or its worst step's if\n * that is higher. A plan cannot understate itself — declaring `read_only`\n * while carrying a deploy step would otherwise walk straight past the policy.\n */\nexport function effectiveRisk(plan: ExecutionPlan): RiskClass {\n return highestRisk([plan.risk, ...plan.steps.map(step => step.risk)])\n}\n\n/**\n * The requirements a policy produces for a plan, before any decision is\n * collected. This is what a plan card renders: \"this needs one reviewer on the\n * diff and two on release, because it deploys\".\n *\n * Each matching rule contributes its own requirement and all of them must be\n * met. Merging two rules into one would need a rule for combining their modes,\n * roles and counts — and every such rule has a case where the result is\n * *looser* than one of its inputs. Keeping them separate is what makes the\n * policy additive in fact and not just in intent.\n *\n * `auto` rules are dropped rather than kept as zero-approver requirements: a\n * requirement nobody has to satisfy is noise on the card, and a stricter rule\n * matching the same gate still stands on its own.\n */\nexport function requiredApprovals(plan: ExecutionPlan, policy: ApprovalPolicyFile = DEFAULT_APPROVAL_POLICY): ApprovalRequirement[] {\n const risk = effectiveRisk(plan)\n const requirements: ApprovalRequirement[] = []\n let matched = false\n\n for (const rule of policy.rules) {\n if (!ruleMatchesScope(rule, plan)) continue\n\n // A demanding rule covers its own class and everything above it: the\n // classes are a ladder, so a policy written for `bulk_content` also\n // governs a deploy.\n //\n // An `auto` rule does NOT climb. It says \"this class needs nothing\", and\n // if it inherited upward a single `auto` rule on a low rung would exempt\n // every heavier operation above it — turning the one mode that demands\n // nothing into the only mode that can loosen a policy. Matching its own\n // class alone keeps the file additive in fact and not just in intent.\n const climbs = rule.mode !== 'auto'\n const applies = climbs ? riskRank(risk) >= riskRank(rule.risk) : risk === rule.risk\n if (!applies) continue\n\n matched = true\n const min_approvals = approvalsNeeded(rule)\n // `auto` produces no requirement — but it did match, so the policy's\n // `default_mode` does not step in behind it.\n if (min_approvals === 0) continue\n\n requirements.push({\n gate: rule.gate,\n mode: rule.mode,\n min_approvals,\n ...(rule.roles && rule.roles.length > 0 ? { roles: [...rule.roles] } : {}),\n because: rule.risk,\n })\n }\n\n if (!matched && policy.default_mode && policy.default_mode !== 'auto') {\n requirements.push({\n gate: 'change',\n mode: policy.default_mode,\n min_approvals: approvalsNeeded({ risk, gate: 'change', mode: policy.default_mode }),\n because: risk,\n })\n }\n\n // Gate order, then the strictest first within a gate, so a card reads\n // chronologically and the heaviest demand is not buried.\n return requirements.toSorted((a, b) =>\n APPROVAL_GATES.indexOf(a.gate) - APPROVAL_GATES.indexOf(b.gate)\n || MODE_RANK[b.mode] - MODE_RANK[a.mode]\n || b.min_approvals - a.min_approvals)\n}\n\n/** Whether a grant may be counted at all, independent of any requirement. */\nfunction grantDisqualification(\n grant: ApprovalGrant,\n plan: ExecutionPlan,\n policy: ApprovalPolicyFile,\n commit_sha: string | undefined,\n now: string,\n): GrantRejection | undefined {\n if (grant.plan_hash !== plan.plan_hash) return 'plan_hash_mismatch'\n if (grant.expires_at && grant.expires_at <= now) return 'expired'\n // An agent may never approve — not its own work, not anyone's. The whole\n // point of the gate is that something outside the automation looked.\n if (grant.approver.kind === 'agent') return 'agent_approver'\n if (\n !policy.allow_self_approval\n && plan.created_by\n && grant.approver.id === plan.created_by.id\n && grant.approver.kind === plan.created_by.kind\n ) return 'self_approval'\n // A `change` decision is a decision about a diff. Presented with a different\n // tip than the one reviewed, it says nothing about what is about to merge.\n if (grant.gate === 'change' && commit_sha && grant.commit_sha !== commit_sha) return 'commit_mismatch'\n return undefined\n}\n\n/**\n * May this plan proceed?\n *\n * Answers with the full picture rather than a boolean: which requirements\n * exist, which are met, who met them, and — for every decision that was\n * supplied but did not count — why it did not. The last part is what makes\n * \"I approved this, why is it still blocked?\" answerable without reading the\n * evaluator's source.\n */\nexport function evaluateApproval(input: ApprovalEvaluation): ApprovalDecision {\n const { plan, commit_sha } = input\n const policy = input.policy ?? DEFAULT_APPROVAL_POLICY\n const grants = input.grants ?? []\n const now = input.now ?? new Date().toISOString()\n\n const risk = effectiveRisk(plan)\n // `requiredApprovals` builds fresh objects (roles included) on every call, so\n // completing them in place is safe and costs no second allocation.\n const requirements: ApprovalStatus[] = requiredApprovals(plan, policy).map(requirement =>\n Object.assign(requirement, { approvers: [] as ActorRef[], remaining: requirement.min_approvals }))\n\n const rejected_grants: RejectedGrant[] = []\n\n for (const grant of grants) {\n const disqualified = grantDisqualification(grant, plan, policy, commit_sha, now)\n if (disqualified) {\n rejected_grants.push({ grant, reason: disqualified })\n continue\n }\n\n // One decision can satisfy several requirements at the same gate — it is\n // the same person saying yes to the same thing once.\n let counted = false\n let sawGate = false\n let roleBlocked = false\n let duplicate = false\n\n for (const requirement of requirements) {\n if (requirement.gate !== grant.gate) continue\n sawGate = true\n if (requirement.roles && !requirement.roles.includes(grant.approver.role ?? '')) {\n roleBlocked = true\n continue\n }\n if (requirement.approvers.some(approver => approver.id === grant.approver.id)) {\n duplicate = true\n continue\n }\n requirement.approvers.push(grant.approver)\n requirement.remaining = Math.max(0, requirement.min_approvals - requirement.approvers.length)\n counted = true\n }\n\n if (counted) continue\n if (!sawGate) rejected_grants.push({ grant, reason: 'no_matching_requirement' })\n else if (duplicate) rejected_grants.push({ grant, reason: 'duplicate_approver' })\n else if (roleBlocked) rejected_grants.push({ grant, reason: 'role_not_permitted' })\n }\n\n const outstanding = requirements.filter(requirement => requirement.remaining > 0)\n const reasons: string[] = []\n\n const expired = Boolean(plan.expires_at && plan.expires_at <= now)\n if (expired) {\n reasons.push(`The plan expired at ${plan.expires_at}; rebuild it rather than running it.`)\n }\n\n for (const requirement of outstanding) {\n const who = requirement.roles ? ` from ${requirement.roles.join(' or ')}` : ''\n const have = requirement.approvers.length\n // `because` is the matching rule's risk class: a floor the plan climbed\n // past, not the plan's own risk. Say \"the plan is X\" only when they agree.\n const cause = requirement.because === risk\n ? `the plan is ${risk}`\n : `the policy requires it at ${requirement.because} and above (this plan is ${risk})`\n reasons.push(\n `${requirement.gate} approval: ${requirement.remaining} more${who} needed `\n + `(${have}/${requirement.min_approvals}), because ${cause}.`,\n )\n }\n\n return {\n allowed: !expired && outstanding.length === 0,\n risk,\n requirements,\n outstanding,\n rejected_grants,\n reasons,\n }\n}\n","// ─── Migrate → Studio claim contract ───\n//\n// A paid Migrate order includes a Studio trial. When the migrated site is\n// delivered, Migrate hands the customer to Studio with a signed claim token;\n// Studio verifies it, grants the trial once per order, and opens the delivered\n// repository as a project.\n//\n// The token is a compact JWS, `alg: \"EdDSA\"` (Ed25519). Migrate signs with its\n// private key; Studio verifies with the public key. There is no shared secret.\n// A `kid` header is optional and used for key rotation. This module is the\n// payload both sides agree on. It deliberately does no cryptography — the\n// signature is checked by the consumer, before `validateMigrateStudioClaim`.\n//\n// Pure and dependency-free; every input is passed in, `now` included.\n\nimport type { CapabilityKey } from './migration.js'\nimport { CAPABILITY_KEYS } from './migration.js'\n\n/** Contract version carried in the payload as `v`. */\nexport const MIGRATE_STUDIO_CLAIM_VERSION = 1\n/**\n * v2: \"Migrate with Studio\". No trial: the Studio year is part of the order's\n * first invoice (Studio creates that checkout itself, server to server), and\n * the claim names the customer's GitHub account so Studio can find or create\n * the user without a sign-in. v1 stays valid for the claim-link flow.\n */\nexport const MIGRATE_STUDIO_CLAIM_VERSION_2 = 2\n/** JWS `alg` header value. */\nexport const MIGRATE_STUDIO_CLAIM_ALG = 'EdDSA'\n/** `iss` — who signs. */\nexport const MIGRATE_STUDIO_CLAIM_ISSUER = 'contentrain-migrate'\n/** `aud` — who may accept it. */\nexport const MIGRATE_STUDIO_CLAIM_AUDIENCE = 'contentrain-studio'\n/** Upper bound on `exp - iat`: a claim link is short-lived (30 minutes). */\nexport const MIGRATE_STUDIO_CLAIM_MAX_TTL_SECONDS = 1800\n/** Upper bound on the trial a claim can grant. v1 grants 60. */\nexport const MIGRATE_STUDIO_CLAIM_MAX_TRIAL_DAYS = 90\n/** Clock skew tolerated when checking `iat` / `exp`. */\nexport const MIGRATE_STUDIO_CLAIM_CLOCK_SKEW_SECONDS = 60\n\nexport const MIGRATE_STUDIO_PLANS = ['starter', 'pro'] as const\nexport type MigrateStudioPlan = (typeof MIGRATE_STUDIO_PLANS)[number]\n\n/**\n * One measurement the plan recommendation rests on, so Studio can say why:\n * \"3 200 comments a month exceed Starter's 500.\" `limit_key` is the Studio\n * plan-feature key the measurement was sized against; `limit` is the value of\n * that key for the recommended plan when the claim was signed. Sizing leaves\n * headroom (limit ≥ 1.2 × measured), so `measured <= limit` is expected.\n */\nexport interface MigrateStudioPlanEvidence {\n /** Studio plan-feature limit key, e.g. `comments.per_month`, `media.storage_gb`. */\n limit_key: string\n /** What discovery measured, in the limit's unit (a count per month, GB, MB). */\n measured: number\n /** The recommended plan's value for `limit_key` at signing time. */\n limit: number\n /** The discovered capability behind the measurement, when there is one. */\n capability?: CapabilityKey\n}\n\nexport interface MigrateStudioRepo {\n provider: 'github'\n owner: string\n name: string\n}\n\nexport interface MigrateStudioCapability {\n key: CapabilityKey\n /** Human-readable scale from discovery, e.g. \"1 240 comments\", \"2.1 GB\". */\n scale?: string | null\n}\n\n/** The claim token's payload. */\nexport interface MigrateStudioClaim {\n // Registered JWT claims\n iss: typeof MIGRATE_STUDIO_CLAIM_ISSUER\n aud: typeof MIGRATE_STUDIO_CLAIM_AUDIENCE\n /** Migrate account (user) id. */\n sub: string\n /** Single-use token id (uuid). Studio refuses a `jti` it has seen. */\n jti: string\n /** Issued at, seconds since the epoch. */\n iat: number\n /** Expires at, seconds since the epoch. `exp - iat` ≤ `MIGRATE_STUDIO_CLAIM_MAX_TTL_SECONDS`. */\n exp: number\n\n /** Contract version. */\n v: typeof MIGRATE_STUDIO_CLAIM_VERSION\n /** Migrate order id. Studio stores it UNIQUE: one grant per order. */\n order_id: string\n /**\n * Verified at Migrate. Studio shows it and may prefill with it, but does not\n * require a match: the customer's GitHub login email may differ.\n */\n email: string\n /** The plan sized from discovery. */\n plan: MigrateStudioPlan\n /** Why this plan — empty when discovery found nothing to size against (then `starter`). */\n plan_evidence: MigrateStudioPlanEvidence[]\n /** Trial length the grant opens, 1..`MIGRATE_STUDIO_CLAIM_MAX_TRIAL_DAYS`. Never taken from the client. */\n trial_days: number\n /** The delivered repository. */\n repo: MigrateStudioRepo\n /** Discovery summary shown on Studio's claim screen. Optional. */\n capabilities?: MigrateStudioCapability[]\n /**\n * The WordPress site the order migrated, as a bare origin (`https://host`,\n * lowercase, no path). Signed so Studio can trust it: media import fetches\n * only from this origin, never from an origin named in the repository.\n * Optional; `https:` only, plus `http:` for localhost.\n *\n * Must equal `new URL(origin).origin` exactly, so no userinfo, path, query\n * or fragment, and IDN hosts in punycode. A trailing-dot host is refused, so\n * one site has one spelling.\n */\n origin?: string\n /**\n * Where Studio fetches the order's comment export when the migrated site had\n * comments (`contentrain-comments@1`, the file the run delivered with\n * commenters' email, IP and avatar already removed). The export never enters\n * the repository, so this is its only way into Studio.\n *\n * A GET on Migrate's fixed export address with `token` as\n * `Authorization: Bearer`. The address carries no secret, so nothing that\n * logs request lines (proxies, the platform's edge) sees one. The token is\n * signed per job and valid until `expires_at` (the end of the grant window,\n * not the claim's 30 minutes); a missing, invalid or expired token reads as\n * 404. Studio fetches it server-side only and trusts the host only when it is\n * on its own Migrate allowlist — never because the claim names it. Absent\n * when the source had no comments.\n */\n comments_export?: MigrateStudioCommentsExport\n}\n\nexport interface MigrateStudioCommentsExport {\n /** `https:` URL (`http:` only for localhost), no userinfo, query or fragment. Carries no secret. */\n url: string\n /** The export's bearer token: a compact JWS, at most 2048 characters. Sent only as `Authorization: Bearer`. */\n token: string\n /** When the URL stops serving, seconds since the epoch. After `iat`. */\n expires_at: number\n /** Comments in the export, for the claim screen. */\n comments: number\n}\n\n/**\n * What the customer was quoted, signed so Studio can refuse a checkout whose\n * price would differ. Studio adds its own Studio-year line to `migrate_fee_cents`\n * and compares the sum with `quoted_total_cents`.\n */\nexport interface MigrateStudioClaimBilling {\n /** The Migrate dynamic fee, in cents, before the Studio year. ≥ 0. */\n migrate_fee_cents: number\n /** The first invoice the customer saw (Migrate fee + Studio year 1), in cents. > 0. */\n quoted_total_cents: number\n currency: 'usd'\n}\n\n/**\n * The v2 payload (\"Migrate with Studio\"): a v1 claim without `trial_days`, plus\n * the identity and billing a server-to-server provision needs.\n */\nexport interface MigrateStudioClaimV2 extends Omit<MigrateStudioClaim, 'v' | 'trial_days' | 'repo'> {\n v: typeof MIGRATE_STUDIO_CLAIM_VERSION_2\n /**\n * The delivered repository. Absent in a provision request: it is signed before payment, when the\n * delivery repository does not exist yet (it reaches Studio at the install-url / claim step). Present\n * means valid; never a placeholder.\n */\n repo?: MigrateStudioRepo\n /** The customer's GitHub user id (decimal string) from the Migrate sign-in. Studio finds or creates the user by it. */\n github_user_id: string\n /** Whether Migrate verified `email`. Studio binds by email only when `true`; never by an unverified one. */\n email_verified: boolean\n /** Where Studio sends the customer after paying (Migrate's payment page; it trusts the payment webhook, never this redirect): `https:` (`http:` for localhost), no credentials or fragment. Studio accepts it only for hosts on its own Migrate allowlist. */\n return_url: string\n billing: MigrateStudioClaimBilling\n}\n\nexport type MigrateStudioClaimV2Result =\n | { ok: true, claim: MigrateStudioClaimV2, warnings?: string[] }\n | { ok: false, errors: string[] }\n\nexport type MigrateStudioClaimResult =\n /**\n * `warnings`: optional parts that were dropped rather than failing the claim\n * (today only `comments_export.*`), as `field: problem` strings; absent when\n * nothing was dropped. `claim` no longer carries a dropped part.\n */\n | { ok: true, claim: MigrateStudioClaim, warnings?: string[] }\n | { ok: false, errors: string[] }\n\nconst CAPABILITY_SET: ReadonlySet<string> = new Set(CAPABILITY_KEYS)\nconst isObject = (x: unknown): x is Record<string, unknown> => typeof x === 'object' && x !== null && !Array.isArray(x)\nconst isText = (x: unknown): x is string => typeof x === 'string' && x.trim().length > 0\nconst isSeconds = (x: unknown): x is number => typeof x === 'number' && Number.isInteger(x) && x > 0\nconst isFiniteNonNegative = (x: unknown): x is number => typeof x === 'number' && Number.isFinite(x) && x >= 0\nconst isCents = (x: unknown): x is number => typeof x === 'number' && Number.isInteger(x) && x >= 0 && x <= 100_000_000\nconst LOCAL_HOSTS: ReadonlySet<string> = new Set(['localhost', '127.0.0.1', '[::1]'])\n\n/** A bare, canonical origin: `new URL(x).origin` gives it back unchanged. */\nfunction isClaimOrigin(x: unknown): x is string {\n if (typeof x !== 'string') return false\n let url: URL\n try { url = new URL(x) }\n catch { return false }\n if (url.origin !== x || url.hostname.endsWith('.')) return false\n return url.protocol === 'https:' || (url.protocol === 'http:' && LOCAL_HOSTS.has(url.hostname))\n}\n\n/** A compact JWS: three base64url segments, the signature non-empty. */\nfunction isCompactJws(x: unknown): x is string {\n return typeof x === 'string' && x.length <= 2048 && /^[\\w-]+\\.[\\w-]+\\.[\\w-]+$/.test(x)\n}\n\n/** A post-payment return address: https (http for localhost), no credentials or fragment; a query is fine. */\nfunction isReturnUrl(x: unknown): x is string {\n if (typeof x !== 'string' || x.length > 2048) return false\n let url: URL\n try { url = new URL(x) }\n catch { return false }\n if (url.username || url.password || url.hash || url.hostname.endsWith('.')) return false\n return url.protocol === 'https:' || (url.protocol === 'http:' && LOCAL_HOSTS.has(url.hostname))\n}\n\n/** A fetchable export address: https (http for localhost), no credentials, query or fragment — nowhere for a secret. */\nfunction isExportUrl(x: unknown): x is string {\n if (typeof x !== 'string' || x.length > 2048) return false\n let url: URL\n try { url = new URL(x) }\n catch { return false }\n if (url.username || url.password || url.search || url.hash || url.hostname.endsWith('.')) return false\n return url.protocol === 'https:' || (url.protocol === 'http:' && LOCAL_HOSTS.has(url.hostname))\n}\n\n/**\n * Check a decoded, signature-verified payload against the contract. Pass\n * `now` (seconds since the epoch) to also check the validity window; without\n * it only the shape and the window's own bounds are checked. Returns every\n * problem found, as stable machine-readable strings (`field: problem`).\n *\n * A malformed `comments_export` does not fail the claim: it is an add-on, and\n * a Migrate still sending an older shape must not stop the trial. It is\n * dropped from `claim` and reported in `warnings`; Studio then offers the\n * file upload instead.\n */\nexport function validateMigrateStudioClaim(input: unknown, options: { now?: number } = {}): MigrateStudioClaimResult {\n return validateClaim(input, options, MIGRATE_STUDIO_CLAIM_VERSION) as MigrateStudioClaimResult\n}\n\n/**\n * The v2 counterpart of `validateMigrateStudioClaim`: same envelope, plan, repo\n * and add-on checks, no `trial_days` (a v2 claim that carries one is refused),\n * plus `github_user_id`, `email_verified`, `return_url` and `billing`.\n */\nexport function validateMigrateStudioClaimV2(input: unknown, options: { now?: number } = {}): MigrateStudioClaimV2Result {\n return validateClaim(input, options, MIGRATE_STUDIO_CLAIM_VERSION_2) as MigrateStudioClaimV2Result\n}\n\n/** Validate a claim of either version; read `result.claim.v` to tell them apart. */\nexport function validateMigrateStudioClaimAny(input: unknown, options: { now?: number } = {}):\n | { ok: true, claim: MigrateStudioClaim | MigrateStudioClaimV2, warnings?: string[] }\n | { ok: false, errors: string[] } {\n const v = isObject(input) ? input.v : undefined\n return v === MIGRATE_STUDIO_CLAIM_VERSION_2\n ? validateMigrateStudioClaimV2(input, options)\n : validateMigrateStudioClaim(input, options)\n}\n\nfunction validateClaim(\n input: unknown,\n options: { now?: number },\n version: typeof MIGRATE_STUDIO_CLAIM_VERSION | typeof MIGRATE_STUDIO_CLAIM_VERSION_2,\n): MigrateStudioClaimResult | MigrateStudioClaimV2Result {\n const errors: string[] = []\n if (!isObject(input)) return { ok: false, errors: ['payload: not an object'] }\n const x = input\n\n if (x.iss !== MIGRATE_STUDIO_CLAIM_ISSUER) errors.push('iss: unexpected issuer')\n if (x.aud !== MIGRATE_STUDIO_CLAIM_AUDIENCE) errors.push('aud: unexpected audience')\n if (x.v !== version) errors.push('v: unsupported version')\n for (const key of ['sub', 'jti', 'order_id'] as const) {\n if (!isText(x[key])) errors.push(`${key}: required`)\n }\n if (!isText(x.email) || !x.email.includes('@')) errors.push('email: invalid')\n\n if (!isSeconds(x.iat)) errors.push('iat: invalid')\n if (!isSeconds(x.exp)) errors.push('exp: invalid')\n if (isSeconds(x.iat) && isSeconds(x.exp)) {\n const ttl = x.exp - x.iat\n if (ttl <= 0) errors.push('exp: not after iat')\n else if (ttl > MIGRATE_STUDIO_CLAIM_MAX_TTL_SECONDS) errors.push('exp: lifetime too long')\n if (options.now !== undefined) {\n if (options.now >= x.exp + MIGRATE_STUDIO_CLAIM_CLOCK_SKEW_SECONDS) errors.push('exp: expired')\n if (x.iat > options.now + MIGRATE_STUDIO_CLAIM_CLOCK_SKEW_SECONDS) errors.push('iat: in the future')\n }\n }\n\n if (!(MIGRATE_STUDIO_PLANS as readonly unknown[]).includes(x.plan)) errors.push('plan: unknown plan')\n if (!Array.isArray(x.plan_evidence)) {\n errors.push('plan_evidence: required (may be empty)')\n }\n else {\n x.plan_evidence.forEach((e, i) => {\n if (!isObject(e) || !isText(e.limit_key) || !isFiniteNonNegative(e.measured) || !isFiniteNonNegative(e.limit)) {\n errors.push(`plan_evidence[${i}]: invalid`)\n }\n else if (e.capability !== undefined && !CAPABILITY_SET.has(e.capability as string)) {\n errors.push(`plan_evidence[${i}].capability: unknown`)\n }\n })\n }\n\n if (version === MIGRATE_STUDIO_CLAIM_VERSION) {\n if (typeof x.trial_days !== 'number' || !Number.isInteger(x.trial_days)\n || x.trial_days < 1 || x.trial_days > MIGRATE_STUDIO_CLAIM_MAX_TRIAL_DAYS) {\n errors.push('trial_days: out of range')\n }\n }\n else {\n if (x.trial_days !== undefined) errors.push('trial_days: not part of v2')\n if (typeof x.github_user_id !== 'string' || !/^[1-9]\\d{0,18}$/.test(x.github_user_id)) errors.push('github_user_id: invalid')\n if (typeof x.email_verified !== 'boolean') errors.push('email_verified: required')\n if (!isReturnUrl(x.return_url)) errors.push('return_url: invalid')\n const b = x.billing\n if (!isObject(b) || !isCents(b.migrate_fee_cents) || !isCents(b.quoted_total_cents) || b.quoted_total_cents <= 0\n || b.currency !== 'usd' || b.quoted_total_cents < b.migrate_fee_cents) {\n errors.push('billing: invalid')\n }\n }\n\n // v2 is signed before payment, when no delivery repository exists yet: `repo` is absent then, and\n // a placeholder would be a false signed claim. A v2 claim that carries one still has to be valid.\n const repo = x.repo\n if (repo !== undefined || version === MIGRATE_STUDIO_CLAIM_VERSION) {\n if (!isObject(repo) || repo.provider !== 'github' || !isText(repo.owner) || !isText(repo.name)\n || repo.owner.includes('/') || repo.name.includes('/')) {\n errors.push('repo: invalid')\n }\n }\n\n if (x.capabilities !== undefined) {\n if (!Array.isArray(x.capabilities)) {\n errors.push('capabilities: not an array')\n }\n else {\n x.capabilities.forEach((c, i) => {\n if (!isObject(c) || !CAPABILITY_SET.has(c.key as string)) errors.push(`capabilities[${i}]: unknown capability`)\n else if (c.scale !== undefined && c.scale !== null && typeof c.scale !== 'string') errors.push(`capabilities[${i}].scale: invalid`)\n })\n }\n }\n\n if (x.origin !== undefined && !isClaimOrigin(x.origin)) errors.push('origin: invalid')\n\n const warnings: string[] = []\n if (x.comments_export !== undefined) {\n const ce = x.comments_export\n if (!isObject(ce)) {\n warnings.push('comments_export: invalid')\n }\n else {\n if (!isExportUrl(ce.url)) warnings.push('comments_export.url: invalid')\n if (!isCompactJws(ce.token)) warnings.push('comments_export.token: invalid')\n if (!isSeconds(ce.expires_at)) warnings.push('comments_export.expires_at: invalid')\n else if (isSeconds(x.iat) && ce.expires_at <= x.iat) warnings.push('comments_export.expires_at: not after iat')\n if (typeof ce.comments !== 'number' || !Number.isInteger(ce.comments) || ce.comments < 0) warnings.push('comments_export.comments: invalid')\n }\n }\n\n if (errors.length > 0) return { ok: false, errors }\n if (warnings.length === 0) return { ok: true, claim: x as never }\n const { comments_export: _dropped, ...rest } = x\n return { ok: true, claim: rest as never, warnings }\n}\n\n/**\n * Shape-only type guard (no clock check). The signature is the consumer's job. Stricter than\n * `validateMigrateStudioClaim`: a claim whose `comments_export` would be dropped is `false` here,\n * since the guard narrows the input itself, malformed export included. To keep such a claim, use\n * `result.claim` from `validateMigrateStudioClaim`.\n */\nexport function isMigrateStudioClaim(input: unknown): input is MigrateStudioClaim {\n const result = validateMigrateStudioClaim(input)\n return result.ok && !result.warnings\n}\n\n// ─── Account state (Offer sizing) ───\n//\n// Before the Offer, Migrate asks Studio what the customer's GitHub account\n// already has, so the Studio line and its price are right: nothing (`none`:\n// \"Migrate with Studio\"), a plan that already carries the site (`covers`:\n// \"added to your plan, $0\"), or one that does not (`too_small`: upgrade).\n// Server to server, signed like a claim (same key, `iss`/`aud`/`jti`/`exp`).\n\nexport const MIGRATE_ACCOUNT_STATES = ['none', 'covers', 'too_small'] as const\nexport type MigrateAccountStateKind = (typeof MIGRATE_ACCOUNT_STATES)[number]\n\n/** The signed request body Migrate sends. */\nexport interface MigrateAccountStateRequest {\n iss: typeof MIGRATE_STUDIO_CLAIM_ISSUER\n aud: typeof MIGRATE_STUDIO_CLAIM_AUDIENCE\n jti: string\n iat: number\n exp: number\n github_user_id: string\n /** The plan discovery sized (`MigrateStudioPlan`). */\n plan: MigrateStudioPlan\n}\n\n/** Studio's answer. `year1_cents` is what the Studio line adds to the first invoice (0 when `covers`). */\nexport interface MigrateAccountStateResponse {\n state: MigrateAccountStateKind\n /** The plan the Studio line sells or keeps: the sized plan, or the account's own when it covers. */\n plan: MigrateStudioPlan\n /** Studio part of the first invoice, in cents: year 1 at 20% off (`none`), the plan difference (`too_small`), 0 (`covers`). */\n year1_cents: number\n /** What the subscription renews at after year 1, in cents: the yearly list price of `plan` (0 when `covers`). Migrate shows it and never computes it; absent from a Studio that predates it (the cart then omits the renewal line). */\n renewal_cents?: number\n /** The account's current plan, when it has a running one. */\n current_plan?: MigrateStudioPlan\n}\n\n/** Plans in ascending order; a higher index carries more. */\nconst PLAN_RANK: Record<MigrateStudioPlan, number> = { starter: 0, pro: 1 }\n\n/**\n * Check the signed request Studio receives. Pass `now` (seconds since the\n * epoch) to also check the validity window. It does no cryptography, and it\n * does not remember `jti`: Studio keeps a replay record for account-state\n * requests, as it does for claims (a repeated `jti` is refused).\n */\nexport function validateMigrateAccountStateRequest(input: unknown, options: { now?: number } = {}):\n | { ok: true, request: MigrateAccountStateRequest }\n | { ok: false, errors: string[] } {\n if (!isObject(input)) return { ok: false, errors: ['payload: not an object'] }\n const x = input\n const errors: string[] = []\n if (x.iss !== MIGRATE_STUDIO_CLAIM_ISSUER) errors.push('iss: unexpected issuer')\n if (x.aud !== MIGRATE_STUDIO_CLAIM_AUDIENCE) errors.push('aud: unexpected audience')\n if (!isText(x.jti)) errors.push('jti: required')\n if (!isSeconds(x.iat)) errors.push('iat: invalid')\n if (!isSeconds(x.exp)) errors.push('exp: invalid')\n if (isSeconds(x.iat) && isSeconds(x.exp)) {\n const ttl = x.exp - x.iat\n if (ttl <= 0) errors.push('exp: not after iat')\n else if (ttl > MIGRATE_STUDIO_CLAIM_MAX_TTL_SECONDS) errors.push('exp: lifetime too long')\n if (options.now !== undefined) {\n if (options.now >= x.exp + MIGRATE_STUDIO_CLAIM_CLOCK_SKEW_SECONDS) errors.push('exp: expired')\n if (x.iat > options.now + MIGRATE_STUDIO_CLAIM_CLOCK_SKEW_SECONDS) errors.push('iat: in the future')\n }\n }\n if (typeof x.github_user_id !== 'string' || !/^[1-9]\\d{0,18}$/.test(x.github_user_id)) errors.push('github_user_id: invalid')\n if (!(MIGRATE_STUDIO_PLANS as readonly unknown[]).includes(x.plan)) errors.push('plan: unknown plan')\n return errors.length === 0 ? { ok: true, request: x as unknown as MigrateAccountStateRequest } : { ok: false, errors }\n}\n\n/**\n * Check Studio's answer against the plan that was asked about (`requested`,\n * required: an answer is only meaningful against its question), which is what\n * Migrate does before it prices an Offer from it:\n * - `none`: no `current_plan`; `plan` is the requested plan.\n * - `too_small`: `plan` is the requested plan and `current_plan` is below it.\n * - `covers`: `current_plan` is at least the requested plan, `plan` is that\n * current plan, and nothing is added (`year1_cents` 0).\n * Errors are the stable `field: problem` strings the claim validators use.\n */\nexport function validateMigrateAccountStateResponse(input: unknown, options: { requested: MigrateStudioPlan }):\n | { ok: true, response: MigrateAccountStateResponse }\n | { ok: false, errors: string[] } {\n if (!isObject(input)) return { ok: false, errors: ['payload: not an object'] }\n const errors: string[] = []\n const planOk = (MIGRATE_STUDIO_PLANS as readonly unknown[]).includes(input.plan)\n const currentOk = (MIGRATE_STUDIO_PLANS as readonly unknown[]).includes(input.current_plan)\n if (!(MIGRATE_ACCOUNT_STATES as readonly unknown[]).includes(input.state)) errors.push('state: unknown')\n if (!planOk) errors.push('plan: unknown plan')\n if (!isCents(input.year1_cents)) errors.push('year1_cents: invalid')\n else if (input.state === 'covers' && input.year1_cents !== 0) errors.push('year1_cents: must be 0 when covers')\n else if (input.state !== undefined && input.state !== 'covers' && input.year1_cents === 0) errors.push('year1_cents: must be > 0')\n if (input.renewal_cents === undefined) { /* optional: an older Studio does not send it */ }\n else if (!isCents(input.renewal_cents)) errors.push('renewal_cents: invalid')\n else if (input.state === 'covers' && input.renewal_cents !== 0) errors.push('renewal_cents: must be 0 when covers')\n else if (input.state !== undefined && input.state !== 'covers' && input.renewal_cents === 0) errors.push('renewal_cents: must be > 0')\n if (input.current_plan !== undefined && !currentOk) errors.push('current_plan: unknown plan')\n if (input.state === 'none' && input.current_plan !== undefined) errors.push('current_plan: not allowed when none')\n if ((input.state === 'covers' || input.state === 'too_small') && input.current_plan === undefined) errors.push('current_plan: required unless none')\n const asked = (options as { requested?: unknown } | undefined)?.requested as MigrateStudioPlan\n // Fail closed: a missing or unknown `requested` (a caller bypassing the types) can never pass.\n if (!(MIGRATE_STUDIO_PLANS as readonly unknown[]).includes(asked)) errors.push('requested: unknown plan')\n else if (planOk) {\n const plan = input.plan as MigrateStudioPlan\n if (input.state === 'covers') {\n if (currentOk && PLAN_RANK[input.current_plan as MigrateStudioPlan] < PLAN_RANK[asked]) errors.push('current_plan: below the requested plan')\n if (currentOk && plan !== input.current_plan) errors.push('plan: must be the current plan when covers')\n }\n else if (input.state === 'none' || input.state === 'too_small') {\n if (plan !== asked) errors.push('plan: not the requested plan')\n if (input.state === 'too_small' && currentOk && PLAN_RANK[input.current_plan as MigrateStudioPlan] >= PLAN_RANK[asked]) errors.push('current_plan: not below the requested plan')\n }\n }\n return errors.length === 0 ? { ok: true, response: input as unknown as MigrateAccountStateResponse } : { ok: false, errors }\n}\n\n// ─── Grant status and install URL (Studio setup beside the live move) ───\n//\n// While the move runs, the Migrate page can show a Studio card: is the trial\n// running, and may the customer connect Studio's GitHub App yet? Both are\n// asked of Studio, server to server, signed like the account-state request\n// (same key, `iss`/`aud`/`jti`/`exp`, the token POSTed as `{ token }`). The\n// order is the key: Studio keeps one grant per `order_id`, and an order Studio\n// has no grant for is a 404 there, not a state.\n\nexport const MIGRATE_GRANT_STATES = ['claimed', 'bound', 'redeemed', 'revoked'] as const\n/**\n * Where the order's Studio grant stands:\n * - `claimed`: provisioned, the Studio year not started (checkout not completed).\n * - `bound`: tied to a Studio workspace, the subscription not running yet\n * (checkout open or its webhook late).\n * - `redeemed`: the subscription is running (trial or paid). Only now may the\n * customer connect GitHub, or Studio could not open a project (402).\n * - `revoked`: the grant was withdrawn (by the founder, or after a refund).\n * It may still be `installed` (withdrawn after the App was installed); Migrate\n * shows it as \"not ready\", like an unknown state.\n */\nexport type MigrateGrantState = (typeof MIGRATE_GRANT_STATES)[number]\n\n/** The signed envelope every Migrate → Studio request after provisioning carries. */\ninterface MigrateS2sEnvelope {\n iss: typeof MIGRATE_STUDIO_CLAIM_ISSUER\n aud: typeof MIGRATE_STUDIO_CLAIM_AUDIENCE\n jti: string\n iat: number\n exp: number\n /** The Migrate order whose grant is asked about. */\n order_id: string\n}\n\n/** `POST /api/migrate/grants/status`: where does this order's grant stand? */\nexport type MigrateGrantStatusRequest = MigrateS2sEnvelope\n\n/** `POST /api/migrate/grants/install-url`: where may the customer install Studio's GitHub App? */\nexport type MigrateInstallUrlRequest = MigrateS2sEnvelope\n\nexport interface MigrateGrantStatusResponse {\n state: MigrateGrantState\n /** Studio's GitHub App is installed for the grant's workspace. Only possible once `redeemed` (and after, if `revoked`). */\n installed: boolean\n}\n\nexport interface MigrateInstallUrlResponse {\n /**\n * The GitHub App install page, `https://github.com/apps/<slug>/installations/new?state=<signed>`.\n * It carries Studio's own signed `state` (grant, workspace, return address), and no repository:\n * during the move the delivery repository does not exist yet. Opened by the customer's browser.\n */\n url: string\n /** When `url` stops being useful (its `state` expires), seconds since the epoch. */\n expires_at: number\n}\n\nfunction checkS2sEnvelope(input: unknown, options: { now?: number }):\n | { ok: true, request: MigrateS2sEnvelope }\n | { ok: false, errors: string[] } {\n if (!isObject(input)) return { ok: false, errors: ['payload: not an object'] }\n const x = input\n const errors: string[] = []\n if (x.iss !== MIGRATE_STUDIO_CLAIM_ISSUER) errors.push('iss: unexpected issuer')\n if (x.aud !== MIGRATE_STUDIO_CLAIM_AUDIENCE) errors.push('aud: unexpected audience')\n if (!isText(x.jti)) errors.push('jti: required')\n if (!isText(x.order_id)) errors.push('order_id: required')\n if (!isSeconds(x.iat)) errors.push('iat: invalid')\n if (!isSeconds(x.exp)) errors.push('exp: invalid')\n if (isSeconds(x.iat) && isSeconds(x.exp)) {\n const ttl = x.exp - x.iat\n if (ttl <= 0) errors.push('exp: not after iat')\n else if (ttl > MIGRATE_STUDIO_CLAIM_MAX_TTL_SECONDS) errors.push('exp: lifetime too long')\n if (options.now !== undefined) {\n if (options.now >= x.exp + MIGRATE_STUDIO_CLAIM_CLOCK_SKEW_SECONDS) errors.push('exp: expired')\n if (x.iat > options.now + MIGRATE_STUDIO_CLAIM_CLOCK_SKEW_SECONDS) errors.push('iat: in the future')\n }\n }\n return errors.length === 0 ? { ok: true, request: x as unknown as MigrateS2sEnvelope } : { ok: false, errors }\n}\n\n/** Check the signed status request Studio receives. Same rules as `validateMigrateAccountStateRequest`; the signature and `jti` replay are the consumer's. */\nexport function validateMigrateGrantStatusRequest(input: unknown, options: { now?: number } = {}):\n | { ok: true, request: MigrateGrantStatusRequest }\n | { ok: false, errors: string[] } {\n return checkS2sEnvelope(input, options)\n}\n\n/** Check the signed install-URL request Studio receives. Same rules as the status request. */\nexport function validateMigrateInstallUrlRequest(input: unknown, options: { now?: number } = {}):\n | { ok: true, request: MigrateInstallUrlRequest }\n | { ok: false, errors: string[] } {\n return checkS2sEnvelope(input, options)\n}\n\n/** Check Studio's status answer. `installed` is refused before `redeemed`: an install can only follow a running subscription (a `revoked` grant may keep an install made earlier). */\nexport function validateMigrateGrantStatusResponse(input: unknown):\n | { ok: true, response: MigrateGrantStatusResponse }\n | { ok: false, errors: string[] } {\n if (!isObject(input)) return { ok: false, errors: ['payload: not an object'] }\n const errors: string[] = []\n if (!(MIGRATE_GRANT_STATES as readonly unknown[]).includes(input.state)) errors.push('state: unknown')\n if (typeof input.installed !== 'boolean') errors.push('installed: required')\n else if (input.installed && input.state !== 'redeemed' && input.state !== 'revoked') errors.push('installed: only once redeemed')\n return errors.length === 0 ? { ok: true, response: input as unknown as MigrateGrantStatusResponse } : { ok: false, errors }\n}\n\n/**\n * Check Studio's install-URL answer before Migrate hands it to a browser: the\n * address must be GitHub's own install page for an App (`https://github.com/apps/<slug>/installations/new`,\n * no credentials, no fragment), and must not already be expired when `now` is given.\n */\nexport function validateMigrateInstallUrlResponse(input: unknown, options: { now?: number } = {}):\n | { ok: true, response: MigrateInstallUrlResponse }\n | { ok: false, errors: string[] } {\n if (!isObject(input)) return { ok: false, errors: ['payload: not an object'] }\n const errors: string[] = []\n if (!isGithubInstallUrl(input.url)) errors.push('url: not a GitHub App install address')\n if (!isSeconds(input.expires_at)) errors.push('expires_at: invalid')\n else if (options.now !== undefined && input.expires_at <= options.now) errors.push('expires_at: expired')\n return errors.length === 0 ? { ok: true, response: input as unknown as MigrateInstallUrlResponse } : { ok: false, errors }\n}\n\nfunction isGithubInstallUrl(x: unknown): x is string {\n if (typeof x !== 'string' || x.length > 4096) return false\n let url: URL\n try { url = new URL(x) }\n catch { return false }\n return url.protocol === 'https:' && url.hostname === 'github.com' && !url.port && !url.username && !url.password && !url.hash\n && /^\\/apps\\/[\\w-]+\\/installations\\/new(\\/permissions)?\\/?$/.test(url.pathname)\n}\n\n// ─── Revoke (refund or failed delivery withdraws the Studio side) ───\n//\n// `POST /api/migrate/grants/revoke`. Migrate asks Studio, server to server, to withdraw an order's\n// grant: the Studio year that rode on the bundle's checkout is cancelled (no renewal) and the grant\n// becomes `revoked`. It is Migrate's half of a refund: the money itself is refunded in Polar by an\n// operator; this call only stops Studio from continuing a year nobody pays for. Idempotent: revoking a\n// revoked grant answers the same `revoked` state. The request is signed and single-use by `jti` under\n// its own purpose, like the status and install-url requests.\n\nexport const MIGRATE_REVOKE_REASONS = ['refund_before_delivery', 'refund_after_delivery', 'delivery_failed', 'ops'] as const\n/**\n * Why the grant is withdrawn (kept on the grant for support):\n * - `refund_before_delivery`: the customer was refunded in full before the site was delivered.\n * - `refund_after_delivery`: the customer asked within the refund window after delivery (Studio's share refunded pro rata).\n * - `delivery_failed`: the move could not be delivered and the order is refunded.\n * - `ops`: an operator withdrew it for another reason.\n */\nexport type MigrateRevokeReason = (typeof MIGRATE_REVOKE_REASONS)[number]\n\n/** `POST /api/migrate/grants/revoke`. */\nexport interface MigrateRevokeRequest extends MigrateS2sEnvelope {\n reason: MigrateRevokeReason\n}\n\n/** Studio's answer: the grant is `revoked`, and whether its subscription was cancelled by this call. */\nexport interface MigrateRevokeResponse {\n state: 'revoked'\n /** Studio's GitHub App is installed for the grant's workspace (an install made earlier is not undone). */\n installed: boolean\n /** The Studio subscription was cancelled now. `false` when there was none (never paid) or it had already ended. */\n subscription_canceled: boolean\n}\n\n/** Check the signed revoke request Studio receives. The envelope rules of the status request, plus a known `reason`. */\nexport function validateMigrateRevokeRequest(input: unknown, options: { now?: number } = {}):\n | { ok: true, request: MigrateRevokeRequest }\n | { ok: false, errors: string[] } {\n const base = checkS2sEnvelope(input, options)\n const errors = base.ok ? [] : [...base.errors]\n const reason = isObject(input) ? input.reason : undefined\n if (!(MIGRATE_REVOKE_REASONS as readonly unknown[]).includes(reason)) errors.push('reason: unknown')\n return errors.length === 0 ? { ok: true, request: input as unknown as MigrateRevokeRequest } : { ok: false, errors }\n}\n\n/** Check Studio's revoke answer: it must say `revoked` and carry both flags. */\nexport function validateMigrateRevokeResponse(input: unknown):\n | { ok: true, response: MigrateRevokeResponse }\n | { ok: false, errors: string[] } {\n if (!isObject(input)) return { ok: false, errors: ['payload: not an object'] }\n const errors: string[] = []\n if (input.state !== 'revoked') errors.push('state: must be revoked')\n if (typeof input.installed !== 'boolean') errors.push('installed: required')\n if (typeof input.subscription_canceled !== 'boolean') errors.push('subscription_canceled: required')\n return errors.length === 0 ? { ok: true, response: input as unknown as MigrateRevokeResponse } : { ok: false, errors }\n}\n\n// ─── Provision (Migrate with Studio: one checkout for the move and the Studio year) ───\n//\n// `POST /api/migrate/provision`. Migrate asks Studio, server to server, to set up the\n// customer's Studio side before they pay: find or create the user by GitHub id, record the\n// grant, and open ONE Polar checkout whose first invoice is the whole bundle\n// (`billing.quoted_total_cents`: Migrate fee + Studio year 1). The request is the signed\n// `MigrateStudioClaimV2` itself, POSTed as `{ token }` and single-use by `jti` under its own\n// purpose, so a provision token cannot also be replayed as a claim link. Studio recomputes the\n// Studio line (`MigrateAccountStateResponse`) and refuses a quote it does not agree with.\n// The customer then pays at `checkout_url`; Migrate learns of the payment from its own webhook.\n\n/** What every provision answer carries. */\ninterface MigrateProvisionBase {\n /** The Studio grant for the order (one per order; a repeated provision returns the same one). */\n grant_id: string\n /** The plan the Studio line sells or keeps (`MigrateAccountStateResponse.plan`). */\n plan: MigrateStudioPlan\n}\n\n/** A Studio line to pay: the customer follows `checkout_url` for the whole bundle. */\nexport interface MigrateProvisionCheckout extends MigrateProvisionBase {\n /** `claimed` on a fresh provision; a repeat may find the grant further along (never `redeemed`: that is `MigrateProvisionCovered`). */\n state: Exclude<MigrateGrantState, 'redeemed'>\n /** The Studio workspace the grant is tied to, when it already is (an existing subscriber's, or after binding). */\n workspace_slug?: string\n /** Polar's checkout page for the bundle: `https://polar.sh/...` or `https://sandbox.polar.sh/...`, no credentials or fragment. Opened by the customer's browser. */\n checkout_url: string\n /** The first invoice Studio put on the checkout, in cents. Must equal the request's `billing.quoted_total_cents`. */\n amount_cents: number\n /** When `checkout_url` stops being payable, seconds since the epoch. */\n checkout_expires_at: number\n}\n\n/**\n * Nothing for Studio to charge: the account already runs a plan that covers the sized one, and the\n * grant is tied to that plan's workspace (`covers`). There is no checkout and no Studio amount; the\n * request's quote is Migrate's own fee.\n */\nexport interface MigrateProvisionCovered extends MigrateProvisionBase {\n state: 'redeemed'\n /** The workspace of the running plan the delivered site joins. */\n workspace_slug: string\n checkout_url?: undefined\n amount_cents?: undefined\n checkout_expires_at?: undefined\n}\n\n/** Studio's answer to a provision request. */\nexport type MigrateProvisionResponse = MigrateProvisionCheckout | MigrateProvisionCovered\n\n/**\n * Check Studio's provision answer before Migrate redirects a browser to it. `quoted_total_cents` is the\n * request's quote (required, like `requested` in the account-state check: an answer only means something\n * against its question); a different amount is refused, and so is an expired checkout when `now` is given.\n * A `redeemed` answer (the account's running plan covers the order) is the other shape: it must name the\n * workspace and carry no checkout, amount or expiry, so a browser is never sent anywhere to pay Studio.\n */\nexport function validateMigrateProvisionResponse(input: unknown, options: { quoted_total_cents: number, now?: number }):\n | { ok: true, response: MigrateProvisionResponse }\n | { ok: false, errors: string[] } {\n if (!isObject(input)) return { ok: false, errors: ['payload: not an object'] }\n const errors: string[] = []\n if (!isText(input.grant_id) || input.grant_id.length > 128) errors.push('grant_id: required')\n if (!(MIGRATE_GRANT_STATES as readonly unknown[]).includes(input.state)) errors.push('state: unknown')\n if (!(MIGRATE_STUDIO_PLANS as readonly unknown[]).includes(input.plan)) errors.push('plan: unknown plan')\n if (input.workspace_slug !== undefined && (typeof input.workspace_slug !== 'string' || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(input.workspace_slug))) errors.push('workspace_slug: invalid')\n if (input.state === 'redeemed') {\n // Covered by a running plan: nothing to pay at Studio, so nothing to follow or to compare with the quote.\n if (typeof input.workspace_slug !== 'string') errors.push('workspace_slug: required when the plan covers the order')\n for (const key of ['checkout_url', 'amount_cents', 'checkout_expires_at'] as const)\n if (input[key] !== undefined) errors.push(`${key}: not allowed when the plan covers the order`)\n return errors.length === 0 ? { ok: true, response: input as unknown as MigrateProvisionResponse } : { ok: false, errors }\n }\n if (!isPolarCheckoutUrl(input.checkout_url)) errors.push('checkout_url: not a Polar checkout address')\n const quoted = (options as { quoted_total_cents?: unknown } | undefined)?.quoted_total_cents\n if (!isCents(input.amount_cents) || input.amount_cents === 0) errors.push('amount_cents: invalid')\n // Fail closed: a missing or invalid quote (a caller bypassing the types) can never pass.\n else if (!isCents(quoted) || quoted === 0 || input.amount_cents !== quoted) errors.push('amount_cents: not the quoted total')\n if (!isSeconds(input.checkout_expires_at)) errors.push('checkout_expires_at: invalid')\n else if (options.now !== undefined && input.checkout_expires_at <= options.now) errors.push('checkout_expires_at: expired')\n return errors.length === 0 ? { ok: true, response: input as unknown as MigrateProvisionResponse } : { ok: false, errors }\n}\n\nfunction isPolarCheckoutUrl(x: unknown): x is string {\n if (typeof x !== 'string' || x.length > 4096) return false\n let url: URL\n try { url = new URL(x) }\n catch { return false }\n return url.protocol === 'https:' && (url.hostname === 'polar.sh' || url.hostname === 'sandbox.polar.sh') && !url.port && !url.username && !url.password && !url.hash\n && url.pathname.startsWith('/checkout/')\n}\n","// ─── Project plan ───\n//\n// The one holistic decision of a migration, between the fact pack (what the\n// WordPress site is) and the writer (the Astro + Contentrain project). It is\n// written once per site — by the planner from the facts, the builder mapping\n// table and the narrow decisions — and then applied deterministically: models\n// and content come from the import, components from the kit or the site\n// writer, and every page is composed from placements whose props are bound to\n// content. Nothing in a plan is page content: it names where content comes\n// from, never copies it. That keeps the cost proportional to the number of\n// components, not pages, and keeps Studio edits reaching the site.\n//\n// `site` mirrors the starter's `src/site.config.ts` field for field, plus the\n// three files the deterministic writer also owns: `astro.config` (`url`),\n// `redirects.json` and the `@theme` block of `global.css`.\n//\n// Addresses the starter owns are not plan routes: `/search/`, `/rss.xml`,\n// `/404`, `robots.txt` and the sitemap. A plan never emits them.\n\nimport { isTitleFieldType, titleFieldTarget, type FieldDef, type ModelKind } from './index.js'\n\nexport const PROJECT_PLAN_FORMAT = 'contentrain-project-plan@1'\n\n/** Semantic design roles the kit reads — the starter's `@theme` names without the `--` prefix. */\nexport const PLAN_TOKEN_ROLES = [\n 'color-surface', 'color-surface-muted', 'color-ink', 'color-ink-muted', 'color-line', 'color-accent', 'color-accent-ink',\n 'font-sans', 'font-serif', 'font-mono', 'container-prose', 'container-page', 'container-wide', 'radius-card',\n // The source theme's type and shape (theme.json styles): absent means the kit's own values.\n 'font-weight-heading', 'font-weight-body', 'text-body', 'leading-body', 'leading-heading', 'radius-control', 'radius-image', 'spacing-gutter',\n // The theme's type scale and rhythm: the site's theme applies them on the kit's markers (`data-kit-*`, `data-cr-part`).\n // `spacing-section` is the distance between two consecutive top-level sections, not a padding: each section gets half.\n // `spacing-title`: the space under a page's or post's title (the prose h1; absent: the kit's own, 0.8888889em or 0.8333333em at the large prose size).\n // `container-inset`: the page frame's `container-page` is the theme's wide size as CONTENT width, so the frame adds\n // this much on each side (the root padding); absent: 0, the starter's frame holds its gutter inside the width.\n 'spacing-title', 'container-inset',\n 'text-nav', 'text-footer', 'text-heading-1', 'text-heading-2', 'text-heading-3', 'spacing-section',\n // The source's link colour where it is not the accent (Hello Elementor: #c36 links, a grey button). Absent: the accent.\n 'color-link',\n // The page behind the content and the card on it, where the source tints one and floats the other (Astra: a blue-grey\n // page, white shadowed cards). Absent: both are the surface. `shadow-card` is a box-shadow, `spacing-card` the card's padding.\n 'color-page', 'color-card', 'shadow-card', 'spacing-card',\n // The brand colour a page builder declares and paints on a band of its own (Elementor's secondary on a call to action),\n // where it is not the accent the buttons paint; `color-brand-ink` is the text on it. Absent: the accent and its ink.\n 'color-brand', 'color-brand-ink',\n] as const\nexport type PlanTokenRole = (typeof PLAN_TOKEN_ROLES)[number]\n\nexport interface ProjectPlan {\n format: typeof PROJECT_PLAN_FORMAT\n source: PlanSource\n site: PlanSite\n /** The starter's single `BaseLayout`: chrome every route shares. */\n layout: { header?: PlanPlacement, footer?: PlanPlacement }\n models: PlanModel[]\n components: PlanComponent[]\n routes: PlanRoute[]\n /**\n * What becomes of each behavior the fact pack recorded (form, search, embed, accordion, …), one\n * record per fact behavior id — so none is dropped without a reason a reviewer can read.\n */\n behaviors?: PlanBehavior[]\n /** Every decision the plan rests on, with who made it — the audit trail a reviewer reads. */\n decisions?: PlanDecisionRecord[]\n}\n\nexport interface PlanSource {\n /** The WordPress origin, `https://example.com`. */\n origin: string\n builder: 'gutenberg' | 'elementor' | 'divi' | 'classic'\n /** Hash of the fact pack the plan was made from; a plan is only valid for those facts. */\n facts: string\n /** Version of the builder mapping table the plan applied. */\n mapping?: string\n}\n\n// ─── Site ───\n\n/** WordPress-style address pattern: `/blog/:slug/`, `/:path/`; tokens `:slug :path :year :month :day :id`. */\nexport type PlanPermalink = string\n\n/** From `min` px of viewport width up, a column `width` px wide, centred. */\nexport interface ChromeFrameStep { min: number, width: number }\n/** From `min` px of viewport up, the post cards run in `columns` columns. */\nexport interface ChromeColumnStep { min: number, columns: number }\n\nexport interface PlanSite {\n /** Public URL the site is built for (`astro.config` `site`). */\n url: string\n title: string\n description?: string\n /** Default locale, short form (`en`, `tr`). */\n locale: string\n permalinks: { post: PlanPermalink, page: PlanPermalink, category: PlanPermalink, tag: PlanPermalink, author: PlanPermalink, blog: PlanPermalink }\n home: { kind: 'posts' } | { kind: 'page', slug: string }\n postsPerPage: number\n /**\n * Menu slugs (`menus.slug`, as WordPress named them) for the starter's navigation areas — `getMenu(slug)`.\n * `footer` lists the footer's menus in the source's order, one column each (at most 4); empty for none.\n * A plan written before 1.25 has one slug, or `'none'`: read it through {@link footerMenusOf}.\n */\n menus: { primary: string, footer: string | string[] }\n /**\n * A single post as the source's single template lays it out: the header parts in their order, links\n * to the previous and next post (`core/post-navigation-link`), and how many other posts a list under\n * it shows (a `core/query` after the content; 0 for none). `moreIncludesCurrent`: that list also shows the post\n * being read, as the source's query does (it does not exclude the current post); absent, the post is left out. `adjacentText`: the size the previous/next links print at (a CSS size: the theme's preset on `core/post-navigation-link`); absent, the starter's own. `moreText`: the size that list prints its\n * titles at (a CSS size: the theme's `large` preset on `core/post-title`); absent, the starter's own.\n * Absent: the starter's own layout.\n */\n post?: { header: Array<'terms' | 'title' | 'byline' | 'cover'>, adjacent: boolean, adjacentText?: string, more: number, moreIncludesCurrent?: boolean, moreText?: string }\n /**\n * Post lists (blog index, archives): `cards`, or `full` when the source's query loop shows each post's\n * content; `list` is cards in one column, as themes whose archive is a single stack of posts show\n * it. `heading` shows the index's title on the front page. Absent: cards without a heading.\n * `text`: the size of each post's content in a `full` list, when the source's loop sets its own\n * (Twenty Twenty-Five: `has-medium-font-size` on the query's post content); absent, the body size.\n * `tone`: `muted` when the source's query loop sets its own text colour to the theme's muted one (its titles\n * carry their own colour, so only the meta text follows); absent, the body colour.\n * `surface`: cards are floating cards (the `color-card`, `radius-card`, `shadow-card` and `spacing-card` roles), not\n * bare. `byline`: each card prints its author and date below the title, the category above it, as the source's do.\n */\n lists?: { display: 'cards' | 'list' | 'full', heading: boolean, text?: string, tone?: 'muted', surface?: boolean, byline?: boolean }\n /**\n * The interface text the source theme itself printed (its \"Read more\" link, skip link, search placeholder, the\n * previous and next labels), by the `site/ui-strings` key it answers (`blog.read_more`, `nav.skip`): written over the\n * site's bundled table, because the source's wording is what its visitors saw. Plain text, no markup; at most 100.\n */\n uiStrings?: Record<string, string>\n /**\n * What the source's own page for a custom post type showed, by the type's single route id (`type-project`):\n * `adjacent` = links to the previous and next entry under it. The starter's entry view prints them for that\n * type only. `more` = a list of that many other entries under it (0 to 20); `moreIncludesCurrent`: the list also\n * shows the entry being read, as the source's query does; `moreOf: 'posts'`: the list holds the newest blog posts, as a\n * theme's single template does whatever the type, not other entries of the same type; `moreText`: the size it prints its titles at (a CSS size). Absent (or a type left out): none.\n */\n entryLayouts?: Record<string, { adjacent: boolean, more?: number, moreIncludesCurrent?: boolean, moreOf?: 'posts', moreText?: string }>\n /**\n * What the source's footer prints of the three things the starter's footer prints by default, each only when the\n * source does (a migration reads them off the rendered footer). `feed`: a visible \"RSS feed\" link in the last\n * column (the feed file itself, `/rss.xml`, is always kept for SEO). `border`: a border on the footer's top side.\n * `copyright`: the line it prints (`{year}` for the current year), or `false` when it prints none. The \"Powered by\n * WordPress\" credit is platform chrome and is never carried. Absent, or a key absent: the starter's own (the link,\n * the border on the default tone, \"© year Site\"). `chrome.copyright` is the older place for the line.\n */\n footer?: { feed?: boolean, border?: boolean, copyright?: string | false }\n /**\n * The header and footer as the source's theme prints them, where they differ from the starter's.\n * Every key is optional; absent, the starter's own. `brand`: the site title's size in the header\n * (Hello Elementor: a 2.5rem heading). `tagline`: the header shows the tagline under the title.\n * `copyright`: the footer's line as the source prints it (\"All rights reserved\"; `{year}` for the\n * current year), instead of \"© year Site\". `titleLinks`: post titles in lists take the link colour, as the source's do.\n * `navLinks`: so does the header navigation. `footerAlign`: `center` when the source's footer is\n * one centred column; absent, brand and links to the sides. `frame`: the width the header's and footer's\n * content is held to where the theme boxes it, read from the source's renders: from `min` px of viewport\n * up it is `width` px wide and centred (Hello Elementor: 768 → 600, 1280 → 1140); below the first step,\n * and absent, the starter's own fluid frame. `listFrame`: the same for the post lists' main column.\n * `postFrame`: the same for a single post's reading column, where the theme reads narrower than its frame\n * (Astra: 630 in a 1200 frame). `listColumns`: how many columns the post cards run in from `min` px of viewport up,\n * as the source's lists show them (Astra: one column at 768, three at 1280); absent, the starter's own grid.\n * `footerColumns`: the footer's link columns packed at the end edge at their own width, as the source's are (a theme's\n * footer is the brand left and two menus on the right, not a grid spread from the centre): `gap` px between the\n * columns, the links `size` px, and `narrowGap` px when the columns stay side by side below 768 (absent: they stack).\n * Absent, the starter's own: the columns share the footer's width.\n * `header`: what the source's header does differently from the starter's, each key only where it does (computed styles):\n * `border: false` draws no line under the bar; `brandWeight` and `brandTracking` set the site name's weight and\n * letter-spacing; `offsetTop` is the space above the bar and `padTop`/`padBottom` the space inside it above and below\n * its content (CSS lengths, a fluid theme's as a `clamp()`). A key absent: the starter's own.\n */\n chrome?: {\n brand?: string\n tagline?: boolean\n copyright?: string\n titleLinks?: boolean\n navLinks?: boolean\n footerAlign?: 'start' | 'center'\n frame?: ChromeFrameStep[]\n listFrame?: ChromeFrameStep[]\n postFrame?: ChromeFrameStep[]\n listColumns?: ChromeColumnStep[]\n footerColumns?: { gap: number, size?: number, narrowGap?: number }\n header?: { border?: false, brandWeight?: string, brandTracking?: string, offsetTop?: string, padTop?: string, padBottom?: string }\n }\n studio?: { baseUrl: string, projectId: string }\n /** `redirects.json`: old path → new path, or with a status other than 301. */\n redirects: Record<string, string | { status: number, destination: string }>\n tokens: PlanTokens\n}\n\nexport interface PlanTokens {\n /** Values of the kit's roles. A role left out falls back to the starter's default. */\n roles: Partial<Record<PlanTokenRole, string>>\n /** The site's own scale beyond the roles (`--color-brand-2: …`), as `@theme` declarations. */\n extra?: Record<string, string>\n /** WordPress preset slug → value per kind, for `has-<slug>-*` classes left in rich-text bodies. */\n presets?: Partial<Record<'color' | 'font-size' | 'font-family' | 'spacing' | 'shadow', Record<string, string>>>\n /** Fonts to self-host: files are fetched once and served from the project. */\n fonts?: { family: string, weight: string, style: string, unicodeRange?: string, files: string[] }[]\n}\n\n// ─── Models ───\n\n/**\n * A content model of the project. Imported models (`posts`, `pages`, `categories`, `tags`, `authors`,\n * `media`, `menus`, `menu-items`, `site`, `ui-strings`) are written by wp-import and only referenced;\n * a `plan` model is new — section content lifted out of builder pages into editable fields.\n */\nexport interface PlanModel {\n id: string\n kind: ModelKind\n origin: 'import' | 'plan'\n /** What a `plan` model needs to be a Contentrain model: display name, storage domain (`.contentrain/content/<domain>/<id>`), locales, and the field an entry list shows. */\n name?: string\n /** What the model holds, for Studio's model list (the model definition's `description`). */\n description?: string\n domain?: string\n i18n?: boolean\n /** Required for a `plan` collection/document: a text-like field (`validate` refuses a model without one). */\n title_field?: string\n /** Fields of a `plan` model (an imported model's fields are the import's). */\n fields?: Record<string, FieldDef>\n /** How entries of a `plan` model are filled from the source, deterministically. */\n extract?: PlanExtraction[]\n /**\n * A Studio form: submissions from the site's form are saved as entries of this model (a `plan`\n * collection with `i18n: false` whose fields are the source form's fields). Written to the model definition's `form` key.\n */\n form?: PlanFormConfig\n}\n\n/**\n * The form settings Studio reads from a model definition's `form` key (Studio's `FormConfig`, kept here\n * so the plan does not depend on Studio). Captcha, honeypot and notifications replace what the form\n * plugin did on WordPress; mail recipients and webhooks are never carried over.\n */\nexport interface PlanFormConfig {\n enabled: boolean\n /** Accept submissions from the public site (no Studio session). */\n public: boolean\n /** Model fields the public form shows and accepts, in order. */\n exposedFields: string[]\n /** Field → required on the form, where it differs from the model field's own `required`. */\n requiredOverrides?: Record<string, boolean>\n honeypot?: boolean\n captcha?: 'turnstile' | null\n successMessage?: string\n autoApprove?: boolean\n /** Email the workspace owner/admins on every submission. */\n notifications?: boolean\n}\n\n/**\n * Fill a plan model from builder elements. Each matched element becomes one entry (collection) or\n * the entry (singleton); each field reads a path of the element — the same paths the fact pack's\n * component variance reported. The engine applies it to every page; the model never retypes content.\n */\nexport interface PlanExtraction {\n /** Builder element (`core/cover`, `elementor/icon-box`), fact component id (`repeat:1x2y`), or `range:<from>-<to>` (top-level body blocks, for prose runs). */\n from: string\n /** Only on these WordPress entries (ids); all pages when absent. */\n pages?: number[]\n /** Entry id for a matched element: `page-slug`, `page-slug:index`, or a fixed id for a singleton. */\n entryId: string\n /**\n * Apply this builder mapping rule (`<builder>:<match>`, astro-kit `mapping/<builder>.json`) to the element:\n * its props, `into`/`each`/`item` fill the entry's fields of the same names. `fields` then only adds to it.\n */\n rule?: string\n /**\n * The object field of a page singleton this extraction fills (`hero`, `services`): the element is one\n * section, and `rule`'s props / `fields` fill that field's keys instead of the entry's top level.\n */\n field?: string\n /** Model field → `<path>|<prop>` inside the element (`0.1.0|text`, `0.0|src`), `attr:<name>`, or an element expression (`dom:`, `img:`, `link:`, `html:`). */\n fields?: Record<string, string>\n}\n\n// ─── Components ───\n\nexport interface PlanComponent {\n /**\n * Plan-level name (`Hero`, `PostCard`). A `site` component is written to `src/components/<id>.astro`; a\n * `kit` component is copied by the kit's copy planner to `src/components/kit/<kit-id>/`.\n */\n id: string\n /** Copied from the kit, or written for this site by the writer. Variants are per placement. */\n origin: 'kit' | 'site'\n kit?: { id: string }\n /** A `site` component's props. For a `kit` component the catalog is the source and this stays empty. */\n props?: Record<string, FieldDef>\n /** Fact component ids / builder elements this component stands for. */\n covers?: string[]\n /** For `site` components: what the writer must reproduce (fact template + region paths to look at). */\n brief?: { template: string, regions: string[], notes?: string }\n}\n\n// ─── Routes ───\n\nexport type PlanRouteKind = 'home' | 'post' | 'page' | 'blog' | 'category' | 'tag' | 'author' | 'custom'\n\nexport interface PlanRoute {\n id: string\n kind: PlanRouteKind\n /** Address pattern (a `site.permalinks` value or a custom one). */\n pattern: PlanPermalink\n /** Fact template this route renders like. */\n template: string\n /**\n * Entries the route builds one page each for (`getStaticPaths`); none for a single page. Routes over the\n * same model must not share an entry: disjoint `where: { wp_id: [...] }` sets plus at most one catch-all.\n */\n source?: { model: string, where?: Record<string, unknown>, paginate?: number }\n /**\n * The page model whose object fields hold this page's sections: a singleton (`page-about`), or, for pages\n * that share one layout, a collection that is also the route's `source` (one entry per page). The page's\n * text lives there: the writer does not render the source entry's `body`, and extraction leaves it empty.\n */\n page?: string\n /**\n * `rich-text`: the entry body renders as prose (posts, simple pages).\n * `composed`: the page is the `sections` below, top to bottom.\n */\n body: 'rich-text' | 'composed'\n /** The page body, in document order (chrome is `layout`'s). */\n sections: PlanPlacement[]\n}\n\nexport interface PlanPlacement {\n /** `PlanComponent.id`. */\n component: string\n /**\n * The section's name on its page (`hero`, `services`, `selected-work`): the page singleton's field for a\n * `section` binding. Unique within a route.\n */\n id?: string\n /**\n * What classified the section: a mapping table's section rule (`<builder>:section:<rule id>`, whoever\n * picked it: rule, cache or JEW), `opus` for a site component written for it, `prose` for the fallback.\n */\n rule?: string\n variant?: Record<string, string>\n bind: PlanBinding\n /** Interface strings from `ui-strings`: prop → key (`prevLabel: 'pagination.prev'`). */\n labels?: Record<string, string>\n /**\n * How wide the section's content runs, as the source block's alignment: `content` for a block without\n * one (the theme's contentSize, `container-prose`), `wide` for `alignwide`/`alignfull` (`container-page`).\n * The section's background spans the page either way. Absent: `wide`.\n */\n width?: 'content' | 'wide'\n}\n\n/**\n * A prop value, never content itself. A small closed set, the same in the mapping table's `from`:\n *\n * - `field:<name>` — a field of the bound entry\n * - `media:<field>` — an image field resolved to the kit's `ImageInput` (`src`, `alt`, `width`, `height`)\n * - `ref:<field>.<target field>` — a field of the entry a relation points at (`ref:category.name`)\n * - `href:self` — the bound entry's own address; `href:<relation field>` — the related entry's, via `site.permalinks`\n * - `term:<relation field>` — a related term as `{ label, href }` (the first of a multi-relation; all of them with `into`)\n * - `ui:<key>` — an interface string of `ui-strings`\n * - `site:<field>` — a field of the `site` singleton (`site:title`, `site:logo`)\n * - `menu:primary|footer` — the items of the menu `site.menus` names for that area (the first footer menu)\n * - `page:base|current|total|breadcrumb` — what the route knows about the page being built\n * - `const:<value>` — a layout switch, never content: `true`, `false`, a number, or a lowercase identifier\n * (`const:contact`); anything that reads like text is refused, so page copy cannot be baked into code\n *\n * The builder mapping tables use the same set plus element expressions; one placement can mix them\n * (a header's `siteName` from `site:title`, its `items` from `menu:primary`).\n */\nexport type PlanValue = `field:${string}` | `media:${string}` | `ref:${string}` | `href:${string}` | `term:${string}` | `ui:${string}` | `site:${string}` | `menu:${string}` | `page:${string}` | `const:${string}`\n\nexport const PLAN_VALUE_PATTERN = /^(?:field:[\\w-]+|media:[\\w-]+|ref:[\\w-]+\\.[\\w-]+|href:(?:self|[\\w-]+)|term:[\\w-]+|ui:[\\w.-]+|site:[\\w-]+|menu:(?:primary|footer)|page:(?:base|current|total|breadcrumb)|const:(?:true|false|-?\\d+(?:\\.\\d+)?|[a-z][a-z0-9_-]{0,31}))$/\n\n/** Where a placement's props come from. */\nexport type PlanBinding =\n /** The route's own entry. */\n | { kind: 'entry', props: Record<string, PlanValue> }\n /** One entry of a model (a singleton, or a fixed entry id). */\n | { kind: 'model', model: string, entry?: string, props: Record<string, PlanValue> }\n /**\n * A list: a query over a collection. With `into`, the list fills that one array prop (`card-grid.items`,\n * `faq.items`) and `item` maps each element; without it, the component repeats once per entry (`post-card`)\n * and `item` maps its props.\n */\n | { kind: 'collection', model: string, where?: Record<string, unknown>, sort?: string, limit?: number, into?: string, item: Record<string, PlanValue>, props?: Record<string, PlanValue> }\n /** A menu by slug; its items fill `into` (default `items`). */\n | { kind: 'menu', menu: string, into?: string, props?: Record<string, PlanValue> }\n /** Only fixed values and labels. */\n | { kind: 'static', props: Record<string, PlanValue> }\n /**\n * A page section: the object field `field` of a singleton (or of entry `entry`, or of the route's own entry\n * when `model` is the route's template collection) feeds the component. Its keys are the component's\n * content props in snake_case (`cta_label` → `ctaLabel`, DECISIONS §7b); the view passes each one\n * explicitly. `props` adds fixed values (a variant-like setting, a label) after the field's.\n */\n | { kind: 'section', model: string, entry?: string, field: string, props?: Record<string, PlanValue> }\n\n// ─── Behaviors ───\n\n/**\n * - `component` — a placed component carries it (`component`: a `PlanComponent` id). An element the\n * mapping already turned into a section (core/details → faq) points at that section's component; it\n * is not placed a second time.\n * - `starter` — the starter already provides it (`feature`: site search is Pagefind on `/search/`).\n * - `prose` — it sits in a rich-text body and the prose renderer handles it (an oEmbed in a post).\n * - `drop` — deliberately not reproduced; `reason` says why.\n * - `needs_review` — no counterpart yet (an exit-intent popup, a payment form); `reason` says what a person must decide.\n */\nexport type PlanBehaviorOutcome = 'component' | 'starter' | 'prose' | 'drop' | 'needs_review'\n\nexport const PLAN_STARTER_FEATURES = ['search'] as const\nexport type PlanStarterFeature = (typeof PLAN_STARTER_FEATURES)[number]\n\nexport interface PlanBehavior {\n /** The fact pack's behavior id (`form:3fa9c21e`, `embed:0c1d2e3f`, `nav:t2`). */\n fact: string\n outcome: PlanBehaviorOutcome\n /** `PlanComponent` id, for `component`. */\n component?: string\n /** Starter feature, for `starter`. */\n feature?: PlanStarterFeature\n /** The model a form's submissions are saved to (a plan model with `form`). */\n model?: string\n /** `<code>: <sentence>`, required for `drop` and `needs_review` (`form-feature: Studio forms has no payment step`). */\n reason?: string\n}\n\nexport const PLAN_BEHAVIOR_REASON = /^[a-z][a-z0-9-]*: \\S.*$/\n\n// ─── Decisions ───\n\nexport interface PlanDecisionRecord {\n /** The fact decision id (`field_type:repeat:1x2y:0.1.0:text`) or a plan-level question. */\n id: string\n answer: string\n by: 'rule' | 'cache' | 'jew' | 'haiku' | 'sonnet' | 'opus' | 'fallback' | 'human'\n /** The exact model id that decided (`claude-sonnet-5`), when `by` is a model. */\n model?: string\n confidence?: number\n}\n\n// ─── Validation ───\n\nexport interface ProjectPlanReport { errors: string[], warnings: string[] }\n\nconst PERMALINK = /^\\/(?:[^/\\s]+\\/)*$/\nconst IMPORTED_MODELS = new Set(['posts', 'pages', 'categories', 'tags', 'authors', 'media', 'menus', 'menu-items', 'site', 'ui-strings'])\n\n/**\n * Structural checks a plan must pass before anything is written: every reference resolves (routes →\n * components → models), permalinks are well-formed, kit components carry a kit id, plan models carry\n * their fields. Kit catalog and fact checks (does the kit id exist, does the template exist) belong to\n * the caller, which holds those documents.\n */\n/** The footer's menus as a list: an older plan's single slug is one menu, its `'none'` is none. */\nexport function footerMenusOf(site: Pick<PlanSite, 'menus'>): string[] {\n const footer = site.menus.footer\n if (typeof footer === 'string') return footer === 'none' || footer === '' ? [] : [footer]\n return [...footer]\n}\n\n/** A letter-spacing the starter writes into a style: `normal`, or a signed length. */\nconst LETTER_SPACING = /^(?:normal|-?\\d+(?:\\.\\d+)?(?:px|rem|em))$/\n\n/** A font size the starter writes into a style: a length, or a `clamp()`/`calc()`/`min()`/`max()` of lengths (a theme's fluid size). */\nconst CSS_LENGTH = /^(?!.*url\\()(?:\\d+(?:\\.\\d+)?(?:px|rem|em|%)|(?:clamp|calc|min|max)\\([\\d\\s.,+*/()a-z%-]+\\))$/\n\n/**\n * How many object levels a field nests: 0 for a scalar or a list of scalars, 1 for an object of scalars,\n * 2 for an object holding a list of objects (`hero.actions[]`). A list adds no level of its own.\n */\nexport function fieldDepth(field: FieldDef): number {\n const children = [...Object.values(field.fields ?? {}), ...(typeof field.items === 'object' ? [field.items] : [])]\n return (field.type === 'object' ? 1 : 0) + Math.max(0, ...children.map(fieldDepth))\n}\n\n/**\n * Placement `rule`: a mapping table's section rule (`elementor:section:hero.split`) or element rule\n * (`gutenberg:element:core/details`, `gutenberg:element:core/template-part:header`), a site component\n * (`opus`), or the rich-text fallback.\n */\nexport const PLAN_SECTION_RULE = /^(?:(?:gutenberg|elementor|divi|classic):(?:section:[a-z][\\w.-]*|element:[a-z][\\w./:-]*)|opus|prose)$/\n\n/** A pixel measure: a finite number from 0 to `max`. */\nconst pxWithin = (v: unknown, max: number): boolean => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= max\n\n/** A content prop's field name in a section object: the prop in snake_case (`ctaLabel` → `cta_label`). */\nexport function sectionFieldName(prop: string): string {\n return prop.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`)\n}\n\nexport function validateProjectPlan(plan: ProjectPlan): ProjectPlanReport {\n const errors: string[] = []\n const warnings: string[] = []\n if (plan.format !== PROJECT_PLAN_FORMAT) errors.push(`format is not ${PROJECT_PLAN_FORMAT}`)\n if (!/^https?:\\/\\/[^/]+$/.test(plan.source?.origin ?? '')) errors.push('source.origin is not an http(s) origin without a path')\n const site = plan.site\n for (const [key, value] of Object.entries(site?.permalinks ?? {})) {\n if (!PERMALINK.test(value)) errors.push(`site.permalinks.${key} must start and end with \"/\" (${value})`)\n }\n if (!Number.isSafeInteger(site?.postsPerPage) || site.postsPerPage < 1) errors.push('site.postsPerPage is not a positive integer')\n if (site?.post) {\n const parts = site.post.header ?? []\n if (parts.some(part => !['terms', 'title', 'byline', 'cover'].includes(part)) || new Set(parts).size !== parts.length) errors.push('site.post.header lists a part twice or one the starter does not have')\n if (!Number.isSafeInteger(site.post.more) || site.post.more < 0 || site.post.more > 20) errors.push('site.post.more is not a count from 0 to 20')\n if (site.post.moreIncludesCurrent !== undefined && typeof site.post.moreIncludesCurrent !== 'boolean') errors.push('site.post.moreIncludesCurrent is not true or false')\n if (site.post.adjacentText !== undefined && !CSS_LENGTH.test(site.post.adjacentText)) errors.push(`site.post.adjacentText ${site.post.adjacentText} is not a CSS size`)\n if (site.post.moreText !== undefined && !CSS_LENGTH.test(site.post.moreText)) errors.push(`site.post.moreText ${site.post.moreText} is not a CSS size`)\n }\n for (const [key, value] of Object.entries(site?.uiStrings ?? {})) {\n if (!/^[a-z][a-z0-9]*(?:\\.[a-z][a-z0-9_]*)+$/.test(key)) errors.push(`site.uiStrings key ${key} is not a ui-strings key`)\n else if (typeof value !== 'string' || !value.trim() || value.length > 200 || /[<>]/.test(value)) errors.push(`site.uiStrings.${key} is not a plain text of 1 to 200 characters`)\n }\n if (Object.keys(site?.uiStrings ?? {}).length > 100) errors.push('site.uiStrings holds more than 100 strings')\n if (site?.lists && !['cards', 'list', 'full'].includes(site.lists.display)) errors.push(`site.lists.display ${site.lists.display} is not cards, list or full`)\n if (site?.lists?.tone !== undefined && site.lists.tone !== 'muted') errors.push(`site.lists.tone ${String(site.lists.tone)} is not muted`)\n if (site?.lists?.text !== undefined && !CSS_LENGTH.test(site.lists.text)) errors.push(`site.lists.text ${site.lists.text} is not a CSS size`)\n for (const [route, layout] of Object.entries(site?.entryLayouts ?? {})) {\n if (typeof layout?.adjacent !== 'boolean') errors.push(`site.entryLayouts.${route}.adjacent is not true or false`)\n if (layout?.more !== undefined && (!Number.isSafeInteger(layout.more) || layout.more < 0 || layout.more > 20)) errors.push(`site.entryLayouts.${route}.more is not a count from 0 to 20`)\n if (layout?.moreIncludesCurrent !== undefined && typeof layout.moreIncludesCurrent !== 'boolean') errors.push(`site.entryLayouts.${route}.moreIncludesCurrent is not true or false`)\n if (layout?.moreOf !== undefined && layout.moreOf !== 'posts') errors.push(`site.entryLayouts.${route}.moreOf ${String(layout.moreOf)} is not posts`)\n if (layout?.moreText !== undefined && !CSS_LENGTH.test(layout.moreText)) errors.push(`site.entryLayouts.${route}.moreText ${layout.moreText} is not a CSS size`)\n }\n const printed = site?.footer\n if (printed?.feed !== undefined && typeof printed.feed !== 'boolean') errors.push('site.footer.feed is not a boolean')\n if (printed?.border !== undefined && typeof printed.border !== 'boolean') errors.push('site.footer.border is not a boolean')\n if (printed?.copyright !== undefined && printed.copyright !== false && (typeof printed.copyright !== 'string' || !printed.copyright.trim() || printed.copyright.length > 200 || /[<>]/.test(printed.copyright))) errors.push('site.footer.copyright is not false or a line of plain text (1–200 characters, no markup)')\n const chrome = site?.chrome\n if (chrome?.brand !== undefined && !CSS_LENGTH.test(chrome.brand)) errors.push(`site.chrome.brand ${chrome.brand} is not a CSS size`)\n if (chrome?.tagline !== undefined && typeof chrome.tagline !== 'boolean') errors.push('site.chrome.tagline is not a boolean')\n if (chrome?.titleLinks !== undefined && typeof chrome.titleLinks !== 'boolean') errors.push('site.chrome.titleLinks is not a boolean')\n if (chrome?.navLinks !== undefined && typeof chrome.navLinks !== 'boolean') errors.push('site.chrome.navLinks is not a boolean')\n const header = chrome?.header\n if (header !== undefined) {\n if (header === null || typeof header !== 'object' || Array.isArray(header)) errors.push('site.chrome.header is not an object')\n else {\n if (header.border !== undefined && header.border !== false) errors.push('site.chrome.header.border is not false')\n if (header.brandWeight !== undefined && !/^[1-9]00$/.test(header.brandWeight)) errors.push(`site.chrome.header.brandWeight ${header.brandWeight} is not a font weight`)\n if (header.brandTracking !== undefined && !LETTER_SPACING.test(header.brandTracking)) errors.push(`site.chrome.header.brandTracking ${header.brandTracking} is not a letter spacing`)\n for (const key of ['offsetTop', 'padTop', 'padBottom'] as const) {\n if (header[key] !== undefined && !CSS_LENGTH.test(header[key]!)) errors.push(`site.chrome.header.${key} ${header[key]} is not a CSS size`)\n }\n }\n }\n if (chrome?.footerAlign !== undefined && !['start', 'center'].includes(chrome.footerAlign)) errors.push(`site.chrome.footerAlign ${chrome.footerAlign} is not start or center`)\n for (const key of ['frame', 'listFrame', 'postFrame'] as const) {\n const steps = chrome?.[key]\n if (steps === undefined) continue\n if (!Array.isArray(steps) || steps.length === 0 || steps.length > 8) errors.push(`site.chrome.${key} is not a list of 1–8 steps`)\n else if (steps.some((step, i) => !Number.isSafeInteger(step?.min) || !Number.isSafeInteger(step?.width) || step.min < 1 || step.width < 1 || step.min > 10_000 || step.width > 10_000 || (i > 0 && step.min <= steps[i - 1]!.min))) errors.push(`site.chrome.${key} steps are not whole pixel widths in ascending order of min`)\n }\n if (chrome?.listColumns !== undefined) {\n const steps = chrome.listColumns\n if (!Array.isArray(steps) || steps.length === 0 || steps.length > 8) errors.push('site.chrome.listColumns is not a list of 1–8 steps')\n else if (steps.some((step, i) => !Number.isSafeInteger(step?.min) || !Number.isSafeInteger(step?.columns) || step.min < 1 || step.min > 10_000 || step.columns < 1 || step.columns > 6 || (i > 0 && step.min <= steps[i - 1]!.min))) errors.push('site.chrome.listColumns steps are not a whole pixel min in ascending order and 1–6 columns')\n }\n if (chrome?.footerColumns !== undefined) {\n const { gap, size, narrowGap } = chrome.footerColumns\n if (!pxWithin(gap, 400) || (size !== undefined && !(pxWithin(size, 100) && size > 0)) || (narrowGap !== undefined && !pxWithin(narrowGap, 400))) errors.push('site.chrome.footerColumns is not a gap (0–400 px), a link size (1–100 px) and a narrow gap (0–400 px)')\n }\n if (chrome?.copyright !== undefined && (typeof chrome.copyright !== 'string' || !chrome.copyright.trim() || chrome.copyright.length > 200 || /[<>]/.test(chrome.copyright))) errors.push('site.chrome.copyright is not a line of plain text (1–200 characters, no markup)')\n const footer = site?.menus?.footer\n if (typeof footer === 'string') {\n if (footer === '') errors.push('site.menus.footer has an empty menu slug')\n } else if (!Array.isArray(footer)) errors.push('site.menus.footer is not a menu slug or a list of them')\n else {\n if (footer.length > 4) errors.push(`site.menus.footer has ${footer.length} menus; the footer takes at most 4`)\n if (footer.some(slug => typeof slug !== 'string' || slug === '')) errors.push('site.menus.footer has an empty menu slug')\n if (new Set(footer).size !== footer.length) errors.push('site.menus.footer names a menu twice')\n }\n for (const [from, to] of Object.entries(site?.redirects ?? {})) {\n if (!from.startsWith('/')) errors.push(`redirect ${from} does not start with \"/\"`)\n const status = typeof to === 'string' ? 301 : to.status\n if (![301, 302, 307, 308, 410].includes(status)) errors.push(`redirect ${from} has status ${status}`)\n }\n for (const role of Object.keys(site?.tokens?.roles ?? {})) {\n if (!(PLAN_TOKEN_ROLES as readonly string[]).includes(role)) errors.push(`tokens.roles.${role} is not a kit role`)\n }\n for (const role of ['spacing-title', 'container-inset'] as const) {\n const value = site?.tokens?.roles?.[role]\n if (value !== undefined && !CSS_LENGTH.test(value)) errors.push(`tokens.roles.${role} ${value} is not a CSS size`)\n }\n\n const models = new Map<string, PlanModel>()\n for (const m of plan.models ?? []) {\n if (models.has(m.id)) errors.push(`model ${m.id} is declared twice`)\n models.set(m.id, m)\n if (m.origin === 'import' && !IMPORTED_MODELS.has(m.id)) warnings.push(`model ${m.id} is marked imported but wp-import does not write it`)\n if (m.origin === 'plan') {\n if (!m.fields) errors.push(`plan model ${m.id} has no fields`)\n if (!m.name || !m.domain) errors.push(`plan model ${m.id} needs name and domain`)\n // A singleton is titled too when it says so: a page built from sections names a section's heading.\n if (m.kind === 'collection' || m.kind === 'document' || (m.kind === 'singleton' && m.title_field)) {\n const title = m.title_field ? titleFieldTarget(m.fields, m.title_field) : undefined\n if (!m.title_field || !title) errors.push(`plan model ${m.id} has no title_field`)\n else if (title.kind === 'too-deep') errors.push(`plan model ${m.id}: title_field ${m.title_field} reaches more than one object deep`)\n else if (title.kind === 'not-object') errors.push(`plan model ${m.id}: title_field ${m.title_field} goes through ${title.at} (${title.type}), not an object field`)\n else if (title.kind === 'missing') errors.push(`plan model ${m.id}: title_field ${m.title_field} is not one of its fields`)\n else if (!isTitleFieldType(title.def.type)) errors.push(`plan model ${m.id}: title_field ${m.title_field} is ${title.def.type}, not a text-like type`)\n }\n if (!m.extract?.length && !m.form) warnings.push(`plan model ${m.id} has no extraction — its entries start empty`)\n }\n if (m.form) {\n if (m.origin !== 'plan' || m.kind !== 'collection') errors.push(`model ${m.id}: a form model must be a plan collection`)\n // Unset means localized for a Contentrain model; submissions have no locale to be translated into.\n if (m.i18n !== false) errors.push(`model ${m.id}: a form model must be i18n: false (Studio writes submissions in the default locale)`)\n if (!m.form.exposedFields.length) errors.push(`model ${m.id}: form exposes no fields`)\n for (const field of [...m.form.exposedFields, ...Object.keys(m.form.requiredOverrides ?? {})]) {\n if (m.fields && !m.fields[field]) errors.push(`model ${m.id}: form field ${field} is not a model field`)\n }\n if (m.form.captcha !== undefined && m.form.captcha !== null && m.form.captcha !== 'turnstile') errors.push(`model ${m.id}: form captcha ${String(m.form.captcha)} is not turnstile`)\n }\n for (const x of m.extract ?? []) {\n if (!x.rule && !x.fields) errors.push(`model ${m.id} extraction from ${x.from} has neither rule nor fields`)\n if (x.field !== undefined && m.fields && m.fields[x.field]?.type !== 'object') errors.push(`model ${m.id} extraction from ${x.from} fills ${x.field}, which is not an object field`)\n if (x.rule && !/^(gutenberg|elementor|divi|classic):[\\w./:-]+$/.test(x.rule)) errors.push(`model ${m.id} extraction rule ${x.rule} is not <builder>:<match>`)\n const target = x.field !== undefined ? m.fields?.[x.field]?.fields : m.fields\n for (const [field, path] of Object.entries(x.fields ?? {})) {\n if (target && !target[field]) errors.push(`model ${m.id} extraction fills unknown field ${field}`)\n if (!/^attr:[\\w.-]+$|^[\\d.]+\\|(text|href|src|alt|datetime)$|^(?:dom|img|link|html):[^@]*(?:@[\\w-]+)?$/.test(path)) errors.push(`model ${m.id} field ${field}: path ${path} is not \"<path>|<prop>\", \"attr:<name>\" or an element expression`)\n }\n }\n }\n const knownModel = (id: string) => models.has(id) || IMPORTED_MODELS.has(id)\n\n const components = new Map<string, PlanComponent>()\n for (const c of plan.components ?? []) {\n if (components.has(c.id)) errors.push(`component ${c.id} is declared twice`)\n components.set(c.id, c)\n if (!/^[A-Z][A-Za-z0-9]*$/.test(c.id)) errors.push(`component ${c.id} is not a PascalCase name`)\n if (c.origin === 'kit' && !c.kit?.id) errors.push(`kit component ${c.id} names no kit id`)\n if (c.origin === 'site' && !c.props) errors.push(`site component ${c.id} declares no props`)\n if (c.origin === 'site' && !c.brief) warnings.push(`site component ${c.id} has no brief for the writer`)\n }\n\n const placement = (s: PlanPlacement, at: string, route?: PlanRoute) => {\n if (s.width !== undefined && s.width !== 'content' && s.width !== 'wide') errors.push(`${at}: width ${s.width as string} is not content or wide`)\n const c = components.get(s.component)\n if (!c) { errors.push(`${at}: component ${s.component} is not declared`); return }\n const b = s.bind\n if ('model' in b && !knownModel(b.model)) errors.push(`${at}: model ${b.model} is not declared`)\n if (b.kind === 'entry' && !route?.source) errors.push(`${at}: binds the route entry but ${route ? 'the route has no source' : 'layout has no entry'}`)\n if (s.id !== undefined && !/^[a-z][a-z0-9-]*$/.test(s.id)) errors.push(`${at}: id ${s.id} is not kebab-case`)\n if (s.rule !== undefined && !PLAN_SECTION_RULE.test(s.rule)) errors.push(`${at}: rule ${s.rule} is not <builder>:section:<id>, <builder>:element:<match>, opus or prose`)\n if (b.kind === 'section') {\n const m = models.get(b.model)\n const field = m?.fields?.[b.field]\n // A template route's sections read the route's own entry: its page collection is the route source.\n const ownEntry = route?.page === b.model && route.source?.model === b.model\n if (m && m.origin === 'plan' && m.kind !== 'singleton' && b.entry === undefined && !ownEntry) errors.push(`${at}: section of ${b.model} names no entry and ${b.model} is neither a singleton nor this route's page`)\n if (m?.fields && !field) errors.push(`${at}: ${b.model} has no field ${b.field}`)\n else if (field && field.type !== 'object') errors.push(`${at}: ${b.model}.${b.field} is ${field.type}, not an object`)\n else if (field && fieldDepth(field) > 2) errors.push(`${at}: ${b.model}.${b.field} nests deeper than 2 (an object inside a list item's object)`)\n const fieldOf = new Map(Object.keys(c.props ?? {}).map(prop => [sectionFieldName(prop), prop]))\n if (c.props && field?.fields) for (const key of Object.keys(field.fields)) if (!fieldOf.has(key)) errors.push(`${at}: ${c.id} has no prop for field ${b.model}.${b.field}.${key} (fields are the props in snake_case)`)\n }\n const values: Record<string, PlanValue> = { ...('props' in b ? b.props : {}), ...(b.kind === 'collection' && !b.into ? b.item : {}) }\n for (const [prop, value] of Object.entries({ ...values, ...(b.kind === 'collection' ? b.item : {}) })) {\n if (!PLAN_VALUE_PATTERN.test(value)) errors.push(`${at}: ${prop} = ${value} is not a plan value (field: media: ref: href: term: ui: site: menu: page: const:)`)\n }\n // A kit component's props are the catalog's; the caller checks them. A site component's are here.\n if (c.props) {\n const byField = new Map(Object.keys(c.props).map(prop => [sectionFieldName(prop), prop]))\n const spread = b.kind === 'section' ? Object.keys(models.get(b.model)?.fields?.[b.field]?.fields ?? {}).flatMap(key => byField.get(key) ?? []) : []\n const bound = new Set([...Object.keys(values), ...spread, ...Object.keys(s.labels ?? {}), ...('into' in b && b.into ? [b.into] : b.kind === 'menu' ? ['items'] : [])])\n for (const prop of bound) if (!c.props[prop]) errors.push(`${at}: ${c.id} has no prop ${prop}`)\n for (const [prop, def] of Object.entries(c.props)) if (def.required && !bound.has(prop)) errors.push(`${at}: required prop ${c.id}.${prop} is not bound`)\n }\n }\n if (plan.layout?.header) placement(plan.layout.header, 'layout header')\n if (plan.layout?.footer) placement(plan.layout.footer, 'layout footer')\n\n const routeIds = new Set<string>()\n const byModel = new Map<string, PlanRoute[]>()\n for (const r of plan.routes ?? []) {\n if (routeIds.has(r.id)) errors.push(`route ${r.id} is declared twice`)\n routeIds.add(r.id)\n if (!PERMALINK.test(r.pattern)) errors.push(`route ${r.id}: pattern ${r.pattern} must start and end with \"/\"`)\n if (r.source) {\n if (!knownModel(r.source.model)) errors.push(`route ${r.id}: source model ${r.source.model} is not declared`)\n ;(byModel.get(r.source.model) ?? byModel.set(r.source.model, []).get(r.source.model)!).push(r)\n }\n if (r.body === 'composed' && !r.sections.length) warnings.push(`route ${r.id} is composed but has no sections`)\n if (r.page !== undefined) {\n const page = models.get(r.page)\n if (!page) errors.push(`route ${r.id}: page ${r.page} is not declared`)\n // A collection of pages sharing one layout is a template route: each entry is one page.\n else if (page.kind === 'collection' ? r.source?.model !== r.page : page.kind !== 'singleton') errors.push(`route ${r.id}: page ${r.page} is a ${page.kind}; a page is a singleton, or a collection that is the route's source`)\n if (r.body !== 'composed') errors.push(`route ${r.id}: a page model needs body composed`)\n }\n const sectionIds = new Set<string>()\n for (const s of r.sections) {\n if (s.id === undefined) continue\n if (sectionIds.has(s.id)) errors.push(`route ${r.id}: section id ${s.id} is used twice`)\n sectionIds.add(s.id)\n }\n r.sections.forEach((s, i) => placement(s, `route ${r.id} section ${i}`, r))\n }\n // Two routes on one pattern build one address twice, unless they split one model's entries by `where`.\n const byPattern = new Map<string, PlanRoute[]>()\n for (const r of plan.routes ?? []) (byPattern.get(r.pattern) ?? byPattern.set(r.pattern, []).get(r.pattern)!).push(r)\n for (const [pattern, routes] of byPattern) {\n if (routes.length < 2) continue\n const sources = new Set(routes.map(r => r.source?.model ?? ''))\n const split = sources.size === 1 && !sources.has('') && routes.filter(r => !r.source?.where).length <= 1\n if (!split) warnings.push(`routes ${routes.map(r => r.id).join(', ')} share the pattern ${pattern}`)\n }\n // One address per entry: routes over one model split it by disjoint wp_id sets plus at most one catch-all.\n for (const [model, routes] of byModel) {\n if (routes.length < 2) continue\n const catchAll = routes.filter(r => !r.source?.where || Object.keys(r.source.where).length === 0)\n if (catchAll.length > 1) errors.push(`model ${model}: routes ${catchAll.map(r => r.id).join(', ')} are all catch-alls`)\n const seen = new Map<unknown, string>()\n for (const r of routes) {\n const ids = r.source?.where?.wp_id\n if (r.source?.where && ids === undefined) warnings.push(`route ${r.id}: where without wp_id — overlap with other ${model} routes cannot be checked`)\n for (const id of Array.isArray(ids) ? ids : ids === undefined ? [] : [ids]) {\n const other = seen.get(id)\n if (other) errors.push(`model ${model}: entry ${String(id)} is in routes ${other} and ${r.id}`)\n else seen.set(id, r.id)\n }\n }\n }\n if (!plan.routes?.some(r => r.kind === 'home')) errors.push('no home route')\n\n const behaviorIds = new Set<string>()\n for (const b of plan.behaviors ?? []) {\n const at = `behavior ${b.fact}`\n if (behaviorIds.has(b.fact)) errors.push(`${at} is planned twice`)\n behaviorIds.add(b.fact)\n if (b.outcome === 'component') {\n if (!b.component) errors.push(`${at}: outcome component names no component`)\n else if (!components.has(b.component)) errors.push(`${at}: component ${b.component} is not declared`)\n }\n if (b.outcome === 'starter' && !(PLAN_STARTER_FEATURES as readonly string[]).includes(b.feature ?? '')) errors.push(`${at}: starter feature ${String(b.feature)} is not one of ${PLAN_STARTER_FEATURES.join(', ')}`)\n if ((b.outcome === 'drop' || b.outcome === 'needs_review') && !PLAN_BEHAVIOR_REASON.test(b.reason ?? '')) errors.push(`${at}: ${b.outcome} needs a reason \"<code>: <sentence>\"`)\n if (b.model) {\n const m = models.get(b.model)\n if (!m) errors.push(`${at}: model ${b.model} is not declared`)\n else if (!m.form) errors.push(`${at}: model ${b.model} is not a form model`)\n }\n }\n return { errors, warnings }\n}\n","import type { SyncResult } from './index.js'\n\n// ─── Repository Provider Contracts ───\n//\n// Shared interfaces for the provider-agnostic content repository model used\n// by @contentrain/mcp. They live in @contentrain/types so third-party tools\n// can implement a custom RepoProvider (e.g. for a private git host, an\n// internal service, a mock in a test suite) without depending on MCP\n// internals.\n//\n// @contentrain/mcp re-exports every symbol here from\n// @contentrain/mcp/core/contracts so existing consumers do not have to\n// migrate imports.\n\n// ─── File change ───\n\n/**\n * A single file change within a plan.\n *\n * - `content: string` — write or overwrite the file with this UTF-8 content.\n * - `content: null` — delete the file.\n *\n * Paths are content-root relative, use forward slashes, and must not contain\n * `..` segments or absolute anchors. Providers are responsible for resolving\n * paths against their backing store (worktree, git tree, etc.).\n */\nexport interface FileChange {\n path: string\n content: string | null\n}\n\n// ─── Capabilities ───\n\n/**\n * Capabilities describe what a provider can and cannot do. Operations check\n * required capabilities before running. Normalize extract needs `sourceRead`;\n * normalize reuse needs `sourceWrite`; submit needs `pushRemote`; AST scans\n * need `astScan` (which implies a local working tree).\n *\n * The consistent position is: all git hosts are commodity MIT providers; the\n * distinction between providers is operational (how they read/write), not\n * commercial. Enterprise features live in Studio, not in capability gates.\n */\nexport interface ProviderCapabilities {\n /** Provider backs onto a local worktree and can selectively sync changes into the developer's working tree. */\n localWorktree: boolean\n /** Provider can read arbitrary source files outside `.contentrain/`. Required for normalize extract. */\n sourceRead: boolean\n /** Provider can write arbitrary source files outside `.contentrain/`. Required for normalize reuse. */\n sourceWrite: boolean\n /** Provider can push commits to a remote. Required for submit. */\n pushRemote: boolean\n /** Provider detects branch protection rules on the remote. */\n branchProtection: boolean\n /** Provider can open a pull request as a merge fallback when branch protection blocks direct merge. */\n pullRequestFallback: boolean\n /** Provider can execute AST scanners against source files. Implies local disk access. */\n astScan: boolean\n /**\n * Provider can write a two-parent merge commit (see\n * `RepoProvider.createMergeCommit`). Absent = false. Optional so that\n * existing external `implements` code keeps compiling: a required member\n * here would be a breaking change for every custom provider.\n */\n mergeCommit?: boolean\n}\n\n/** Capability set for the LocalProvider (simple-git + worktree). */\nexport const LOCAL_CAPABILITIES: ProviderCapabilities = {\n localWorktree: true,\n sourceRead: true,\n sourceWrite: true,\n pushRemote: true,\n branchProtection: false,\n pullRequestFallback: false,\n astScan: true,\n mergeCommit: true,\n}\n\n// ─── Reader ───\n\n/**\n * Read-only interface to a content repository.\n *\n * Paths are relative to the repository's content root (e.g.\n * `.contentrain/config.json`). The `ref` parameter is a branch name, tag,\n * or commit SHA. Providers that operate on a single working tree\n * (LocalReader) ignore `ref`; API-backed providers use it to resolve the\n * correct revision.\n *\n * `readFile` and `listDirectory` deliberately have different error semantics:\n * - `readFile` THROWS when the file is missing so callers must opt into\n * tolerance explicitly (typically with a try/catch returning a default).\n * - `listDirectory` returns `[]` for a missing directory because the empty\n * case is the common, uninteresting one.\n */\n/**\n * Where a provider read `.contentrain/` from (see `RepoProvider.contentSource`).\n * `ref`: the content branch at `commit`, because `checked_out` is a branch\n * content writes do not update.\n */\nexport type ContentReadSource =\n | { source: 'working_tree' }\n | { source: 'ref', ref: string, commit: string, checked_out: string }\n\nexport interface RepoReader {\n /**\n * Read a file's contents as UTF-8.\n * @throws when the file does not exist or cannot be read.\n */\n readFile(path: string, ref?: string): Promise<string>\n\n /**\n * List file and directory names directly under `path`. Does not recurse.\n * Returns an empty array when the directory does not exist.\n */\n listDirectory(path: string, ref?: string): Promise<string[]>\n\n /** Check whether a file or directory exists at `path`. */\n fileExists(path: string, ref?: string): Promise<boolean>\n}\n\n// ─── Writer ───\n\nexport interface CommitAuthor {\n name: string\n email: string\n}\n\nexport interface Commit {\n sha: string\n message: string\n author: CommitAuthor\n timestamp: string\n /**\n * What the provider did with the branch. Providers that own the merge\n * decision (a local worktree honouring the project's `workflow` setting)\n * report it here; providers that hand the branch off to an external\n * orchestrator leave it undefined, and the caller treats that as\n * `pending-review`.\n */\n workflowAction?: 'auto-merged' | 'pending-review'\n /**\n * Selective-sync bookkeeping — which files were copied into the developer's\n * working tree and which were skipped because of local edits. Only\n * providers backed by a local worktree populate it.\n */\n sync?: SyncResult\n /** Non-fatal problem encountered while completing the write. */\n warning?: string\n /**\n * Whether the base branch advanced to the contentrain tip after an\n * auto-merge. Only providers that own the base advance (a local worktree)\n * report it. `blocked_diverged` is a partial success: the content is on\n * the contentrain branch; only the fast-forward is pending.\n */\n base_advance?: BaseAdvance\n /** Outcome of pushing the contentrain branch, when the provider pushes. */\n remote_push?: RemotePush\n}\n\n/**\n * Input to `applyPlan`. Represents a single atomic commit: all changes land\n * in one commit on `branch`, created from `base` if it does not yet exist.\n */\nexport interface ApplyPlanInput {\n /** Branch name to commit to. Created from `base` if missing. */\n branch: string\n /** File additions, modifications and deletions to apply in a single commit. */\n changes: FileChange[]\n /** Commit message. */\n message: string\n /** Commit author. */\n author: CommitAuthor\n /**\n * Optional base branch. Defaults to the Contentrain content-tracking\n * branch (`CONTENTRAIN_BRANCH` — the `contentrain` ref) — NOT the\n * repository's default branch. This is the single source of truth for\n * content state; every feature branch forks from it. Pass an explicit\n * `base` only when you know you want to bypass the invariant.\n *\n * The GitHub provider also takes a full 40-hex commit SHA here, as a\n * compare-and-set: a missing `branch` forks from that commit, and an\n * existing one must still point at it or the write is refused with a\n * 409 (`PROVIDER_CONFLICT`).\n */\n base?: string\n /**\n * Describes the operation that produced these changes. A provider that\n * regenerates `.contentrain/context.json` itself (the local worktree flow\n * does, after merge, single-threaded) uses it; others ignore it. It is\n * never committed on the feature branch — context.json embeds timestamps,\n * so two branches forked from the same commit would always conflict on it.\n */\n context?: { tool: string; model: string; locale?: string; entries?: string[] }\n /** Override the project's configured workflow for this write. */\n workflowOverride?: 'auto-merge' | 'review'\n}\n\n/**\n * Write-side interface. Providers implement this to persist a set of file\n * changes as a single atomic commit. LocalProvider writes through a worktree\n * and `git commit`; API-backed providers post to the Git Data API or\n * equivalent.\n */\nexport interface RepoWriter {\n applyPlan(input: ApplyPlanInput): Promise<Commit>\n}\n\n// ─── Branch / diff / merge ───\n\nexport interface Branch {\n name: string\n sha: string\n protected?: boolean\n}\n\nexport interface FileDiff {\n path: string\n status: 'added' | 'modified' | 'removed'\n before: string | null\n after: string | null\n}\n\nexport interface MergeResult {\n merged: boolean\n sha: string | null\n pullRequestUrl: string | null\n /**\n * Selective-sync bookkeeping — only populated by providers that back onto\n * a local worktree (LocalProvider). Remote-API providers (GitHub, GitLab,\n * etc.) omit it because they do not touch a developer's working tree.\n */\n sync?: SyncResult\n /**\n * Outcome of the post-merge source-branch cleanup on the remote.\n * `deleted: false` with `skipped` is an expected no-op (cleanup disabled,\n * no remote, ref already gone); `warning` is a real failure (offline,\n * auth) that did NOT fail the merge itself.\n */\n remote?: {\n deleted: boolean\n skipped?: string\n warning?: string\n }\n}\n\n// ─── Base-branch advance / remote push outcomes ───\n\n/**\n * What happened to the base branch after content landed on the\n * content-tracking branch.\n *\n * `advanced` — the base branch fast-forwarded to the contentrain tip.\n * `blocked_diverged` — the base branch holds commits that are not in\n * contentrain, so fast-forwarding was impossible. The content itself is\n * safe on the contentrain branch; only the advance is pending until the\n * branches are reconciled.\n *\n * These two values are shared vocabulary across the ecosystem — Studio\n * reports the same enum for its main-advance state. A pull request is an\n * attachment (`pullRequestUrl`), never a third state: \"PR opened\" is\n * `blocked_diverged` with a non-null URL.\n */\nexport type BaseAdvance = 'advanced' | 'blocked_diverged'\n\n/**\n * Outcome of pushing the content-tracking branch to the configured remote.\n *\n * `pushed` — the remote accepted the push (possibly after one\n * fetch-merge-retry). `rejected` — the push failed even after the retry;\n * local and remote have diverged and need reconciling. `no-remote` — the\n * repository has no configured remote, so nothing was attempted.\n */\nexport type RemotePush = 'pushed' | 'rejected' | 'no-remote'\n\n// ─── Reconcile / conflicts ───\n\n/**\n * Machine-readable conflict kinds produced by the content-aware three-way\n * reconcile planner. CLOSED SET: consumers (Studio) key their localized\n * editor questions on these values, so a free string would drift. Adding a\n * value here is a minor version bump and MUST carry a changelog entry;\n * renaming or removing one is breaking.\n */\nexport type ConflictCode =\n | 'field_value_conflict' // collection/singleton: same field changed differently on both sides\n | 'dictionary_value_conflict' // same dictionary key, two different values\n | 'vocabulary_value_conflict' // same term + locale, two different translations\n | 'model_key_conflict' // same model schema key changed differently\n | 'meta_status_conflict' // both sides moved an entry's publish status differently\n | 'document_body_conflict' // both sides edited a document body (never text-merged)\n | 'frontmatter_value_conflict' // same frontmatter key, two different values\n | 'delete_edit_conflict' // one side deleted what the other edited\n | 'file_conflict' // unrecognized file changed on both sides — resolved by choosing a side\n\n/**\n * One surviving conflict from a three-way reconcile: base (merge-base),\n * ours (content branch), theirs (base branch) disagree in a way the policy\n * table cannot resolve mechanically. Everything mechanical has already been\n * merged; only these items need a decision from an editor or agent.\n *\n * `id` is derived from the position AND the three values (see\n * `conflictId`), so a resolution made against a stale dry-run no longer\n * matches once any side changes — compare-and-set for free.\n */\nexport interface ConflictItem {\n id: string\n /** Content-root-relative path of the file the conflict lives in. */\n path: string\n model?: string\n kind: 'collection' | 'singleton' | 'document' | 'dictionary'\n | 'vocabulary' | 'model' | 'meta' | 'file'\n /** Entry ID, dictionary key, vocabulary term, or model schema key. */\n key?: string\n /** Field inside the entry / frontmatter key, when the conflict is field-level. */\n field?: string\n locale?: string\n /** The three values in question — for display; omitted for `kind: 'file'`. */\n base?: unknown\n ours?: unknown\n theirs?: unknown\n code: ConflictCode\n /** Ready-to-display English sentence naming the conflict. */\n message: string\n /**\n * Policy-table suggestion, never auto-applied. Present only where the\n * approved policy names a preferred side (model schema keys → `theirs`).\n */\n suggested?: 'ours' | 'theirs'\n}\n\n/**\n * A decision for one conflict, keyed by `ConflictItem.id`. `choose` picks a\n * side (absence of the item on that side means deletion); `value` supplies\n * a hand-authored replacement. A resolution whose `id` no longer matches a\n * live conflict is dropped and the conflict re-reported with a fresh id.\n */\nexport type ConflictResolution =\n | { id: string; choose: 'ours' | 'theirs' }\n | { id: string; value: unknown }\n\n// ─── Media (optional provider facet) ───\n\n/**\n * One media asset as the provider's media stack reports it. `path` is the\n * repo-relative storage path content fields reference (`media/...`); `url`\n * is the absolute delivery URL when the provider can resolve one (see\n * `RepoProvider.mediaBaseUrl`). `meta` carries provider-defined extras\n * (dimensions, blurhash, variants) without widening this contract.\n */\nexport interface MediaAsset {\n id: string\n path: string\n url?: string\n mime?: string\n size?: number\n alt?: string\n tags?: string[]\n createdAt?: string\n meta?: Record<string, unknown>\n}\n\nexport interface MediaListOptions {\n /** Substring match on filename/path/alt. */\n search?: string\n /** Filter to assets carrying this tag. */\n tag?: string\n /** Page size — implementations may clamp. */\n limit?: number\n /** Opaque cursor from a previous `MediaListResult.nextCursor`. */\n cursor?: string\n}\n\nexport interface MediaListResult {\n assets: MediaAsset[]\n nextCursor?: string\n total?: number\n}\n\n/**\n * URL-based ingest — MCP has no binary channel, so the source is always a\n * URL the provider fetches server-side. Implementations MUST enforce their\n * own SSRF, MIME, and size policies before fetching; MCP passes the input\n * through verbatim and never fetches the URL itself.\n */\nexport interface MediaIngestInput {\n url: string\n filename?: string\n alt?: string\n tags?: string[]\n}\n\nexport interface MediaUpdateInput {\n alt?: string\n tags?: string[]\n filename?: string\n}\n\n/**\n * Optional media facet of a provider. When present on `RepoProvider.media`,\n * the MCP server registers the `contentrain_media_*` tools; when absent\n * (LocalProvider, plain GitHub/GitLab providers), those tools are not\n * listed at all. Hosted providers (Studio MCP Cloud) implement this by\n * delegating to their media stack — deterministic passthrough, no content\n * decisions in MCP.\n */\nexport interface MediaProvider {\n list(opts?: MediaListOptions): Promise<MediaListResult>\n get(id: string): Promise<MediaAsset | null>\n ingest(input: MediaIngestInput): Promise<MediaAsset>\n update(id: string, patch: MediaUpdateInput): Promise<MediaAsset>\n delete(id: string): Promise<void>\n}\n\n// ─── Provider (full surface) ───\n\n/**\n * A content repository provider — the unified surface that MCP tools drive.\n *\n * Implementations wrap a git backend:\n *\n * - `LocalProvider` — simple-git + temp worktree + selective sync\n * - `GitHubProvider` — Octokit Git Data API (no clone)\n * - `GitLabProvider` — gitbeaker REST client (no clone)\n * - `BitbucketProvider` — planned, coming soon\n *\n * Providers are commodity and all live in MIT.\n */\n/** Result of {@link RepoProvider.checkWriteReadiness}. */\nexport interface WriteReadiness {\n blocked: boolean\n /** Human-readable reason, present when `blocked`. */\n message?: string\n}\n\nexport interface RepoProvider extends RepoReader, RepoWriter {\n readonly capabilities: ProviderCapabilities\n\n /**\n * Per-project public media delivery base, e.g.\n * `https://studio.example/api/cdn/v1/{projectId}` — already includes the\n * project segment, so the content-write path only joins `{base}/{path}`.\n *\n * Set by hosted providers (Studio's MCP Cloud loopback resolves it from a\n * request header and attaches it here). When present, content writes\n * normalize relative `media/...` references — in media/image/file fields and\n * markdown bodies — to absolute delivery URLs, so external-agent writes\n * render anywhere without an SDK. Undefined for local/CLI providers, where\n * media stays a relative path (the OSS file model). Never affects reads.\n */\n readonly mediaBaseUrl?: string\n\n /**\n * Optional media facet. Present only on providers whose backend exposes a\n * media stack (Studio MCP Cloud); drives the `contentrain_media_*` MCP\n * tools, which are not registered when this is absent.\n */\n readonly media?: MediaProvider\n\n /**\n * Whether the provider will accept a write right now.\n *\n * A local worktree accumulates unmerged `cr/*` branches and eventually has\n * to refuse new ones; a hosted provider manages that server-side. Asking the\n * provider keeps the tool layer out of the business of counting git branches\n * — it previously reached through an `instanceof` check to a `projectRoot`\n * only one implementation has.\n *\n * Optional: a provider that omits it is always ready.\n */\n checkWriteReadiness?(): Promise<WriteReadiness>\n\n /**\n * Where this provider's `.contentrain/` reads come from right now. A local\n * provider on a checked-out feature branch reads content from the\n * `contentrain` ref rather than the working tree, which content writes do\n * not update there; read tools report that. Optional: a provider that\n * omits it reads one fixed source.\n */\n contentSource?(): Promise<ContentReadSource>\n\n listBranches(prefix?: string): Promise<Branch[]>\n createBranch(name: string, fromRef?: string): Promise<void>\n deleteBranch(name: string): Promise<void>\n getBranchDiff(branch: string, base?: string): Promise<FileDiff[]>\n /**\n * Merge `branch` into `into`. Like `git merge` and the platform merge APIs,\n * the source branch is left in place by default. Pass\n * `opts.removeSourceBranch: true` to also delete it after a successful merge\n * (best-effort — reported via `MergeResult.remote`, never a thrown error).\n * Even when opted in, a long-lived branch is never deleted: not `into`, not\n * the `contentrain` content branch, and not the repo's default branch.\n * LocalProvider ignores the option: its cleanup is governed by\n * `config.remoteBranchCleanup` and its own `cr/*`-only guard.\n */\n mergeBranch(branch: string, into: string, opts?: { removeSourceBranch?: boolean }): Promise<MergeResult>\n isMerged(branch: string, into?: string): Promise<boolean>\n getDefaultBranch(): Promise<string>\n\n /**\n * The merge-base commit of two refs — the `base` input of a three-way\n * reconcile.\n *\n * Reconciling a diverged content branch needs the last common ancestor,\n * and every backend can name it (git `merge-base`, GitHub's compare API,\n * GitLab's merge_base endpoint) — but not every existing implementation\n * does yet, and demanding it would break external `implements` code.\n *\n * Optional: a provider that omits it cannot drive a reconcile; the caller\n * falls back to a pull-request flow. Returns `null` when the refs share\n * no history.\n */\n getMergeBase?(refA: string, refB: string): Promise<string | null>\n\n /**\n * Write a set of resolved changes as a TWO-PARENT merge commit on\n * `branch`, joining `ours` and `theirs` so that git history records the\n * reconcile — after it, `theirs` is an ancestor of `branch` and a plain\n * fast-forward advance works again. `changes` are applied on top of the\n * `ours` tree; for content-owned paths the planner's output is\n * authoritative, never a textual auto-merge.\n *\n * Optional, mirrored by `capabilities.mergeCommit`: a backend whose\n * commits API cannot express two parents (GitLab) omits it and the\n * caller falls back to a merge-request flow.\n */\n createMergeCommit?(input: {\n /** Branch whose tip becomes the merge commit (the content branch). */\n branch: string\n /** First parent — the current tip of `branch`. */\n ours: string\n /** Second parent — the ref being reconciled in. */\n theirs: string\n changes: FileChange[]\n message: string\n author: CommitAuthor\n }): Promise<Commit>\n}\n","import type { FieldDef, FieldType } from './index.js'\n\n/**\n * Media path → delivery URL rewriting. The single source of truth for how a\n * stored `media/...` reference becomes an absolute delivery URL — every\n * reader of Contentrain content that resolves this at read or write time\n * shares this module, so the rules can only be defined once.\n *\n * Two independent consumers need the exact same answer: `@contentrain/mcp`'s\n * `planContentSave` normalizes on write, for hosted/cloud mode, so a\n * git-committed value is ready to use with no SDK; `@contentrain/query`\n * resolves the same relative paths at read time, for the local-file model\n * (the generated client, the Astro loader, raw markdown) where nothing\n * upstream rewrote them. A second, hand-rolled implementation on either side\n * drifts the moment one of the rules below changes — titled markdown embeds,\n * an HTML `src`/`href`, or a media field nested inside an `object`/`array`\n * were all missed by an earlier, separate read-side implementation before\n * this module existed.\n *\n * Only image/video/file fields (resolved via the model schema, including those\n * nested in object/array fields) and `media/...` src/href targets in\n * markdown/HTML are rewritten. External URLs (`http(s)://`, `//`, `data:`) and\n * already-absolute delivery URLs pass through untouched, so every entry point\n * is idempotent and safe to call repeatedly. No base means no-op — the\n * relative path is kept verbatim, which is the OSS local-file model's default.\n */\n\nconst MEDIA_FIELD_TYPES = new Set<FieldType>(['image', 'video', 'file'])\n\n/**\n * Whether a stored field value is a relative media-storage path (`media/...`)\n * rather than an already-absolute URL or external link. Only these are\n * rewritten to delivery URLs — `http(s)://`, `//`, and `data:` are left as-is.\n */\nexport function isStoredMediaPath(value: unknown): value is string {\n return typeof value === 'string' && value.startsWith('media/')\n}\n\n/** Join a relative media path onto the per-project delivery base. */\nexport function toDeliveryUrl(base: string, path: string): string {\n return `${base.replace(/\\/+$/, '')}/${path}`\n}\n\n/**\n * Rewrite a stored media path to its absolute delivery URL. Non-media values\n * (external URLs, empty, non-strings) pass through untouched, so this is safe\n * to call on any field value.\n */\nexport function rewriteMediaUrl(base: string, value: unknown): unknown {\n return isStoredMediaPath(value) ? toDeliveryUrl(base, value) : value\n}\n\n/**\n * Rewrite media paths within a single field value, guided by its FieldDef so\n * only media fields — including those nested inside object/array fields — are\n * touched. Non-media values pass through.\n */\nexport function rewriteFieldMedia(value: unknown, field: FieldDef, base: string): unknown {\n if (value == null) return value\n\n if (MEDIA_FIELD_TYPES.has(field.type))\n return rewriteMediaUrl(base, value)\n\n if (field.type === 'object' && field.fields && typeof value === 'object' && !Array.isArray(value))\n return rewriteEntryMedia(value as Record<string, unknown>, field.fields, base)\n\n if (field.type === 'array' && Array.isArray(value)) {\n const itemDef: FieldDef | null = typeof field.items === 'string'\n ? { type: field.items as FieldType }\n : field.items ?? null\n if (!itemDef) return value\n return value.map(v => rewriteFieldMedia(v, itemDef, base))\n }\n\n return value\n}\n\n/**\n * Rewrite media paths across one entry/frontmatter object using a field map.\n * Returns a shallow copy (the input is never mutated); a no-op when the object\n * has no media fields.\n */\nexport function rewriteEntryMedia(\n entry: Record<string, unknown>,\n fields: Record<string, FieldDef>,\n base: string,\n): Record<string, unknown> {\n const out = { ...entry }\n for (const [fieldId, field] of Object.entries(fields)) {\n if (fieldId in out)\n out[fieldId] = rewriteFieldMedia(out[fieldId], field, base)\n }\n return out\n}\n\n/** Rewrite `media/...` markdown image/link targets and inline src/href attrs. */\nexport function rewriteMarkdownMedia(body: string, base: string): string {\n return body\n // markdown image/link target: ](media/...) — stops at whitespace, ) or \"\n .replace(/(\\]\\()(media\\/[^)\\s\"]+)/g, (_m, open, p) => `${open}${toDeliveryUrl(base, p)}`)\n // inline HTML src=/href=\"media/...\"\n .replace(/(\\s(?:src|href)=)([\"'])(media\\/[^\"']+)\\2/gi,\n (_m, attr, quote, p) => `${attr}${quote}${toDeliveryUrl(base, p)}${quote}`)\n}\n","import { sortKeys } from './canonical.js'\n\n// ─── Type System ───\n\nexport type FieldType =\n | 'string' | 'text' | 'email' | 'url' | 'slug' | 'color' | 'phone' | 'code' | 'icon'\n | 'markdown' | 'richtext'\n | 'number' | 'integer' | 'decimal' | 'percent' | 'rating'\n | 'boolean'\n | 'date' | 'datetime'\n | 'image' | 'video' | 'file'\n | 'relation' | 'relations'\n | 'select' | 'array' | 'object'\n\nexport type ModelKind = 'singleton' | 'collection' | 'document' | 'dictionary'\nexport type ContentStatus = 'draft' | 'in_review' | 'published' | 'rejected' | 'archived'\nexport type ContentSource = 'agent' | 'human' | 'import'\nexport type WorkflowMode = 'auto-merge' | 'review'\n// Meta-frameworks\nexport type StackType =\n | 'nuxt' | 'next' | 'astro' | 'sveltekit' | 'remix' | 'analog'\n // Plain frameworks\n | 'vue' | 'react' | 'svelte' | 'solid' | 'angular'\n // Mobile\n | 'react-native' | 'expo' | 'flutter'\n // Backend\n | 'node' | 'express' | 'fastify' | 'nestjs' | 'django' | 'rails' | 'laravel' | 'go' | 'rust' | 'dotnet'\n // Static site generators\n | 'hugo' | 'jekyll' | 'eleventy'\n // Desktop\n | 'electron' | 'tauri'\n // Catch-all\n | 'other'\n\nexport type Platform = 'web' | 'mobile' | 'api' | 'desktop' | 'static' | 'other'\nexport type ContextSource = 'mcp-local' | 'mcp-studio' | 'studio-ui'\nexport type CollectionRuntimeFormat = 'map' | 'array'\n\n// ─── Field Definition ───\n\nexport interface FieldDef {\n type: FieldType\n required?: boolean\n unique?: boolean\n default?: unknown\n min?: number\n max?: number\n pattern?: string\n options?: string[]\n model?: string | string[]\n items?: string | FieldDef\n fields?: Record<string, FieldDef>\n accept?: string\n maxSize?: number\n description?: string\n /**\n * Human-readable name for this field in an editing UI.\n *\n * A plain string is one label for every locale; an object keyed by locale\n * carries a translation per locale. The union exists now rather than later\n * because widening `string` to it afterwards would break every consumer\n * that treats the value as a string.\n *\n * Absent means the field name itself is the label — which is why an editor\n * shows `body_public` and `is_category_hero` today.\n */\n label?: string | Record<string, string>\n /**\n * Display position in an editing UI, ascending.\n *\n * Fields are stored in canonical alphabetical order, so without this an\n * editor shows them alphabetically: on a 16-field article model `author`\n * comes first and `title` fifteenth. Fractional values are allowed so a\n * field can be inserted between two others without renumbering the rest.\n *\n * Fields without one sort after every field that has one, alphabetically\n * among themselves — so a model that has not been given an order behaves\n * exactly as it does today.\n */\n order?: number\n}\n\n// ─── Model Definition ───\n\nexport type LocaleStrategy = 'file' | 'suffix' | 'directory' | 'none'\n\nexport interface ModelDefinition {\n id: string\n name: string\n kind: ModelKind\n domain: string\n i18n: boolean\n /**\n * The project locales this model's content actually covers — a subset of\n * `config.locales.supported`.\n *\n * Absent means every project locale, which is the behaviour every model had\n * before this field existed: parity is checked against the whole supported\n * list. Present, it narrows that check to the locales named here, so a\n * partially-translated site can declare the truth instead of failing.\n *\n * A locale not in `config.locales.supported` is a validation error — see\n * {@link validateModelLocales}. Meaningless on a non-i18n model, which has\n * only the default locale.\n *\n * It narrows validation; it never grants a locale. Writing content in a\n * locale outside this list is still governed by `config.locales.supported`.\n */\n locales?: string[]\n /**\n * Name of the field whose value is shown as an entry's title — in a Studio row,\n * a picker, a relation reference. Declared, never guessed: a consumer that infers\n * the title from field order or length picks the icon over the headline.\n *\n * Must name a field that exists on this model and whose type can render as text\n * (see `isTitleFieldType`). Dictionary models have no fields, so their only legal\n * value is `DICTIONARY_TITLE_FIELD` — the entry key is the title. A dotted path\n * (`hero.heading`) names a field one level inside an `object` field — a page\n * singleton built from sections is titled by a section's heading\n * (`titleFieldTarget`, `titleFieldValue`).\n */\n title_field: string\n description?: string\n fields?: Record<string, FieldDef>\n /** Framework-relative path for content files (e.g. \"content/blog\", \"locales\"). When set, content is written here instead of .contentrain/content/ */\n content_path?: string\n /** How locale is encoded in file names. Default: \"file\" ({dir}/{locale}.json or {dir}/{slug}/{locale}.md) */\n locale_strategy?: LocaleStrategy\n /**\n * Public form configuration for this model — which fields a visitor-facing\n * form exposes, honeypot, captcha, auto-approve. Owned and interpreted by the\n * runtime provider (Studio writes it through its model PATCH); the content\n * engine carries it verbatim and never reads it. A model edit that rebuilds\n * the definition must keep it, or the site's form silently stops working.\n */\n form?: Record<string, unknown>\n /** Public comments configuration for this model — same ownership and handling as `form`. */\n comments?: Record<string, unknown>\n}\n\n/** Model keys the content engine carries verbatim for the runtime provider — see `ModelDefinition.form`. */\nexport const MODEL_EXTENSION_KEYS = ['form', 'comments'] as const satisfies readonly (keyof ModelDefinition)[]\n\n// ─── Config ───\n\nexport interface ContentrainConfig {\n version: number\n platform?: Platform\n stack: StackType\n workflow: WorkflowMode\n repository?: {\n /**\n * Git host backing this project's remote. Widens as new providers ship\n * in @contentrain/mcp — `github` and `gitlab` are currently supported.\n * The value is informational for tooling: the concrete provider the\n * MCP server talks to is chosen at server construction time, not from\n * this config. Optional, like `owner`/`name`: `init` records\n * `default_branch` alone when it knows no remote host.\n */\n provider?: 'github' | 'gitlab'\n owner?: string\n name?: string\n /**\n * Not informational: local writes advance this branch. A local\n * auto-merge merges it into `contentrain` before each write, then\n * fast-forwards it to the `contentrain` tip and pushes it. The\n * `CONTENTRAIN_BRANCH` env overrides it; without either, the base is\n * the remote's default branch, then `main`, then `master` — never\n * merely the checked-out branch. `init` writes it once, resolved that\n * way, and never overwrites an existing value.\n */\n default_branch: string\n }\n locales: {\n default: string\n supported: string[]\n }\n domains: string[]\n assets_path?: string\n /**\n * Public media delivery base for resolving relative `media/...` references to\n * absolute URLs (e.g. a deployed CDN delivery endpoint). When set,\n * `@contentrain/query`'s `generate` bakes a `media()` resolver into the\n * generated client. The base may already include any project segment — the\n * resolver only joins `{cdn.url}/{path}`. Distinct from `assets_path` (the\n * local on-disk asset directory).\n */\n cdn?: {\n url?: string\n }\n /** Days a merged `cr/*` branch is kept before lazy cleanup. Default: 30. */\n branchRetention?: number\n /** Unmerged `cr/*` branch count that triggers a warning. Default: 50. */\n branchWarnLimit?: number\n /** Unmerged `cr/*` branch count that blocks new writes. Default: 80. */\n branchBlockLimit?: number\n /**\n * Delete the remote copy of a `cr/*` branch when it is merged or deleted\n * locally, and allow lazy pruning of already-merged remote branches.\n * Read-only remote visibility (doctor, branch listing) is not affected.\n * Default: true.\n */\n remoteBranchCleanup?: boolean\n}\n\n// ─── Vocabulary ───\n\nexport interface Vocabulary {\n version: number\n terms: Record<string, Record<string, string>>\n}\n\n// ─── Metadata ───\n\nexport interface EntryMeta {\n status: ContentStatus\n source: ContentSource\n updated_by: string\n /**\n * When this entry was last written, ISO 8601 UTC.\n *\n * Optional, and deliberately not backfilled. It is a fact about the past,\n * and an entry written before the field existed has no recoverable value —\n * a fabricated timestamp would be worse than an absent one, because it\n * would sort. Absent means unknown; the first write mints the real value.\n */\n updated_at?: string\n approved_by?: string | null\n version?: string\n publish_at?: string\n expire_at?: string\n}\n\nexport type SingletonMeta = EntryMeta\nexport type CollectionMeta = Record<string, EntryMeta>\nexport type DocumentMeta = EntryMeta\nexport type DictionaryMeta = EntryMeta\n\n// ─── Asset ───\n\nexport interface AssetEntry {\n path: string\n type: string\n size: number\n alt?: string\n}\n\n// ─── Validation ───\n\nexport interface ValidationError {\n severity: 'error' | 'warning' | 'notice'\n model?: string\n locale?: string\n entry?: string\n slug?: string\n field?: string\n message: string\n}\n\nexport interface ValidationResult {\n valid: boolean\n errors: ValidationError[]\n}\n\n// ─── Content Types (Storage vs Output) ───\n\n// Storage formats — how content is written to disk (canonical JSON)\nexport type SingletonContentFile = Record<string, unknown>\nexport type CollectionContentFile = Record<string, Record<string, unknown>>\nexport type DictionaryContentFile = Record<string, string>\n// Document storage = markdown file (frontmatter + body), not JSON\n\n// Output formats — how MCP/SDK returns content to consumers\nexport type CollectionEntry = { id: string } & Record<string, unknown>\nexport type CollectionContentOutput = CollectionEntry[]\n\n/** Document entry as returned by MCP/SDK — parsed markdown with frontmatter */\nexport interface DocumentEntry {\n slug: string\n frontmatter: Record<string, unknown>\n body: string\n}\n\nexport type DocumentContentOutput = DocumentEntry[]\n\n/** Polymorphic relation storage — used when model field is string[] (multiple targets) */\nexport interface PolymorphicRelationRef {\n model: string\n ref: string\n}\n\n// ─── Model Summary ───\n\n/** Lightweight model info for listing and status operations */\nexport interface ModelSummary {\n id: string\n kind: ModelKind\n domain: string\n i18n: boolean\n fields: number\n}\n\n// ─── File Framework ───\n\n/** Source file framework — detected from extension for normalize/apply operations */\nexport type FileFramework = 'vue' | 'svelte' | 'jsx' | 'astro' | 'script'\n\n// ─── Path Conventions ───\n\n/** Root directory name for all Contentrain data */\nexport const CONTENTRAIN_DIR = '.contentrain' as const\n\n/**\n * Default path patterns for Contentrain projects.\n * Variables: {domain}, {modelId}, {locale}, {slug}\n *\n * When a model has `content_path` set, content files are written to\n * `{projectRoot}/{content_path}/` instead — but meta files always\n * remain under `.contentrain/meta/`.\n */\n/**\n * Where each kind of file lives. These are the paths a reader can rely on and\n * a writer must produce; `@contentrain/mcp` resolves exactly these, and a\n * parity test there holds the two together.\n *\n * They show the default `locale_strategy: 'file'`. The `suffix`, `directory`\n * and `none` strategies move the locale within the same directory — see\n * `LocaleStrategy` — and `content_path` replaces the\n * `.contentrain/content/{domain}/{modelId}` prefix entirely.\n *\n * Meta always carries a locale in its file name: a non-i18n model keeps one\n * meta record, pinned to the project's default locale.\n */\nexport const PATH_PATTERNS = {\n config: '.contentrain/config.json',\n context: '.contentrain/context.json',\n vocabulary: '.contentrain/vocabulary.json',\n model: '.contentrain/models/{modelId}.json',\n /** Detected source capabilities and the provider chosen for each — `CapabilityManifest`. */\n capabilities: '.contentrain/capabilities.json',\n /** Scheduled and event-driven agent work — `AutomationDefinition[]`. */\n automations: '.contentrain/automations.json',\n /** Which operations need whose approval — `ApprovalPolicyFile`. */\n approvalPolicies: '.contentrain/approval-policies.json',\n /** Source→destination URL map, the output of a migration's `moved` entries. */\n redirects: '.contentrain/redirects.json',\n content: {\n singleton: '.contentrain/content/{domain}/{modelId}/{locale}.json',\n collection: '.contentrain/content/{domain}/{modelId}/{locale}.json',\n document: '.contentrain/content/{domain}/{modelId}/{slug}/{locale}.md',\n dictionary: '.contentrain/content/{domain}/{modelId}/{locale}.json',\n /** Non-i18n JSON content (i18n: false) */\n noLocale: '.contentrain/content/{domain}/{modelId}/data.json',\n /** Non-i18n document (i18n: false) */\n noLocaleDocument: '.contentrain/content/{domain}/{modelId}/{slug}.md',\n },\n meta: {\n singleton: '.contentrain/meta/{modelId}/{locale}.json',\n collection: '.contentrain/meta/{modelId}/{locale}.json',\n document: '.contentrain/meta/{modelId}/{slug}/{locale}.json',\n dictionary: '.contentrain/meta/{modelId}/{locale}.json',\n },\n} as const\n\n/**\n * Paths this repository reserves but does not yet write.\n *\n * Reserving them is the point. `.contentrain/` is a shared namespace: the\n * migration engine, Studio and a customer's own tooling all write into it. A\n * name claimed here cannot later be taken for something else, and — more\n * usefully — the tools that walk the directory know these four are *expected*\n * rather than stray.\n *\n * The contract for them is deliberately narrow and must stay that way until a\n * tool actually owns one:\n *\n * - `contentrain_doctor` and `contentrain_validate` ignore them. They are not\n * orphans, not broken content, and not the validator's business.\n * - `contentrain_reconcile` treats each as one opaque file: it takes the side\n * that changed, and reports `file_conflict` when both sides did. It never\n * merges their interiors, because it does not know their interiors.\n *\n * Both behaviours are pinned by tests in `@contentrain/mcp`, so a future\n * change to directory walking cannot quietly start flagging or auto-merging\n * these files.\n */\nexport const RESERVED_PATHS = [\n PATH_PATTERNS.capabilities,\n PATH_PATTERNS.automations,\n PATH_PATTERNS.approvalPolicies,\n PATH_PATTERNS.redirects,\n] as const\n\nexport function isReservedPath(path: string): boolean {\n return (RESERVED_PATHS as readonly string[]).includes(path)\n}\n\n// ─── Validation Patterns ───\n\n/**\n * Slug: lowercase letters and digits of any script, joined by single hyphens. A letter's combining marks belong to\n * it (Hindi, Thai and Arabic words carry them), so `\\p{M}` is part of a word. ASCII slugs are exactly what they were;\n * `validateSlug` also requires NFC, so one address has one spelling. A slug is the address of a page: it is kept as\n * the source wrote it, not transliterated.\n */\nexport const SLUG_PATTERN = /^[\\p{Ll}\\p{Lo}\\p{Lm}\\p{N}\\p{M}]+(?:-[\\p{Ll}\\p{Lo}\\p{Lm}\\p{N}\\p{M}]+)*$/u\n/** Entry ID: alphanumeric, 1-40 chars, starts with alphanumeric */\nexport const ENTRY_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,39}$/\n/** Locale: ISO 639-1 with optional region code */\nexport const LOCALE_PATTERN = /^[a-z]{2}(?:-[A-Z]{2})?$/\n\n// ─── Canonical Serialization ───\n\n/** Rules for deterministic JSON output — ensures stable git diffs */\nexport const CANONICAL_JSON = {\n indent: 2,\n encoding: 'utf-8',\n trailingNewline: true,\n omitNull: true,\n omitDefaults: true,\n sortKeys: true,\n} as const\n\n/**\n * Key order for canonical model JSON — the required keys first, then the optional\n * ones, then `fields` last so a reader sees the model's identity before scrolling\n * past its schema.\n *\n * This lives here, and only here, because `canonicalStringify` falls back to\n * alphabetical for any key absent from the order: a forgotten entry does not error,\n * it silently relocates. `satisfies` catches a key that is not on `ModelDefinition`;\n * the `keyof` assertion in index.test.ts catches a `ModelDefinition` key missing\n * from this array. Between them, drift fails `tsc`, which runs on every commit.\n */\nexport const MODEL_FIELD_ORDER = [\n 'id',\n 'name',\n 'kind',\n 'domain',\n 'i18n',\n 'locales',\n 'title_field',\n 'description',\n 'content_path',\n 'locale_strategy',\n 'fields',\n 'form',\n 'comments',\n] as const satisfies readonly (keyof ModelDefinition)[]\n\n// ─── Scaffold ───\n\nexport interface ScaffoldTemplate {\n id: string\n models: ModelDefinition[]\n sample_content?: Record<string, Record<string, unknown>>\n vocabulary?: Record<string, Record<string, string>>\n}\n\n// ─── Scan Types ───\n\nexport type StringContext =\n | 'jsx_text' | 'jsx_attribute'\n | 'template_text' | 'template_attribute'\n | 'variable_assignment' | 'function_argument'\n | 'object_value' | 'other'\n\nexport type FileCategory = 'page' | 'component' | 'layout' | 'other'\n\nexport interface ScanCandidate {\n file: string\n line: number\n column: number\n value: string\n context: StringContext\n surrounding: string\n /** Content confidence score (0-1). Higher = more likely user-visible content. */\n contentScore: number\n /** All locations where this value appears (populated when dedup is enabled). */\n occurrences: Array<{ file: string; line: number }>\n}\n\nexport interface DuplicateGroup {\n value: string\n count: number\n occurrences: Array<{ file: string; line: number }>\n}\n\nexport interface GraphNode {\n file: string\n category: FileCategory\n components?: string[]\n imports: string[]\n used_by: string[]\n strings: number\n}\n\nexport interface ProjectGraph {\n pages: GraphNode[]\n components: GraphNode[]\n layouts: GraphNode[]\n orphan_files: string[]\n stats: {\n total_files: number\n total_components: number\n total_pages: number\n total_strings_estimate: number\n }\n}\n\nexport interface ScanCandidatesResult {\n candidates: ScanCandidate[]\n duplicates: DuplicateGroup[]\n stats: {\n files_scanned: number\n raw_strings_found: number\n skipped: number\n low_confidence: number\n unique_candidates: number\n candidates_returned: number\n has_more: boolean\n skip_reasons: Record<string, number>\n }\n}\n\nexport interface ScanSummaryResult {\n total_files: number\n total_candidates_estimate: number\n by_directory: Record<string, { files: number; candidates: number }>\n top_repeated: Array<{ value: string; count: number }>\n sampling_note?: string\n file_types: Record<string, number>\n}\n\n// ─── Normalize Plan ───\n\nexport interface NormalizePlan {\n version: number\n status: 'pending' | 'approved' | 'rejected'\n created_at: string\n approved_at?: string\n agent: string\n scan_stats: {\n files_scanned: number\n raw_strings: number\n candidates_sent: number\n extracted: number\n skipped: number\n }\n models: NormalizePlanModel[]\n extractions: NormalizePlanExtraction[]\n patches: NormalizePlanPatch[]\n approved_models?: string[]\n}\n\nexport interface NormalizePlanModel {\n id: string\n kind: ModelKind\n domain: string\n i18n?: boolean\n fields: Record<string, FieldDef>\n}\n\nexport interface NormalizePlanExtraction {\n value: string\n file: string\n line: number\n model: string\n field: string\n locale?: string\n}\n\nexport interface NormalizePlanPatch {\n file: string\n line: number\n old_value: string\n new_expression: string\n import_statement?: string\n}\n\n// ─── Context ───\n\nexport interface ContextJson {\n version: string\n lastOperation: {\n tool: string\n model: string\n locale: string\n entries?: string[]\n timestamp: string\n source: ContextSource\n }\n stats: {\n models: number\n entries: number\n locales: string[]\n lastSync: string\n }\n}\n\n// ─── Git Transaction Types ───\n\n/** Standard name for the dedicated content branch */\nexport const CONTENTRAIN_BRANCH = 'contentrain' as const\n\n/** Result of selective file sync to developer's working tree */\nexport interface SyncResult {\n /** Files successfully synced to working tree */\n synced: string[]\n /** Files skipped due to uncommitted local changes */\n skipped: string[]\n /** Human-readable warning if files were skipped */\n warning?: string\n}\n\n/** Structured error for git operations with actionable guidance */\nexport interface ContentrainError {\n /** Machine-readable error code */\n code: string\n /** Human-readable error message */\n message: string\n /** Guidance for AI agents on how to handle this error */\n agent_hint: string\n /** Command or action the developer should take */\n developer_action: string\n}\n\n// ─── Validate: Pure Functions ───\n\n/**\n * Patterns for detecting a credential that has been pasted into content.\n *\n * Each one matches a credential-shaped *value*, not prose that mentions\n * credentials. That distinction is the whole design: the earlier set matched\n * bare prefixes, so `task_id` and `risk_level` tripped `sk_`, every page\n * documenting `Authorization: Bearer <token>` tripped `Bearer `, and\n * `postgres://localhost:5432/mydb` in a getting-started guide tripped the\n * connection-string rule. On this repository — which stores its own\n * documentation as content — that was 7 of 12 validation errors, and the real\n * findings were buried under them.\n *\n * A check that fires on the wrong thing is not a strict check; it is a check\n * people learn to scroll past.\n *\n * Every pattern here carries a provider-specific shape — a fixed prefix, a\n * credential-only URL form, a PEM header — and is trusted on its own. The one\n * generic rule, `api_key = …`, has no such shape and lives in\n * {@link API_KEY_ASSIGNMENT}, where what it captures is checked before it fires.\n */\nexport const SECRET_PATTERNS: ReadonlyArray<RegExp> = [\n // Stripe-style keys: the prefix must start a word and carry a long body,\n // so `task_id` and `risk_level` no longer match.\n /\\b[sp]k_(?:live|test)_[A-Za-z0-9]{6,}/,\n // GitHub tokens are a fixed prefix plus a long body.\n /\\bgh[pousr]_[A-Za-z0-9]{10,}/,\n // A bearer token, not the word. Placeholders — <token>, $TOKEN, {{token}},\n // YOUR_TOKEN — are how documentation writes it, and are not secrets.\n /\\bBearer\\s+(?![<${{]|YOUR[_-]|your[_-]|xxx|XXX|\\.{3})[A-Za-z0-9._~+/-]{20,}/,\n /\\baws_secret[_a-z]*\\s*[:=]\\s*['\"`]?[A-Za-z0-9/+=]{20,}/i,\n // AWS access key IDs have a fixed shape.\n /\\bAKIA[0-9A-Z]{16}\\b/,\n // A connection string only carries a secret when it carries credentials;\n // `postgres://localhost:5432/mydb` in a guide does not.\n /\\b(?:postgres(?:ql)?|mysql|mongodb)(?:\\+srv)?:\\/\\/[^\\s:@/]+:[^\\s@/]+@/,\n // A pasted private key is unambiguous.\n /-----BEGIN [A-Z ]*PRIVATE KEY-----/,\n]\n\n/** Validate a slug — returns error message or null if valid */\nexport function validateSlug(slug: string): string | null {\n if (!slug) return 'Slug is required'\n if (!SLUG_PATTERN.test(slug)) return `Invalid slug \"${slug}\": must be kebab-case letters and digits (lowercase, hyphens between words)`\n if (slug !== slug.normalize('NFC')) return `Invalid slug \"${slug}\": must be Unicode NFC`\n // A slug is also a file name (`<slug>.md`): keep it within what file systems take (255 bytes, with the extension).\n if (new TextEncoder().encode(slug).length > 200) return `Invalid slug \"${slug}\": longer than 200 bytes`\n if (slug.startsWith('.') || slug.includes('..')) return `Invalid slug \"${slug}\": path traversal not allowed`\n return null\n}\n\n/** Validate an entry ID — returns error message or null if valid */\nexport function validateEntryId(id: string): string | null {\n if (!ENTRY_ID_PATTERN.test(id)) return `Invalid entry ID \"${id}\": must be 1-40 alphanumeric characters (hyphens and underscores allowed)`\n return null\n}\n\n/** Validate a locale code against pattern and config — returns error message or null if valid */\nexport function validateLocale(locale: string, config: ContentrainConfig): string | null {\n if (!LOCALE_PATTERN.test(locale)) return `Invalid locale \"${locale}\": must be ISO 639-1 format (e.g. \"en\", \"tr\", \"pt-BR\")`\n if (!config.locales.supported.includes(locale)) {\n return `Locale \"${locale}\" is not in supported locales [${config.locales.supported.join(', ')}]. Update config first.`\n }\n return null\n}\n\n/**\n * The locales one model's content is checked against, and where that list came\n * from — `'model'` when the model narrowed it with `locales`, `'project'` when\n * it is the project-wide list (or, for a non-i18n model, the default locale).\n *\n * The provenance travels with the list because every message derived from it\n * has to say which list it evaluated: \"missing tr\" means something different\n * when tr is one of four project locales than when the model declares it.\n */\nexport interface ModelLocaleScope {\n locales: string[]\n source: 'model' | 'project'\n}\n\n/**\n * The locales a model's content is expected to cover.\n *\n * A non-i18n model stores content locale-agnostically, so its scope is the\n * default locale alone — unchanged, and `locales` never applies to it. An i18n\n * model narrows to `model.locales` when it declares one, otherwise it covers\n * every supported locale, which is what every model did before the field\n * existed.\n *\n * Locales are returned in `config.locales.supported` order, not the order the\n * model happened to list them: a message that names the list should read the\n * same for two models that declare the same locales differently. Anything the\n * model names that the project does not support is dropped here and reported\n * by {@link validateModelLocales} — resolution never invents coverage.\n */\nexport function resolveModelLocales(\n model: Pick<ModelDefinition, 'i18n' | 'locales'>,\n config: ContentrainConfig,\n): ModelLocaleScope {\n if (!model.i18n) return { locales: [config.locales.default], source: 'project' }\n const declared = model.locales\n if (!Array.isArray(declared) || declared.length === 0) {\n return { locales: [...config.locales.supported], source: 'project' }\n }\n const narrowed = config.locales.supported.filter(l => declared.includes(l))\n if (narrowed.length === 0) return { locales: [...config.locales.supported], source: 'project' }\n return { locales: narrowed, source: 'model' }\n}\n\n/** Name a locale scope the way a validation message should quote it. */\nexport function describeModelLocaleScope(scope: ModelLocaleScope): string {\n const list = `[${scope.locales.join(', ')}]`\n return scope.source === 'model'\n ? `the model's own locales ${list}`\n : `the project's supported locales ${list}`\n}\n\n/**\n * Check a model's `locales` declaration against the project config.\n *\n * Absent is valid and means every supported locale, so this returns nothing for\n * the models that predate the field. What it rejects is a declaration that\n * cannot be honoured: a locale the project does not support (the coverage would\n * be unreachable), an empty list (a model that covers nothing is a deleted\n * model, not a narrowed one), a duplicate, or a non-string entry.\n *\n * Declaring `locales` on a non-i18n model is a warning, not an error: the field\n * has no effect there, but the model is still readable and its content still\n * validates, so failing the project over it would be out of proportion.\n */\nexport function validateModelLocales(\n model: Pick<ModelDefinition, 'i18n' | 'locales'>,\n config: ContentrainConfig,\n): ValidationError[] {\n if (model.locales === undefined) return []\n\n const errors: ValidationError[] = []\n const err = (message: string): void => { errors.push({ severity: 'error', field: 'locales', message }) }\n\n if (!Array.isArray(model.locales)) {\n err(`Invalid \"locales\": must be an array of locale codes, got ${typeof model.locales}.`)\n return errors\n }\n if (model.locales.length === 0) {\n err('Invalid \"locales\": an empty list covers nothing. Omit the field to cover every supported locale.')\n return errors\n }\n\n const seen = new Set<string>()\n for (const locale of model.locales) {\n if (typeof locale !== 'string') {\n err(`Invalid \"locales\": every entry must be a locale code, got ${typeof locale}.`)\n continue\n }\n if (seen.has(locale)) {\n err(`Invalid \"locales\": locale \"${locale}\" is listed more than once.`)\n continue\n }\n seen.add(locale)\n if (!config.locales.supported.includes(locale)) {\n err(\n `Invalid \"locales\": locale \"${locale}\" is not in the project's supported locales `\n + `[${config.locales.supported.join(', ')}]. A model's locales must be a subset — `\n + `add it to config.json first, or drop it here.`,\n )\n }\n }\n\n if (errors.length === 0 && !model.i18n) {\n errors.push({\n severity: 'warning',\n field: 'locales',\n message: '\"locales\" has no effect on a model with i18n:false — such a model stores one locale-agnostic copy. Remove it, or set i18n:true.',\n })\n }\n\n return errors\n}\n\n/**\n * An API key being assigned a value: `api_key = <tail>`.\n *\n * Generic by nature — there is no provider prefix, so the word plus a separator\n * is the only structural signal — and therefore the one rule that also has to\n * look at what it captured before it fires; see {@link looksLikeCredential}.\n * Without that, `api_key = your_project_api_key_here` in a setup guide was a\n * blocking error, and once `contentrain validate` exited non-zero, a permanent\n * CI failure for any project whose content discusses API keys — which, for a\n * CMS, is ordinary content. Placeholders documentation uses (`<key>`, `$KEY`,\n * `{{key}}`, `YOUR_KEY`, `xxx`, `…`) are excluded up front, as for `Bearer`.\n */\nconst API_KEY_ASSIGNMENT = /\\bapi[_-]?key\\s*[:=]\\s*['\"`]?(?![<${{]|YOUR[_-]|your[_-]|xxx|XXX|\\.{3})([A-Za-z0-9_-]{16,})/gi\n\n/**\n * Does a value assigned to an API-key setting look like a credential rather\n * than prose about one?\n *\n * Two shapes are documentation, not secrets: a tail with no digit at all\n * (`your_project_api_key_here`, `xxxxxxxxxxxxxxxxxxxx`), and a tail whose\n * `_`/`-` separated tokens are each purely letters or purely digits\n * (`my_api_key_2024`, `REPLACE-WITH-KEY-1`) — words with a version number. An\n * issued key mixes letters and digits inside one token (`8f14e45fceea167a`,\n * `k1p8-q7x2m9v4z3w6`), which no sentence does by accident.\n */\nexport function looksLikeCredential(tail: string): boolean {\n if (!/\\d/.test(tail)) return false\n const tokens = tail.split(/[_-]+/).filter(Boolean)\n if (tokens.length > 1 && tokens.every(t => /^[A-Za-z]+$/.test(t) || /^\\d+$/.test(t))) return false\n return true\n}\n\n/** Detect potential secrets in a value — returns validation errors if found */\nexport function detectSecrets(value: unknown): ValidationError[] {\n if (typeof value !== 'string') return []\n const found = SECRET_PATTERNS.some(p => p.test(value))\n || [...value.matchAll(API_KEY_ASSIGNMENT)].some(m => looksLikeCredential(m[1]!))\n if (found) {\n return [{ severity: 'error', message: 'Potential secret detected in value' }]\n }\n return []\n}\n\n/** The models a relation field may point into. */\nfunction relationTargets(fieldDef: FieldDef): string[] {\n if (Array.isArray(fieldDef.model)) return fieldDef.model\n return fieldDef.model ? [fieldDef.model] : []\n}\n\n/**\n * A reference into one of several models: `{ model, ref }`.\n *\n * A relation with more than one target model cannot store a bare id — the id\n * alone does not say which model's entry it is — so it stores the pair. With a\n * single target the id is enough and stays a string. A one-element `model`\n * array is a single target. This is the storage rule the schema documents and\n * the MCP entry validator enforces; a type check that knew only the string form\n * rejected every correctly stored polymorphic reference.\n */\nfunction isPolymorphicRef(value: unknown): value is { model: string, ref: string } {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n && typeof (value as { model?: unknown }).model === 'string'\n && typeof (value as { ref?: unknown }).ref === 'string'\n}\n\nfunction fieldTypeMatches(value: unknown, fieldDef: FieldDef): boolean {\n if (value === null || value === undefined) return true\n\n switch (fieldDef.type) {\n case 'string':\n case 'text':\n case 'email':\n case 'url':\n case 'slug':\n case 'color':\n case 'phone':\n case 'code':\n case 'icon':\n case 'markdown':\n case 'richtext':\n case 'date':\n case 'datetime':\n case 'image':\n case 'video':\n case 'file':\n case 'select':\n return typeof value === 'string'\n\n case 'number':\n case 'integer':\n case 'decimal':\n case 'percent':\n case 'rating':\n return typeof value === 'number'\n\n case 'boolean':\n return typeof value === 'boolean'\n\n case 'relation':\n return relationTargets(fieldDef).length > 1 ? isPolymorphicRef(value) : typeof value === 'string'\n\n case 'relations':\n case 'array':\n return Array.isArray(value)\n\n case 'object':\n return typeof value === 'object' && !Array.isArray(value)\n\n default:\n return true\n }\n}\n\nfunction checkMinMax(value: unknown, fieldDef: FieldDef): string | null {\n if (fieldDef.min !== undefined) {\n if (typeof value === 'number' && value < fieldDef.min) {\n return `Value ${value} is below minimum ${fieldDef.min}`\n }\n if (typeof value === 'string' && value.length < fieldDef.min) {\n return `String length ${value.length} is below minimum ${fieldDef.min}`\n }\n if (Array.isArray(value) && value.length < fieldDef.min) {\n return `Array length ${value.length} is below minimum ${fieldDef.min}`\n }\n }\n if (fieldDef.max !== undefined) {\n if (typeof value === 'number' && value > fieldDef.max) {\n return `Value ${value} is above maximum ${fieldDef.max}`\n }\n if (typeof value === 'string' && value.length > fieldDef.max) {\n return `String length ${value.length} is above maximum ${fieldDef.max}`\n }\n if (Array.isArray(value) && value.length > fieldDef.max) {\n return `Array length ${value.length} is above maximum ${fieldDef.max}`\n }\n }\n return null\n}\n\n// ─── Semantic type validation ───\n\n/** Hex (#rgb/#rrggbb/#rrggbbaa), rgb()/rgba(), hsl()/hsla(), or a bare CSS keyword. */\nconst COLOR_PATTERN = /^(#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|(?:rgb|hsl)a?\\([^)]*\\)|[a-zA-Z]+)$/\n/** Deliberately loose: digits, spaces, and the usual separators, 4-20 digits total. */\nconst PHONE_PATTERN = /^\\+?[\\d\\s().-]{4,}$/\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\nconst DATE_PATTERN = /^\\d{4}-\\d{2}-\\d{2}$/\n\nfunction isParsableDate(value: string): boolean {\n return !Number.isNaN(new Date(value).getTime())\n}\n\n/**\n * Rules a field type implies beyond its runtime `typeof`.\n *\n * Severity follows one rule: a check that is *definitional* is an error — a `slug`\n * that does not match `SLUG_PATTERN` is not a slug. A check that is a *heuristic* is\n * a warning, because the pattern is an approximation and a legitimate value may sit\n * outside it (international phone formats, CSS colour keywords).\n *\n * `rating` is deliberately absent: its scale is never declared, so any range here\n * would be invented. Declare `min`/`max` on the field instead.\n *\n * Returns `[]` for types whose only contract is their `typeof` (`string`, `text`,\n * `code`, `icon`, `markdown`, `richtext`, `number`, `decimal`, `boolean`, media).\n */\nexport function validateSemanticType(value: unknown, type: FieldType): ValidationError[] {\n if (typeof value === 'number') {\n if (type === 'integer' && !Number.isInteger(value)) {\n return [{ severity: 'error', message: `Value ${value} is not an integer` }]\n }\n if (type === 'percent' && (value < 0 || value > 100)) {\n return [{ severity: 'error', message: `Percent ${value} is outside 0-100` }]\n }\n return []\n }\n\n if (typeof value !== 'string' || value === '') return []\n\n switch (type) {\n case 'slug': {\n const err = validateSlug(value)\n return err ? [{ severity: 'error', message: err }] : []\n }\n case 'date':\n return DATE_PATTERN.test(value) && isParsableDate(value)\n ? []\n : [{ severity: 'error', message: `Invalid date \"${value}\": must be YYYY-MM-DD` }]\n case 'datetime':\n return isParsableDate(value)\n ? []\n : [{ severity: 'error', message: `Invalid datetime \"${value}\": must be an ISO 8601 date-time` }]\n case 'email':\n return EMAIL_PATTERN.test(value)\n ? []\n : [{ severity: 'warning', message: `\"${value}\" may not be a valid email` }]\n case 'url':\n return /^https?:\\/\\/.+/.test(value) || value.startsWith('/')\n ? []\n : [{ severity: 'warning', message: `\"${value}\" may not be a valid URL` }]\n case 'color':\n return COLOR_PATTERN.test(value)\n ? []\n : [{ severity: 'warning', message: `\"${value}\" may not be a valid color (expected hex, rgb(), hsl() or a keyword)` }]\n case 'phone':\n return PHONE_PATTERN.test(value) && (value.match(/\\d/g)?.length ?? 0) >= 4\n ? []\n : [{ severity: 'warning', message: `\"${value}\" may not be a valid phone number` }]\n default:\n return []\n }\n}\n\n/** Media field types — the ones whose value is a path/URL to an asset. */\nconst MEDIA_TYPES = new Set<FieldType>(['image', 'video', 'file'])\n\nexport function isMediaType(type: FieldType): boolean {\n return MEDIA_TYPES.has(type)\n}\n\n// ─── Field presentation ───\n\n/**\n * Resolve a field's label for a locale.\n *\n * Falls back in the order a reader would expect: the requested locale, the\n * default locale, any locale present, then the field name. A field with no\n * label resolves to its own name, which is what an editor shows today.\n */\nexport function resolveFieldLabel(\n fieldName: string,\n field: Pick<FieldDef, 'label'>,\n locale?: string,\n defaultLocale?: string,\n): string {\n const { label } = field\n if (typeof label === 'string') return label\n if (label && typeof label === 'object') {\n const byLocale = (locale && label[locale])\n ?? (defaultLocale && label[defaultLocale])\n ?? Object.values(label)[0]\n if (byLocale) return byLocale\n }\n return fieldName\n}\n\n/**\n * Field names in display order.\n *\n * Exported so every consumer orders identically instead of each reimplementing\n * the fallback. Ascending `order`; fields without one come last, alphabetically\n * among themselves, which is the behaviour a model that has no `order` already\n * has.\n */\nexport function orderedFieldNames(fields: Record<string, FieldDef> | undefined): string[] {\n if (!fields) return []\n return Object.keys(fields).toSorted((a, b) => {\n const oa = fields[a]?.order\n const ob = fields[b]?.order\n if (oa !== undefined && ob !== undefined && oa !== ob) return oa - ob\n if (oa !== undefined && ob === undefined) return -1\n if (oa === undefined && ob !== undefined) return 1\n return a.localeCompare(b, 'en')\n })\n}\n\n// ─── Title field ───\n\n/**\n * The reserved `title_field` value for dictionary models. A dictionary has no\n * fields — it is a flat key→string map — so there is nothing to point at. Its key\n * *is* the title, and this names that fact rather than leaving the property blank.\n * Only dictionaries may use it; on any other kind `title_field` must name a real field.\n */\nexport const DICTIONARY_TITLE_FIELD = 'key'\n\n/**\n * Field types whose value can stand in as a human-readable title.\n *\n * Narrower than \"types that hold a string\": `color`, `phone`, `icon`, `select`,\n * `date`, `datetime`, `relation` and the media types all store strings, but a row\n * labelled `#ff0000` or `f3a81c09d24e` is exactly the failure this contract exists\n * to prevent.\n */\nexport const TITLE_FIELD_TYPES = [\n 'string', 'text', 'slug', 'email', 'url', 'code', 'markdown', 'richtext',\n] as const satisfies readonly FieldType[]\n\nconst TITLE_FIELD_TYPE_SET = new Set<FieldType>(TITLE_FIELD_TYPES)\n\n/** Whether a field of this type may be named as a model's `title_field`. */\nexport function isTitleFieldType(type: FieldType): boolean {\n return TITLE_FIELD_TYPE_SET.has(type)\n}\n\n/**\n * What a `title_field` value points at. A plain name is a top-level field. A dotted\n * path `hero.heading` reaches one level into an `object` field: a page built from\n * sections is a singleton whose top level holds only section objects, and its title\n * is a section's heading — a copied top-level `title` would be a field no page prints.\n *\n * - `field`: the field the path names, and the object it sits in for a dotted path\n * - `missing`: the path names no field (`at` is the segment that failed)\n * - `not-object`: the first segment names a field that is not an `object`\n * - `too-deep`: more than one dot — a title reaches at most into one object\n */\nexport type TitleFieldTarget =\n | { kind: 'field', def: FieldDef, parent?: FieldDef }\n | { kind: 'missing', at: string }\n | { kind: 'not-object', at: string, type: FieldType }\n | { kind: 'too-deep' }\n\n/** Resolve a `title_field` path against a model's fields. A field named exactly `path` wins over splitting it. */\nexport function titleFieldTarget(fields: Record<string, FieldDef> | undefined, path: string): TitleFieldTarget {\n // A field whose own name holds a dot predates dotted paths; it keeps meaning itself.\n if (fields && Object.hasOwn(fields, path)) return { kind: 'field', def: fields[path]! }\n const segments = path.split('.')\n if (segments.length > 2) return { kind: 'too-deep' }\n const [head, leaf] = segments as [string, string | undefined]\n const def = fields && Object.hasOwn(fields, head) ? fields[head] : undefined\n if (!def) return { kind: 'missing', at: head }\n if (leaf === undefined) return { kind: 'field', def }\n if (def.type !== 'object') return { kind: 'not-object', at: head, type: def.type }\n const child = def.fields && Object.hasOwn(def.fields, leaf) ? def.fields[leaf] : undefined\n if (!child) return { kind: 'missing', at: path }\n return { kind: 'field', def: child, parent: def }\n}\n\n/** An entry's title value: `data[title_field]`, or one level into an object for a dotted path. */\nexport function titleFieldValue(data: Record<string, unknown> | null | undefined, path: string): unknown {\n if (data && Object.hasOwn(data, path)) return data[path]\n const [head, leaf, ...rest] = path.split('.')\n if (!data || head === undefined || rest.length > 0) return undefined\n const value = data[head]\n if (leaf === undefined) return value\n return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>)[leaf] : undefined\n}\n\n/** Extensions we can name a MIME type for. Deliberately small — this is a sniff. */\nconst EXTENSION_MIME: Record<string, string> = {\n jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',\n webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml', ico: 'image/x-icon',\n mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',\n pdf: 'application/pdf', zip: 'application/zip', json: 'application/json',\n csv: 'text/csv', txt: 'text/plain',\n}\n\n/**\n * Check a media value's file extension against an `accept` list.\n *\n * This is an approximation and says so: the value is a path or URL, so the real\n * MIME type is only knowable where the bytes are — the provider, at ingest\n * (`provider.ts:MediaProvider.ingest` owns that policy). An extension can lie, and\n * an unknown extension is not evidence of a violation. Hence: warning, never error,\n * and silence when we cannot tell.\n *\n * `accept` follows the HTML input syntax: `image/jpeg`, `image/*`, `.jpg`, comma-separated.\n */\nexport function validateAccept(value: string, accept: string): ValidationError[] {\n const path = value.split('?')[0]?.split('#')[0] ?? value\n const ext = path.split('.').pop()?.toLowerCase()\n if (!ext || ext === path.toLowerCase()) return []\n\n const mime = EXTENSION_MIME[ext]\n const patterns = accept.split(',').map(a => a.trim().toLowerCase()).filter(Boolean)\n if (patterns.length === 0) return []\n\n const matches = patterns.some((p) => {\n if (p.startsWith('.')) return p.slice(1) === ext\n if (p.endsWith('/*')) return mime ? mime.startsWith(p.slice(0, -1)) : false\n return mime === p\n })\n if (matches) return []\n\n // An extension we have no MIME for is not evidence of a violation.\n if (!mime && !patterns.some(p => p.startsWith('.'))) return []\n\n return [{\n severity: 'warning',\n message: `File extension \".${ext}\" does not match accept \"${accept}\" (extension check — the provider enforces the real MIME type at ingest)`,\n }]\n}\n\n/**\n * Validate a field value against its FieldDef schema.\n * Checks: required, type match, semantic type rules, min/max, pattern, select\n * options, and `accept` on media fields (by extension).\n * Does NOT check: secrets (use detectSecrets), uniqueness (stateful), relations (I/O),\n * or `maxSize` — byte length is not knowable from a stored path.\n */\nexport function validateFieldValue(value: unknown, fieldDef: FieldDef): ValidationError[] {\n const issues: ValidationError[] = []\n\n if (fieldDef.required && (value === undefined || value === null || value === '')) {\n issues.push({ severity: 'error', message: 'Required field is missing or empty' })\n return issues\n }\n\n if (value === undefined || value === null) return issues\n\n if (!fieldTypeMatches(value, fieldDef)) {\n issues.push({ severity: 'error', message: `Type mismatch: expected ${fieldDef.type}, got ${typeof value}` })\n return issues\n }\n\n issues.push(...validateSemanticType(value, fieldDef.type))\n\n if (fieldDef.type === 'relation' && isPolymorphicRef(value)) {\n const targets = relationTargets(fieldDef)\n if (!targets.includes(value.model)) {\n issues.push({ severity: 'error', message: `Relation target model \"${value.model}\" is not one of: ${targets.join(', ')}` })\n }\n }\n\n if (fieldDef.accept && isMediaType(fieldDef.type) && typeof value === 'string') {\n issues.push(...validateAccept(value, fieldDef.accept))\n }\n\n const minMaxErr = checkMinMax(value, fieldDef)\n if (minMaxErr) {\n issues.push({ severity: 'error', message: minMaxErr })\n }\n\n if (fieldDef.pattern && typeof value === 'string') {\n try {\n const regex = new RegExp(fieldDef.pattern)\n if (!regex.test(value)) {\n issues.push({ severity: 'error', message: `Value \"${value}\" does not match pattern /${fieldDef.pattern}/` })\n }\n } catch {\n issues.push({ severity: 'warning', message: `Invalid pattern regex: ${fieldDef.pattern}` })\n }\n }\n\n if (fieldDef.type === 'select' && fieldDef.options && typeof value === 'string') {\n if (!fieldDef.options.includes(value)) {\n issues.push({ severity: 'error', message: `Value \"${value}\" is not in allowed options: [${fieldDef.options.join(', ')}]` })\n }\n }\n\n return issues\n}\n\n// ─── Serialize: Pure Functions ───\n//\n// Implemented in `canonical.ts` and re-exported here: `execution.ts` hashes\n// plans with `canonicalStringify`, and this module re-exports `execution.ts`,\n// so the implementation has to sit outside the cycle.\n\nexport { canonicalStringify, sortKeys } from './canonical.js'\n\n/** Generate a 12-character hex entry ID */\nexport function generateEntryId(): string {\n const hex = '0123456789abcdef'\n let id = ''\n for (let i = 0; i < 12; i++) {\n id += hex[Math.floor(Math.random() * 16)]\n }\n return id\n}\n\n// ─── Reconcile: conflict id derivation ───\n\n/**\n * FNV-1a 64-bit over the input's UTF-8 code points, returned as 16 hex\n * chars. Deliberately dependency-free (no `node:crypto` — this package has\n * zero runtime imports and runs anywhere). Not cryptographic; the use case\n * is change detection, not tamper resistance.\n */\nexport function stableHash(input: string): string {\n const PRIME = 0x100000001B3n\n const MASK = 0xFFFFFFFFFFFFFFFFn\n let hash = 0xCBF29CE484222325n\n const bytes = new TextEncoder().encode(input)\n for (const byte of bytes) {\n hash ^= BigInt(byte)\n hash = (hash * PRIME) & MASK\n }\n return hash.toString(16).padStart(16, '0')\n}\n\n/**\n * Identity of a reconcile conflict: position AND values. Hashing the three\n * values into the id makes every resolution a compare-and-set — if any side\n * changes between the dry-run that produced the conflict and the apply that\n * carries the decision, the id no longer matches, the stale resolution is\n * dropped, and the conflict is re-reported with a fresh id. Values are\n * normalized through `sortKeys` first so semantically equal objects hash\n * equal regardless of key order; the positional array keeps absent and\n * null distinguishable per slot.\n */\nexport function conflictId(conflict: {\n path: string\n key?: string\n field?: string\n locale?: string\n base?: unknown\n ours?: unknown\n theirs?: unknown\n}): string {\n return stableHash(JSON.stringify([\n conflict.path,\n conflict.key ?? null,\n conflict.field ?? null,\n conflict.locale ?? null,\n sortKeys(conflict.base) ?? null,\n sortKeys(conflict.ours) ?? null,\n sortKeys(conflict.theirs) ?? null,\n ]))\n}\n\n// ─── Markdown Frontmatter ───\n\n/**\n * Decode the interior of a double-quoted scalar, or `undefined` when the text\n * is not one scalar after all — an unescaped `\"` inside it, or a backslash with\n * nothing after it.\n *\n * Returning `undefined` rather than guessing matters: `\"a\" and \"b\"` starts and\n * ends with a quote without being a quoted scalar, and slicing the ends off it\n * would silently corrupt the value.\n *\n * An unrecognised escape keeps its backslash instead of erroring. Our own\n * serializer only ever emits `\\\\`, `\\\"`, `\\n`, `\\r` and `\\t`, so round-tripping is\n * unaffected either way — but hand-written frontmatter says `\"C:\\Users\"` and\n * losing that to strictness would be a worse trade than keeping the bytes.\n */\nfunction decodeDoubleQuoted(inner: string): string | undefined {\n let out = ''\n for (let i = 0; i < inner.length; i += 1) {\n const char = inner[i]!\n if (char === '\"') return undefined\n if (char !== '\\\\') {\n out += char\n continue\n }\n const next = inner[i + 1]\n if (next === undefined) return undefined\n i += 1\n if (next === 'n') out += '\\n'\n else if (next === 'r') out += '\\r'\n else if (next === 't') out += '\\t'\n else if (next === '\\\\' || next === '\"' || next === '/') out += next\n else if (next === 'u' && /^[0-9a-fA-F]{4}$/.test(inner.slice(i + 1, i + 5))) {\n out += String.fromCharCode(parseInt(inner.slice(i + 1, i + 5), 16))\n i += 4\n } else out += `\\\\${next}`\n }\n return out\n}\n\n/** Decode a single-quoted scalar, where YAML escapes a quote by doubling it. */\nfunction decodeSingleQuoted(inner: string): string | undefined {\n let out = ''\n for (let i = 0; i < inner.length; i += 1) {\n const char = inner[i]!\n if (char !== \"'\") {\n out += char\n continue\n }\n if (inner[i + 1] !== \"'\") return undefined\n out += \"'\"\n i += 1\n }\n return out\n}\n\n/**\n * Read one frontmatter scalar: booleans, null, numbers, quoted strings with\n * their escapes decoded, or the text as written.\n *\n * Exported because it is a **contract**, not a utility. Two readers open these\n * documents — this package (through `parseMarkdownFrontmatter`, which\n * `@contentrain/mcp` re-exports) and `@contentrain/query`'s generator and Astro\n * loader. When each had its own copy they disagreed about what `\"He said\n * \\\"Hi\\\"\"` says, which means the generated client and the content engine\n * disagreed about the same file. One implementation, imported by both.\n */\nexport function parseFrontmatterScalar(raw: string): unknown {\n if (raw === 'true') return true\n if (raw === 'false') return false\n if (raw === 'null') return null\n if (/^-?\\d+$/.test(raw)) return parseInt(raw, 10)\n if (/^-?\\d+\\.\\d+$/.test(raw)) return parseFloat(raw)\n // Quoted scalars carry escapes. Stripping the quotes without decoding them is\n // how `He said \\\"Hi\\\"` came back with its backslashes, and how a value with a\n // backslash in it doubled them on every export.\n if (raw.length >= 2 && raw.startsWith('\"') && raw.endsWith('\"')) {\n return decodeDoubleQuoted(raw.slice(1, -1)) ?? raw\n }\n if (raw.length >= 2 && raw.startsWith(\"'\") && raw.endsWith(\"'\")) {\n return decodeSingleQuoted(raw.slice(1, -1)) ?? raw\n }\n return raw\n}\n\n/**\n * A scalar read as a string: quoting and escapes are resolved, but a value that\n * merely looks like a number or a boolean stays the text it was. Array items\n * use this, and so does any reader that knows from the model that a field is\n * string-typed — a SKU of `\"007\"` must not become the number 7.\n */\nexport function parseFrontmatterScalarString(raw: string): string {\n const value = parseFrontmatterScalar(raw)\n return typeof value === 'string' ? value : raw\n}\n\n/**\n * Split an inline array's interior on commas that are not inside a quoted\n * item — `[\"a, b\", c]` is two items, not three. Exported for the same reason\n * as {@link parseFrontmatterScalar}: both readers must split it the same way.\n */\nexport function splitFrontmatterList(inner: string): string[] {\n const items: string[] = []\n let current = ''\n let quote: '\"' | \"'\" | undefined\n for (let i = 0; i < inner.length; i += 1) {\n const char = inner[i]!\n if (quote) {\n if (char === '\\\\' && quote === '\"' && i + 1 < inner.length) {\n current += char + inner[i + 1]!\n i += 1\n continue\n }\n current += char\n if (char === quote) quote = undefined\n continue\n }\n if (char === '\"' || char === \"'\") {\n quote = char\n current += char\n continue\n }\n if (char === ',') {\n items.push(current)\n current = ''\n continue\n }\n current += char\n }\n items.push(current)\n return items\n}\n\nfunction yamlScalar(value: unknown): string {\n const str = String(value)\n // A newline, a tab or edge whitespace cannot survive a bare scalar: the\n // second line of a multi-line value was being written straight into the\n // frontmatter, where the reader saw a line that is not `key: value` and\n // skipped it — the value was silently truncated at its first newline. Edge\n // whitespace was lost to the reader's `trim()` for the same reason.\n if (str.includes(':') || str.includes('#') || str.includes('{') || str.includes('}')\n || str.includes('[') || str.includes(']') || str.includes('*') || str.includes('&')\n || str.includes('!') || str.includes('|') || str.includes('>') || str.includes(\"'\")\n || str.includes('\"') || str.includes('%') || str.includes('@') || str.includes('`')\n || str.includes('\\\\') || str.includes('\\n') || str.includes('\\r') || str.includes('\\t')\n || str.startsWith('-') || str.startsWith('?') || str !== str.trim()\n || str === ''\n // A string whose bare form would read back as something else. `'42'` was\n // written bare and returned as the number 42 — a slug, a version or a title\n // that happens to be digits changed type on every save. Asking the reader\n // is exact where a list of shapes drifts away from it.\n //\n // The type check is the other half. The old rule quoted anything spelled\n // `true`, so a real boolean was written as `\"true\"` and came back a string.\n // Only a *string* that would be misread needs the quotes; a boolean, a\n // number or a null is written bare precisely so it reads back as itself.\n // `yes`/`no` are not keywords to this reader but are to others, so a string\n // spelled that way is quoted for the benefit of whoever else opens the file.\n || (typeof value === 'string' && (parseFrontmatterScalar(str) !== str || str === 'yes' || str === 'no'))) {\n return `\"${str\n .replace(/\\\\/g, '\\\\\\\\')\n .replace(/\"/g, '\\\\\"')\n .replace(/\\n/g, '\\\\n')\n .replace(/\\r/g, '\\\\r')\n .replace(/\\t/g, '\\\\t')}\"`\n }\n return str\n}\n\nfunction serializeYamlValue(value: unknown, indent: number): string[] {\n const pad = ' '.repeat(indent)\n if (value === null || value === undefined) return []\n if (typeof value === 'object' && !Array.isArray(value)) {\n const lines: string[] = []\n for (const [k, v] of Object.entries(value as Record<string, unknown>)) {\n if (v === null || v === undefined) continue\n if (typeof v === 'object' && !Array.isArray(v)) {\n lines.push(`${pad}${k}:`)\n lines.push(...serializeYamlValue(v, indent + 1))\n } else if (Array.isArray(v)) {\n lines.push(`${pad}${k}:`)\n for (const item of v) {\n if (typeof item === 'object' && item !== null && !Array.isArray(item)) {\n const nested = serializeYamlValue(item, indent + 2)\n if (nested.length > 0) {\n lines.push(`${pad} - ${nested[0]!.trimStart()}`)\n lines.push(...nested.slice(1))\n }\n } else {\n lines.push(`${pad} - ${yamlScalar(item)}`)\n }\n }\n } else {\n lines.push(`${pad}${k}: ${yamlScalar(v)}`)\n }\n }\n return lines\n }\n if (Array.isArray(value)) {\n const lines: string[] = []\n for (const item of value) {\n if (typeof item === 'object' && item !== null && !Array.isArray(item)) {\n const nested = serializeYamlValue(item, indent + 1)\n if (nested.length > 0) {\n lines.push(`${pad}- ${nested[0]!.trimStart()}`)\n lines.push(...nested.slice(1))\n }\n } else {\n lines.push(`${pad}- ${yamlScalar(item)}`)\n }\n }\n return lines\n }\n return [`${pad}${yamlScalar(value)}`]\n}\n\nfunction serializeYamlFields(data: Record<string, unknown>): string[] {\n const lines: string[] = []\n for (const [key, value] of Object.entries(data)) {\n if (key === 'body') continue\n if (value === null || value === undefined) continue\n if (typeof value === 'object' && !Array.isArray(value)) {\n lines.push(`${key}:`)\n lines.push(...serializeYamlValue(value, 1))\n } else if (Array.isArray(value)) {\n // A bare `key:` is genuinely ambiguous — empty array, empty object, or\n // null — and two readers guessed it differently. Writing `[]` says which\n // one it is instead of asking anyone to infer it from the next line.\n if (value.length === 0) {\n lines.push(`${key}: []`)\n continue\n }\n lines.push(`${key}:`)\n for (const item of value) {\n if (typeof item === 'object' && item !== null && !Array.isArray(item)) {\n const nested = serializeYamlValue(item, 2)\n if (nested.length > 0) {\n lines.push(` - ${nested[0]!.trimStart()}`)\n lines.push(...nested.slice(1))\n }\n } else {\n lines.push(` - ${yamlScalar(item)}`)\n }\n }\n } else {\n lines.push(`${key}: ${yamlScalar(value)}`)\n }\n }\n return lines\n}\n\n/** Parse markdown frontmatter — extracts YAML frontmatter and body from a markdown string */\nexport function parseMarkdownFrontmatter(content: string): { frontmatter: Record<string, unknown>; body: string } {\n const normalized = content.replace(/\\r\\n/g, '\\n')\n const match = normalized.match(/^---\\n([\\s\\S]*?)\\n---\\n?([\\s\\S]*)$/)\n if (!match) return { frontmatter: {}, body: normalized }\n\n const frontmatterStr = match[1]!\n const body = match[2]!.trim()\n const frontmatter: Record<string, unknown> = {}\n\n let currentKey: string | null = null\n let currentArray: string[] | null = null\n\n for (const line of frontmatterStr.split('\\n')) {\n if (/^\\s+-\\s+/.test(line) && currentKey) {\n const value = parseFrontmatterScalarString(line.replace(/^\\s+-\\s+/, '').trim())\n if (!currentArray) currentArray = []\n currentArray.push(value)\n continue\n }\n\n if (currentKey && currentArray) {\n frontmatter[currentKey] = currentArray\n currentKey = null\n currentArray = null\n }\n\n const kvMatch = line.match(/^([\\w][\\w.-]*)\\s*:\\s*(.*)$/)\n if (!kvMatch) continue\n\n const key = kvMatch[1]!\n const rawValue = kvMatch[2]!.trim()\n\n if (rawValue === '') {\n currentKey = key\n currentArray = []\n continue\n }\n\n if (rawValue.startsWith('[') && rawValue.endsWith(']')) {\n const inner = rawValue.slice(1, -1).trim()\n frontmatter[key] = inner === '' ? [] : splitFrontmatterList(inner).map(item => parseFrontmatterScalarString(item.trim()))\n continue\n }\n\n frontmatter[key] = parseFrontmatterScalar(rawValue)\n }\n\n if (currentKey && currentArray) {\n frontmatter[currentKey] = currentArray\n }\n\n return { frontmatter, body }\n}\n\n/** Serialize data + body into markdown frontmatter format */\nexport function serializeMarkdownFrontmatter(data: Record<string, unknown>, body: string): string {\n const trimmedBody = body.trimStart()\n if (trimmedBody.startsWith('---')) {\n const endIdx = trimmedBody.indexOf('---', 3)\n if (endIdx > 0) {\n const bodyFmContent = trimmedBody.slice(3, endIdx).trim()\n const afterFm = trimmedBody.slice(endIdx + 3)\n\n const lines: string[] = ['---']\n lines.push(...serializeYamlFields(data))\n if (bodyFmContent) {\n const modelKeys = new Set(Object.keys(data))\n for (const line of bodyFmContent.split('\\n')) {\n if (line.length > 0 && line[0] !== ' ' && line[0] !== '-') {\n const colonIdx = line.indexOf(':')\n const lineKey = colonIdx > 0 ? line.slice(0, colonIdx).trim() : ''\n if (lineKey && modelKeys.has(lineKey)) continue\n }\n lines.push(line)\n }\n }\n lines.push('---')\n if (afterFm.trim()) {\n lines.push(afterFm.trimStart())\n }\n lines.push('')\n return lines.join('\\n')\n }\n }\n\n const lines: string[] = ['---']\n lines.push(...serializeYamlFields(data))\n lines.push('---')\n lines.push('')\n if (body) {\n lines.push(body)\n lines.push('')\n }\n return lines.join('\\n')\n}\n\n// ─── Migration contracts ───\n\nexport * from './migration.js'\n\n// ─── Execution, approval and source-delta contracts ───\n\nexport * from './execution.js'\n\n// ─── Approval evaluator ───\n//\n// The decision procedure over the contracts above: risk + scope + policy →\n// what must be approved, and whether it has been. Pure and dependency-free, so\n// Studio, a CLI and a test all reach the same answer.\n\nexport * from './approval.js'\n\n// ─── Migrate → Studio claim ───\n//\n// The signed hand-off from a paid Migrate order to a Studio trial: payload,\n// constants and a pure validator. Signing and verification (EdDSA) live in\n// the two apps; both bind to this one shape.\n\nexport * from './migrate-claim.js'\n\n// ─── Project plan ───\n//\n// Migrate v3: facts → one plan (models, components, routes, bindings) → a\n// deterministic writer. Content is bound, never copied.\n\nexport * from './project-plan.js'\n\n// ─── Repository provider contracts ───\n//\n// Provider-agnostic engine contracts used by @contentrain/mcp. Exposed from\n// @contentrain/types so third-party tools can implement a custom\n// RepoProvider without taking a dependency on @contentrain/mcp.\nexport type {\n ApplyPlanInput,\n BaseAdvance,\n Branch,\n Commit,\n CommitAuthor,\n ConflictCode,\n ConflictItem,\n ConflictResolution,\n ContentReadSource,\n FileChange,\n FileDiff,\n MediaAsset,\n MediaIngestInput,\n MediaListOptions,\n MediaListResult,\n MediaProvider,\n MediaUpdateInput,\n MergeResult,\n ProviderCapabilities,\n RemotePush,\n RepoProvider,\n RepoReader,\n RepoWriter,\n WriteReadiness,\n} from './provider.js'\nexport { LOCAL_CAPABILITIES } from './provider.js'\n\n// Implemented in `media.ts` and re-exported here: both `@contentrain/mcp`\n// (write-time normalization) and `@contentrain/query` (read-time resolution)\n// share this single rewrite engine, so the rules can only be defined once.\nexport {\n isStoredMediaPath,\n rewriteEntryMedia,\n rewriteFieldMedia,\n rewriteMarkdownMedia,\n rewriteMediaUrl,\n toDeliveryUrl,\n} from './media.js'\n"],"mappings":"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"}
|