@contentrain/types 1.17.0 → 1.18.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/README.md CHANGED
@@ -55,6 +55,7 @@ Core interfaces:
55
55
 
56
56
  - `FieldDef`
57
57
  - `ModelDefinition`
58
+ - `ModelLocaleScope`
58
59
  - `ModelSummary`
59
60
  - `ContentrainConfig`
60
61
  - `Vocabulary`
@@ -127,6 +128,9 @@ Validate functions (pure, dependency-free):
127
128
  - `validateSlug(slug)` — kebab-case slug validation
128
129
  - `validateEntryId(id)` — entry ID format validation
129
130
  - `validateLocale(locale, config)` — locale format + config support check
131
+ - `resolveModelLocales(model, config)` — the locales a model's content is expected to cover: `model.locales` when it declares one, otherwise `config.locales.supported` (the default locale alone when `i18n: false`)
132
+ - `describeModelLocaleScope(scope)` — name a resolved scope the way a validation message should quote it
133
+ - `validateModelLocales(model, config)` — check a `locales` declaration is a subset of `config.locales.supported`
130
134
  - `detectSecrets(value)` — detect potential secrets in field values (provider-shaped patterns, plus an `api_key = …` assignment whose tail passes `looksLikeCredential`)
131
135
  - `validateFieldValue(value, fieldDef)` — full field schema validation (type, required, min/max, pattern, select)
132
136
 
@@ -182,6 +186,9 @@ const model: ModelDefinition = {
182
186
  kind: 'collection',
183
187
  domain: 'blog',
184
188
  i18n: true,
189
+ // Optional. Absent means every locale in config.locales.supported; a subset
190
+ // narrows validation for a partially-translated model.
191
+ locales: ['en', 'tr'],
185
192
  fields,
186
193
  }
187
194
 
@@ -250,6 +257,8 @@ Studio (Nuxt 4, web) cannot import `@contentrain/mcp` directly because MCP depen
250
257
  | `validateSlug(slug)` | Form validation for document slugs |
251
258
  | `validateEntryId(id)` | Validate collection entry IDs |
252
259
  | `validateLocale(locale, config)` | Locale picker validation |
260
+ | `resolveModelLocales(model, config)` | Which locales to show, or require, for one model |
261
+ | `validateModelLocales(model, config)` | Validate a model's locale-coverage subset in a model editor |
253
262
  | `detectSecrets(value)` | Content editor secret detection warnings |
254
263
  | `validateFieldValue(value, fieldDef)` | Full field-level validation in content forms |
255
264
  | `canonicalStringify(data, fieldOrder?)` | Preview canonical JSON output |
@@ -328,7 +337,7 @@ Shared shapes for the WordPress → static-site migration pipeline. They exist h
328
337
 
329
338
  | Contract | Role |
330
339
  |---|---|
331
- | `RawIR` | Source-faithful extraction of a WordPress site (posts, terms, menus, comments, media, redirects) with provenance: which access rung produced it (`rest_public` → `rest_auth` → `wxr` → `bridge`). Unresolved references are kept and marked, never dropped. |
340
+ | `RawIR` | Source-faithful extraction of a WordPress site (posts, terms, menus, comments, media, redirects, and from the bridge rung SEO and routing) with provenance: which access rung produced it (`rest_public` → `rest_auth` → `wxr` → `bridge`). Unresolved references are kept and marked, never dropped. |
332
341
  | `CapabilityManifest` | Evidence-based inventory of what the site uses (SEO, forms, comments, i18n, ACF, …) — the input for migration planning and the "what happens to X" conversation. `access.achieved` is the highest source-access rung actually reached, or `null` when none was: a site behind an access wall still gets a manifest, and a producer that writes a rung it did not reach makes the coverage figures unreadable. |
333
342
  | `ProjectIR` | The reproducible model of the site: route model, layout families, component variants, query bindings, design tokens. Not "this page's HTML" — the design system that generates unseen pages correctly. |
334
343
  | `MigrationHandoff` | What the migration hands the user: repository, per-capability dispositions, and offers for runtime capabilities (with cost comparison) — offering is this document's job; fulfilling is the receiving product's. `runtime` (`RuntimeBinding`) records where the generated site's runtime components were bound once an offer was fulfilled. `content_summary.body_kinds` counts standalone pages by `PageBodyKind` — `html` (editor markup, still editable), `builder_html` (rendered by a page builder, carried as rendered), `none` (the theme renders it) — because each promises something different to the person receiving the site. |
@@ -336,6 +345,16 @@ Shared shapes for the WordPress → static-site migration pipeline. They exist h
336
345
 
337
346
  All are plain JSON (snake_case keys), stamped with `MIGRATION_CONTRACT_VERSION`.
338
347
 
348
+ `RawIR` also carries five optional parts from the bridge rung:
349
+
350
+ - **`seo`** (`RawSeo`) holds which plugin serves the head (`serving`: `yoast` / `rank_math` / `aioseo` / `wordpress-core`), each provider's status and settings, and each page's values keyed by `post:<id>` or `term:<taxonomy>:<id>`. `status: 'none'` is an answer, not a missing export: no SEO plugin, and WordPress core renders only the title. `resolved: true` marks values the running plugin rendered; `false` marks stored values and templates only. `robots_served` is what the page actually carries after WordPress core and the plugin reconcile it.
351
+ - **`routing`** (`RawRouting`) states the URL rules that `RawPost.link` holds as facts: permalink structure, bases, the front and posts pages, and each post type's and taxonomy's permastruct, with `with_front` applied.
352
+ - **`redirects_excluded`** (`RawRedirectExcluded`) lists every rule a source holds that the site does not serve as a plain redirect, with the reason (`disabled`, `source-inactive`, `conditional-match:*`, `not-a-redirect:*`, …). Served plus excluded accounts for the whole table.
353
+ - **`hardcoded_text`** (`RawHardcodedText`) accounts for the interface text outside the content tables — theme templates, scripts, widgets, menus, options, Customizer mods and rendered pages. Each `RawTextCandidate` has one outcome: `transfer` to a `target` (`dictionary:ui-strings`, `theme-settings.<mod>`, `site.title`, `site.description`, `content:wp-menu-items`) or `exclude` with a `reason` (`TEXT_EXCLUDE_REASONS`); a source that could not be read is listed in `errors`. Candidates merge only when text, locale and context are equal, and `key` depends on text and context only. Page text that also appears in source is `rendered-from-source` with `related` pointing at the source candidate, so the same words never land twice. `totals` closes: transfer + exclude = candidates, and error = `errors.length`.
354
+ - **`integrations`** (`RawIntegration[]`) lists the outside services the site is connected to (`INTEGRATION_CATEGORIES`: analytics, crm, newsletter, ads, comments, captcha, cdn, other), each with its evidence. Evidence names a plugin, a setting, a script host, a form's wiring or an embed host, never a value. `reconnect_required` is `false` only for an embed, which has no account. `secret_present` says a credential is set on WordPress; the credential itself is never exported. `IntegrationReconnectRequiredIssue` (`code: 'integration_reconnect_required'`) is the one intake/handoff issue that lists every service to connect again.
355
+
356
+ `RawRedirect` gains `id`, `match` and `regex`. Only `match: 'url'` without `regex` is a one-to-one mapping. A consumer that writes a regex or prefix rule as a literal `from` produces the wrong redirect.
357
+
339
358
  Chrome markers the emitter honours: `CHROME_BODY_SLOT` (where page content goes), `CHROME_REPEAT_OPEN`/`CHROME_IF_OPEN` (per-item and conditional regions), `LIST_ITEMS_SLOT` (where a list section's items go) and `componentSlot(id)` (`<!--@@component:ID@@-->`, where a `ComponentDef` — a `comments` thread, a `form` — is mounted).
340
359
 
341
360
  `ModelDefinition` can carry runtime-owned `form` and `comments` configuration.
@@ -365,6 +384,7 @@ approval guarding it would be theatre.
365
384
  | `DeploymentTarget` | Where a build is published. Carries a `secret_ref`, never a secret — this document is written to git. |
366
385
  | `AutomationDefinition` | Reserved shape for `.contentrain/automations.json`. Nothing reads it yet; it exists so the first writer does not invent a fourth vocabulary for schedules. |
367
386
  | `SourceDeltaPlan` | The WordPress→repo delta — *not* `contentrain_reconcile`, which merges two git branches and knows nothing about WordPress. Carries explicit deletion tombstones, because `modified_after` is a filter on changed records and never reports a deletion, plus slug moves (which generate redirects) and semantic conflicts. `deletions_detectable: false` must not be read as "nothing was deleted". |
387
+ | `SourceInventory` / `SourceInventoryRecord` | Every origin record in scope at one moment — what a `bridge_inventory` cursor's hash identifies. Deletions and moves are proven by comparing two inventories (records keyed by `wp_type` + `wp_id`, updates decided on `fingerprint`); `modified_after` only narrows what to fetch again. `SourceDeltaEntry` also carries `path_before`/`path_after` for moves a slug cannot express, and `deleted_kind` (`trashed` / `purged`). |
368
388
 
369
389
  ### The evaluator
370
390
 
package/dist/index.d.mts CHANGED
@@ -216,13 +216,338 @@ interface RawComment {
216
216
  user_id?: number | null;
217
217
  meta?: Record<string, unknown>;
218
218
  }
219
- /** A redirect rule (e.g. from the Redirection plugin — visible from the authenticated rung up). */
219
+ /**
220
+ * How a redirect's `from` is compared with a request path. Only `url` without
221
+ * `regex` is a plain one-to-one mapping; the others are patterns, and turning
222
+ * one into a literal `from` produces the wrong redirect.
223
+ */
224
+ type RawRedirectMatch = 'url' | 'regex' | 'start' | 'contains' | 'end';
225
+ /** A redirect rule the live site serves (e.g. from the Redirection plugin — visible from the authenticated rung up). */
220
226
  interface RawRedirect {
221
227
  from: string;
222
228
  to: string;
223
229
  status?: number;
224
230
  /** Which plugin/table produced the rule. */
225
231
  source?: string;
232
+ /** Stable id within the export, `<source>:<source-specific id>`. */
233
+ id?: string;
234
+ /** How `from` matches; absent means `url`. */
235
+ match?: RawRedirectMatch;
236
+ /** `from` is a regular expression in the source's own dialect. */
237
+ regex?: boolean;
238
+ /** The source's own module/group serving it, when it has one (e.g. Redirection's "Apache"). */
239
+ served_by?: string;
240
+ /** An adjustment the extractor made, stated rather than hidden (e.g. a non-redirect status read as 301). */
241
+ status_note?: string;
242
+ }
243
+ /**
244
+ * A rule a source holds that the site does not serve as a plain redirect, with
245
+ * the reason. Every rule lands in `RawIR.redirects` or here, so the two
246
+ * together account for the source's whole table.
247
+ */
248
+ interface RawRedirectExcluded extends Omit<Partial<RawRedirect>, 'match'> {
249
+ id: string;
250
+ source: string;
251
+ /** The source's own match type — may be a condition (`login`, `referrer`, …) rather than a {@link RawRedirectMatch}. */
252
+ match?: RawRedirectMatch | (string & {});
253
+ reason: 'disabled' | 'source-inactive' | 'slug-reused' | 'no-slug-address' | `not-a-redirect:${string}` | `conditional-match:${string}`;
254
+ /** The condition a conditional rule depends on, verbatim. */
255
+ condition?: unknown;
256
+ }
257
+ declare const SEO_PROVIDERS: readonly ["yoast", "rank_math", "aioseo"];
258
+ type SeoProvider = (typeof SEO_PROVIDERS)[number];
259
+ /** One page's SEO as one provider holds it. */
260
+ interface RawSeoEntry {
261
+ /** true: the running plugin rendered these values; false: stored values and templates only. */
262
+ resolved?: boolean;
263
+ title?: string;
264
+ description?: string;
265
+ canonical?: string;
266
+ robots?: {
267
+ index?: 'index' | 'noindex';
268
+ follow?: 'follow' | 'nofollow';
269
+ advanced?: string[];
270
+ };
271
+ /** The directives the page actually carries, after WordPress core and the plugin reconcile them. */
272
+ robots_served?: string[];
273
+ open_graph?: {
274
+ title?: string;
275
+ description?: string;
276
+ type?: string;
277
+ url?: string;
278
+ image?: string;
279
+ site_name?: string;
280
+ };
281
+ twitter?: {
282
+ card?: string;
283
+ title?: string;
284
+ description?: string;
285
+ image?: string;
286
+ };
287
+ focus_keyword?: string;
288
+ /** Schema.org types, and the JSON-LD graph when the plugin rendered it. */
289
+ schema?: {
290
+ types: string[];
291
+ graph?: unknown;
292
+ };
293
+ /** What the plugin stored for this page (templates such as `%%title%% %%sep%%`), secrets removed. */
294
+ stored?: Record<string, unknown>;
295
+ }
296
+ /** A provider's site-wide settings. */
297
+ interface RawSeoSettings {
298
+ separator?: string | null;
299
+ /** Keyed by context: `post`, `page`, `home`, `author`, `archive`, `tax:<taxonomy>`, `ptarchive-<type>`, … */
300
+ title_templates: Record<string, string>;
301
+ description_templates: Record<string, string>;
302
+ noindex?: Record<string, boolean>;
303
+ social?: Record<string, unknown>;
304
+ /** Site verification codes — public meta values. */
305
+ verification?: Record<string, string>;
306
+ /** Option name → cleaned value, verbatim otherwise. */
307
+ raw: Record<string, unknown>;
308
+ }
309
+ /**
310
+ * The site's SEO layer: which plugin serves the head, its settings, and each
311
+ * page's values. `status: 'none'` is an answer — no SEO plugin, and WordPress
312
+ * core renders only the title — not a missing export.
313
+ */
314
+ interface RawSeo {
315
+ /** Producer's format identifier, e.g. `contentrain-bridge-seo@1`. */
316
+ format?: string;
317
+ status: 'present' | 'none';
318
+ /** Whose output the live site serves. */
319
+ serving: SeoProvider | 'wordpress-core';
320
+ providers: Record<SeoProvider, {
321
+ status: 'active' | 'inactive-with-data' | 'absent';
322
+ version?: string;
323
+ }>;
324
+ settings: Partial<Record<SeoProvider, RawSeoSettings>>;
325
+ /** `post:<id>` or `term:<taxonomy>:<id>` → one block per provider that has data for it. */
326
+ entries: Record<string, Partial<Record<SeoProvider, RawSeoEntry>>>;
327
+ /** Values deliberately not exported (e.g. a sensitive key), by address. */
328
+ excluded?: {
329
+ source: string;
330
+ reason: string;
331
+ }[];
332
+ }
333
+ /** A page WordPress routes to itself (front page, posts page). */
334
+ interface RawRoutedPage {
335
+ id: number;
336
+ slug: string;
337
+ /** Site-root-relative address. */
338
+ path: string;
339
+ }
340
+ /**
341
+ * The site's URL rules — what `RawPost.link` holds as facts, stated as the
342
+ * structure that produced them. Permastructs are site-root-relative with
343
+ * `front` already applied where `with_front` asks for it.
344
+ */
345
+ interface RawRouting {
346
+ /** Producer's format identifier, e.g. `contentrain-bridge-routing@1`. */
347
+ format?: string;
348
+ /** The site's home URL. */
349
+ home?: string;
350
+ permalink_structure: string;
351
+ /** Plain `?p=` permalinks — no structure at all. */
352
+ plain: boolean;
353
+ trailing_slash: boolean;
354
+ front: string;
355
+ category_base: string;
356
+ tag_base: string;
357
+ pagination_base: string;
358
+ author_base: string;
359
+ search_base: string;
360
+ feed_base?: string;
361
+ comments_pagination_base?: string;
362
+ author_structure?: string | null;
363
+ date_structure?: string | null;
364
+ page_structure?: string | null;
365
+ /** `posts` or `page`. */
366
+ show_on_front: string;
367
+ page_on_front?: RawRoutedPage | null;
368
+ page_for_posts?: RawRoutedPage | null;
369
+ posts_per_page: number;
370
+ post_types: {
371
+ name: string;
372
+ hierarchical: boolean;
373
+ rewrite: {
374
+ slug?: string;
375
+ with_front?: boolean;
376
+ feeds?: boolean;
377
+ pages?: boolean;
378
+ } | false;
379
+ permastruct: string | null;
380
+ has_archive: boolean;
381
+ archive_path: string | null;
382
+ query_var: string | false;
383
+ }[];
384
+ taxonomies: {
385
+ name: string;
386
+ object_types: string[];
387
+ hierarchical: boolean;
388
+ rewrite: {
389
+ slug?: string;
390
+ with_front?: boolean;
391
+ hierarchical?: boolean;
392
+ } | false;
393
+ permastruct: string | null;
394
+ }[];
395
+ }
396
+ /** Where a candidate was found. */
397
+ declare const TEXT_CANDIDATE_KINDS: readonly ["php-gettext", "php-html", "php-echo", "html", "js", "widget", "menu", "option", "customizer", "render"];
398
+ type RawTextCandidateKind = (typeof TEXT_CANDIDATE_KINDS)[number];
399
+ /** Why a candidate is not transferred. `RawTextCandidate.reason` joins one or more with `,`. */
400
+ declare const TEXT_EXCLUDE_REASONS: readonly ["empty", "too-long", "secret", "code", "url", "number", "no-letters", "placeholder-only", "dynamic", "not-text", "content", "rendered-from-source"];
401
+ type RawTextExcludeReason = (typeof TEXT_EXCLUDE_REASONS)[number];
402
+ /** Where a transferred candidate lands in the store. */
403
+ type RawTextTarget = 'dictionary:ui-strings' | `theme-settings.${string}` | 'site.title' | 'site.description' | 'content:wp-menu-items';
404
+ /** One place a candidate's text was found. */
405
+ interface RawTextOccurrence {
406
+ kind: RawTextCandidateKind;
407
+ /** A file path relative to `wp-content`, `render:<state>`, `nav_menu_item#<id>`, … */
408
+ source: string;
409
+ /** 1-based line in a file; `0` for a source without lines (render, menu, option). */
410
+ line: number;
411
+ }
412
+ /**
413
+ * One piece of interface text in one place. Candidates merge only when text,
414
+ * locale and context are all equal: "Read more" in an `a` and in a `button`
415
+ * are two candidates, and so are `_x('Post', 'noun')` and `_x('Post', 'verb')`.
416
+ */
417
+ interface RawTextCandidate {
418
+ /** `sha256(text \0 locale \0 context)`, first 20 hex characters. */
419
+ id: string;
420
+ /** The text; `[redacted]` when `reason` includes `secret`. */
421
+ value: string;
422
+ locale: string;
423
+ /**
424
+ * Where the text sits: an HTML tag (`a`, `input@placeholder`),
425
+ * `gettext[:<ctx>][:plural]`, `js`, `widget:<tag>` / `widget:title`, `menu`,
426
+ * `option:<name>`, `customizer:<mod>`, or for rendered text
427
+ * `<landmark>><tag>` (`footer>p`, `nav>nav@aria-label`).
428
+ */
429
+ context: string;
430
+ kind: RawTextCandidateKind;
431
+ /** The first occurrence's `source` and `line`. */
432
+ source: string;
433
+ line: number;
434
+ /** Every place the text was found, in the producer's listing order. */
435
+ occurrences: RawTextOccurrence[];
436
+ outcome: 'transfer' | 'exclude';
437
+ /** Set exactly when `outcome` is `transfer`. */
438
+ target?: RawTextTarget;
439
+ /** Set exactly when `outcome` is `exclude`: one or more `RawTextExcludeReason`s joined with `,`. */
440
+ reason?: string;
441
+ /**
442
+ * With `rendered-from-source`: ids of the source candidates this page text
443
+ * came from. Page text found in source is excluded here and transferred
444
+ * there, so the same words never land twice and contexts are not merged.
445
+ */
446
+ related?: string[];
447
+ /**
448
+ * The store key: `<group>.<context>.<words>-<sha256(text \0 context)[0:6]>`,
449
+ * or `theme-settings.<mod>` for a Customizer value. A function of text and
450
+ * context only — moving a string to another file changes no key. A
451
+ * collision fails the export rather than merging two texts.
452
+ */
453
+ key: string;
454
+ decision: 'review' | 'include' | 'exclude';
455
+ }
456
+ /**
457
+ * Every piece of interface text a site shows, each with one outcome. Totals
458
+ * close: `by_outcome.transfer + by_outcome.exclude = candidates`,
459
+ * `by_outcome.error = errors.length`, and `occurrences` counts every
460
+ * occurrence the scanners found — listed ones plus those past the per-file
461
+ * listing cap, which are counted by reason instead.
462
+ */
463
+ interface RawHardcodedText {
464
+ /** Producer's format identifier, e.g. `contentrain-bridge-hardcoded-text@1`. */
465
+ format: string;
466
+ candidates: RawTextCandidate[];
467
+ /**
468
+ * Sources that could not be read — an outcome, not a silent gap. Reasons:
469
+ * `source-over-2MiB`, `source-unreadable`, `render-fetch-failed: <code>`,
470
+ * `render-http-<status>`, `render-no-body`.
471
+ */
472
+ errors: {
473
+ source: string;
474
+ reason: string;
475
+ }[];
476
+ totals: {
477
+ occurrences: number;
478
+ occurrences_listed: number; /** Occurrences past the listing cap: excluded and counted, not listed. */
479
+ unlisted_excluded: number;
480
+ unlisted_by_reason: Record<string, number>;
481
+ candidates: number;
482
+ by_outcome: {
483
+ transfer: number;
484
+ exclude: number;
485
+ error: number;
486
+ };
487
+ sources: {
488
+ files: number;
489
+ settings: number;
490
+ render_states: number;
491
+ };
492
+ };
493
+ }
494
+ declare const INTEGRATION_CATEGORIES: readonly ["analytics", "crm", "newsletter", "ads", "comments", "captcha", "cdn", "other"];
495
+ type IntegrationCategory = (typeof INTEGRATION_CATEGORIES)[number];
496
+ declare const INTEGRATION_EVIDENCE_KINDS: readonly ["plugin", "option-key", "script-domain", "form-config", "embed-domain"];
497
+ type IntegrationEvidenceKind = (typeof INTEGRATION_EVIDENCE_KINDS)[number];
498
+ /** One outside service the site uses, and why we believe it does. */
499
+ interface RawIntegration {
500
+ /** Catalog id: `mailchimp`, `hubspot`, `google-tag-manager`, `jetpack-stats`, `embed-youtube`, … */
501
+ service: string;
502
+ /** Display name. */
503
+ name: string;
504
+ category: IntegrationCategory;
505
+ /**
506
+ * Never a value. `detail` names a plugin and its version, a setting by name
507
+ * (`mc4wp (api_key)`), a script host and where it was seen
508
+ * (`static.hotjar.com @ render:home`), a form's wiring, or an embed host.
509
+ */
510
+ evidence: {
511
+ kind: IntegrationEvidenceKind;
512
+ detail: string;
513
+ }[];
514
+ /** An account must be connected again on the new site; `false` for an embed, which has no account. */
515
+ reconnect_required: boolean;
516
+ /** A credential is configured on WordPress. Only this boolean is exported, never the value. */
517
+ secret_present: boolean;
518
+ /** What to do, in words for the person receiving the site. */
519
+ notes: string;
520
+ }
521
+ /**
522
+ * The document a bridge writes (`bridge/integrations.json`). `RawIR.integrations`
523
+ * carries its `services`. An empty `services` with `scanned` beside it is an
524
+ * answer — the site uses no outside service we recognise — not a missing scan.
525
+ */
526
+ interface RawIntegrationScan {
527
+ /** Producer's format identifier, e.g. `contentrain-bridge-integrations@1`. */
528
+ format: string;
529
+ /** How much was looked at: option and post counts, active plugins, script sources, and whether the home page was rendered. */
530
+ scanned: {
531
+ options: number;
532
+ plugins: number;
533
+ posts: number;
534
+ rendered_home: boolean;
535
+ script_sources: number;
536
+ };
537
+ services: RawIntegration[];
538
+ totals: {
539
+ services: number;
540
+ reconnect_required: number;
541
+ };
542
+ }
543
+ /**
544
+ * The intake / handoff issue for services that must be connected again. Raised
545
+ * once, listing every `RawIntegration` with `reconnect_required: true`; not
546
+ * raised when there are none.
547
+ */
548
+ interface IntegrationReconnectRequiredIssue {
549
+ code: 'integration_reconnect_required';
550
+ services: Pick<RawIntegration, 'service' | 'name' | 'category' | 'secret_present'>[];
226
551
  }
227
552
  /**
228
553
  * Translation grouping for multilingual sites. The REST rungs read it from
@@ -251,6 +576,14 @@ interface RawIR {
251
576
  menus?: RawMenu[];
252
577
  comments?: RawComment[];
253
578
  redirects?: RawRedirect[];
579
+ /** Rules the sources hold that the site does not serve as plain redirects. */
580
+ redirects_excluded?: RawRedirectExcluded[];
581
+ seo?: RawSeo;
582
+ routing?: RawRouting;
583
+ /** Interface text outside the content tables, each piece with one outcome. */
584
+ hardcoded_text?: RawHardcodedText;
585
+ /** Outside services the site is connected to (`RawIntegrationScan.services`). */
586
+ integrations?: RawIntegration[];
254
587
  language_pairs?: RawLanguagePair[];
255
588
  /** Site options (bridge rung), verbatim. */
256
589
  options?: Record<string, unknown>;
@@ -1246,11 +1579,51 @@ interface SourceDeltaEntry {
1246
1579
  /** Origin content fingerprint at cursor time and now. */
1247
1580
  fingerprint_before?: string;
1248
1581
  fingerprint_after?: string;
1249
- /** For `moved`: the slugs, in that order. Redirects are generated from these. */
1582
+ /** For `moved`: the slugs, in that order. */
1250
1583
  slug_before?: string;
1251
1584
  slug_after?: string;
1585
+ /**
1586
+ * For `moved`: the site-root-relative addresses, in that order. A slug
1587
+ * cannot say that a page changed parent, a term base changed or a dated
1588
+ * permalink moved; the path can, and redirects are generated from it.
1589
+ */
1590
+ path_before?: string;
1591
+ path_after?: string;
1592
+ /**
1593
+ * For `deleted`: `trashed` is still at the origin and can come back;
1594
+ * `purged` is gone from it. A draft or private record is `updated`, never
1595
+ * `deleted` — unpublishing is the planner's decision.
1596
+ */
1597
+ deleted_kind?: 'trashed' | 'purged';
1252
1598
  /** True when the repository also changed this record since the cursor. */
1253
1599
  conflict?: boolean;
1600
+ /**
1601
+ * The repository-side write behind `conflict`: the entry's meta when it was
1602
+ * last written by something other than the importer (Studio, an agent, a
1603
+ * person). `updated_at` is absent when the store never recorded it — an
1604
+ * unknown time is still a conflict.
1605
+ */
1606
+ repo_edit?: {
1607
+ updated_by: string;
1608
+ updated_at?: string;
1609
+ source: string;
1610
+ };
1611
+ /**
1612
+ * Why the planner could not place this record in the store:
1613
+ * `not-in-source-map` (a post-type record the source map does not list),
1614
+ * `no-model-for-type` (no store model holds this origin type),
1615
+ * `entry-not-found` (the model exists, the entry does not).
1616
+ */
1617
+ unmapped?: 'not-in-source-map' | 'no-model-for-type' | 'entry-not-found';
1618
+ /**
1619
+ * For `updated` and `moved`: the fields whose stored value differs from the
1620
+ * incoming export's, by field name (`body` for a document's body). Computed
1621
+ * against the repository as it is now, so a repository-side edit shows here
1622
+ * too — which is what `conflict` is for.
1623
+ */
1624
+ fields_changed?: string[];
1625
+ /** For `moved`: the entry id at the new address, when the store derives ids from slugs and the id changes. */
1626
+ entry_id_after?: string;
1254
1627
  /** Why this entry is here, for the person reading the plan. */
1255
1628
  detail?: string;
1256
1629
  }
@@ -1275,6 +1648,12 @@ interface SourceDeltaPlan {
1275
1648
  * "nothing was deleted".
1276
1649
  */
1277
1650
  deletions_detectable: boolean;
1651
+ /**
1652
+ * Origin types whose deletions could not be determined even though the plan
1653
+ * as a whole could — a type that left the inventory's scope (its plugin was
1654
+ * deactivated) is not a deletion of every record in it.
1655
+ */
1656
+ deletions_undetectable_types?: string[];
1278
1657
  /** Redirects implied by the `moved` entries. */
1279
1658
  redirects?: {
1280
1659
  from: string;
@@ -1308,6 +1687,67 @@ declare function planHashPayload(plan: ExecutionPlan): string;
1308
1687
  * value, so a collision is an approval bypass, not a cache miss.
1309
1688
  */
1310
1689
  declare function computePlanHash(plan: ExecutionPlan): Promise<string>;
1690
+ /** One origin record as the inventory saw it. */
1691
+ interface SourceInventoryRecord {
1692
+ /** Origin object type ("post", "page", "attachment", or a taxonomy name for a term). */
1693
+ wp_type: string;
1694
+ /** Origin id — unique only together with `wp_type`: post 5 and category 5 are different records. */
1695
+ wp_id: number;
1696
+ /** Origin status (`publish`, `draft`, `trash`, …); a term has none. */
1697
+ status?: string;
1698
+ /**
1699
+ * Password-protected at the origin. The record is inventoried like any other
1700
+ * — so removing the password is an `updated`, not a `created` — but has no
1701
+ * public address.
1702
+ */
1703
+ protected?: boolean;
1704
+ slug?: string;
1705
+ /** Site-root-relative public address. A change here is a `moved`. */
1706
+ path?: string;
1707
+ /** Origin id of the parent record, when it has one. */
1708
+ parent?: number;
1709
+ /** ISO 8601 UTC, from the origin — a hint only: a meta-only edit does not change it. */
1710
+ modified_at?: string;
1711
+ /**
1712
+ * Hash of the record as mapped, volatile fields excluded. `updated` is
1713
+ * decided on this, never on `modified_at`.
1714
+ */
1715
+ fingerprint: string;
1716
+ /** Slugs the origin remembers for this record (WordPress `_wp_old_slug`). Corroborates a move; never the authority. */
1717
+ old_slugs?: string[];
1718
+ /** Locale of the record, when the origin is multilingual. */
1719
+ locale?: string;
1720
+ }
1721
+ /**
1722
+ * Every record in scope at one moment — what a `bridge_inventory` cursor's
1723
+ * `inventory_hash` identifies.
1724
+ *
1725
+ * Deletions and moves are proven by comparing two inventories, not by
1726
+ * reading a feed; a `modified_after` query only narrows which bodies to fetch
1727
+ * again. A type in the earlier inventory's `scope` but not in this one's left
1728
+ * the scope — it was not deleted.
1729
+ */
1730
+ interface SourceInventory {
1731
+ /** Inventory format identifier, e.g. `contentrain-bridge-inventory@2`. */
1732
+ format: string;
1733
+ /** ISO 8601 UTC time the inventory was taken. */
1734
+ taken_at: string;
1735
+ /** What was enumerated. */
1736
+ scope: {
1737
+ post_types: string[];
1738
+ taxonomies?: string[];
1739
+ };
1740
+ records: SourceInventoryRecord[];
1741
+ /**
1742
+ * The value a cursor's `inventory_hash` carries: lowercase hex SHA-256 of the
1743
+ * records sorted by `wp_type` (byte order) then `wp_id` (numeric), each
1744
+ * serialized as the JSON array `[wp_type, wp_id, fingerprint, path ?? null,
1745
+ * status ?? null]` — no spaces, slashes and non-ASCII unescaped (what
1746
+ * `JSON.stringify` prints) — joined by `\n`, no trailing newline. Other fields
1747
+ * do not enter it; a change they make shows through `fingerprint`.
1748
+ */
1749
+ inventory_hash: string;
1750
+ }
1311
1751
  //#endregion
1312
1752
  //#region src/approval.d.ts
1313
1753
  /**
@@ -1903,6 +2343,23 @@ interface ModelDefinition {
1903
2343
  kind: ModelKind;
1904
2344
  domain: string;
1905
2345
  i18n: boolean;
2346
+ /**
2347
+ * The project locales this model's content actually covers — a subset of
2348
+ * `config.locales.supported`.
2349
+ *
2350
+ * Absent means every project locale, which is the behaviour every model had
2351
+ * before this field existed: parity is checked against the whole supported
2352
+ * list. Present, it narrows that check to the locales named here, so a
2353
+ * partially-translated site can declare the truth instead of failing.
2354
+ *
2355
+ * A locale not in `config.locales.supported` is a validation error — see
2356
+ * {@link validateModelLocales}. Meaningless on a non-i18n model, which has
2357
+ * only the default locale.
2358
+ *
2359
+ * It narrows validation; it never grants a locale. Writing content in a
2360
+ * locale outside this list is still governed by `config.locales.supported`.
2361
+ */
2362
+ locales?: string[];
1906
2363
  /**
1907
2364
  * Name of the field whose value is shown as an entry's title — in a Studio row,
1908
2365
  * a picker, a relation reference. Declared, never guessed: a consumer that infers
@@ -2152,7 +2609,7 @@ declare const CANONICAL_JSON: {
2152
2609
  * the `keyof` assertion in index.test.ts catches a `ModelDefinition` key missing
2153
2610
  * from this array. Between them, drift fails `tsc`, which runs on every commit.
2154
2611
  */
2155
- declare const MODEL_FIELD_ORDER: readonly ["id", "name", "kind", "domain", "i18n", "title_field", "description", "content_path", "locale_strategy", "fields", "form", "comments"];
2612
+ declare const MODEL_FIELD_ORDER: readonly ["id", "name", "kind", "domain", "i18n", "locales", "title_field", "description", "content_path", "locale_strategy", "fields", "form", "comments"];
2156
2613
  interface ScaffoldTemplate {
2157
2614
  id: string;
2158
2615
  models: ModelDefinition[];
@@ -2338,6 +2795,51 @@ declare function validateSlug(slug: string): string | null;
2338
2795
  declare function validateEntryId(id: string): string | null;
2339
2796
  /** Validate a locale code against pattern and config — returns error message or null if valid */
2340
2797
  declare function validateLocale(locale: string, config: ContentrainConfig): string | null;
2798
+ /**
2799
+ * The locales one model's content is checked against, and where that list came
2800
+ * from — `'model'` when the model narrowed it with `locales`, `'project'` when
2801
+ * it is the project-wide list (or, for a non-i18n model, the default locale).
2802
+ *
2803
+ * The provenance travels with the list because every message derived from it
2804
+ * has to say which list it evaluated: "missing tr" means something different
2805
+ * when tr is one of four project locales than when the model declares it.
2806
+ */
2807
+ interface ModelLocaleScope {
2808
+ locales: string[];
2809
+ source: 'model' | 'project';
2810
+ }
2811
+ /**
2812
+ * The locales a model's content is expected to cover.
2813
+ *
2814
+ * A non-i18n model stores content locale-agnostically, so its scope is the
2815
+ * default locale alone — unchanged, and `locales` never applies to it. An i18n
2816
+ * model narrows to `model.locales` when it declares one, otherwise it covers
2817
+ * every supported locale, which is what every model did before the field
2818
+ * existed.
2819
+ *
2820
+ * Locales are returned in `config.locales.supported` order, not the order the
2821
+ * model happened to list them: a message that names the list should read the
2822
+ * same for two models that declare the same locales differently. Anything the
2823
+ * model names that the project does not support is dropped here and reported
2824
+ * by {@link validateModelLocales} — resolution never invents coverage.
2825
+ */
2826
+ declare function resolveModelLocales(model: Pick<ModelDefinition, 'i18n' | 'locales'>, config: ContentrainConfig): ModelLocaleScope;
2827
+ /** Name a locale scope the way a validation message should quote it. */
2828
+ declare function describeModelLocaleScope(scope: ModelLocaleScope): string;
2829
+ /**
2830
+ * Check a model's `locales` declaration against the project config.
2831
+ *
2832
+ * Absent is valid and means every supported locale, so this returns nothing for
2833
+ * the models that predate the field. What it rejects is a declaration that
2834
+ * cannot be honoured: a locale the project does not support (the coverage would
2835
+ * be unreachable), an empty list (a model that covers nothing is a deleted
2836
+ * model, not a narrowed one), a duplicate, or a non-string entry.
2837
+ *
2838
+ * Declaring `locales` on a non-i18n model is a warning, not an error: the field
2839
+ * has no effect there, but the model is still readable and its content still
2840
+ * validates, so failing the project over it would be out of proportion.
2841
+ */
2842
+ declare function validateModelLocales(model: Pick<ModelDefinition, 'i18n' | 'locales'>, config: ContentrainConfig): ValidationError[];
2341
2843
  /**
2342
2844
  * Does a value assigned to an API-key setting look like a credential rather
2343
2845
  * than prose about one?
@@ -2484,5 +2986,5 @@ declare function parseMarkdownFrontmatter(content: string): {
2484
2986
  /** Serialize data + body into markdown frontmatter format */
2485
2987
  declare function serializeMarkdownFrontmatter(data: Record<string, unknown>, body: string): string;
2486
2988
  //#endregion
2487
- export { APPROVAL_GATES, APPROVAL_MODES, ActorRef, type ApplyPlanInput, ApprovalDecision, ApprovalEvaluation, ApprovalGate, ApprovalGrant, ApprovalMode, ApprovalPolicyFile, ApprovalRequirement, ApprovalRule, ApprovalStatus, AssetEntry, AutomationDefinition, AutomationSchedule, type BaseAdvance, type Branch, CANONICAL_JSON, CAPABILITY_KEYS, CHROME_BODY_SLOT, CHROME_COMPONENT_CLOSE, CHROME_COMPONENT_OPEN, CHROME_IF_CLOSE, CHROME_IF_OPEN, CHROME_REPEAT_CLOSE, CHROME_REPEAT_OPEN, COMMENTS_EXPORT_FORMAT, COMPONENT_TYPES, CONTENTRAIN_BRANCH, CONTENTRAIN_DIR, CapabilityDetection, CapabilityDisposition, CapabilityKey, CapabilityManifest, Checkpoint, ChromeChunk, CollectionContentFile, CollectionContentOutput, CollectionEntry, CollectionMeta, CollectionRuntimeFormat, CommentsExport, type Commit, type CommitAuthor, ComponentDef, ComponentPlacement, ComponentSource, ComponentType, ComponentVariantDef, type ConflictCode, type ConflictItem, type ConflictResolution, ContentSource, ContentStatus, ContentrainConfig, ContentrainError, ContextJson, ContextSource, CostComparison, CostEstimate, CssStrategy, DEFAULT_APPROVAL_POLICY, DICTIONARY_TITLE_FIELD, DeploymentTarget, DesignTokens, DictionaryContentFile, DictionaryMeta, DocumentContentOutput, DocumentEntry, DocumentMeta, DuplicateGroup, ENTRY_ID_PATTERN, EXECUTION_CONTRACT_VERSION, EntryMeta, EntrySourceMap, EntrySourceRef, ExcerptSource, ExecutionCost, ExecutionEstimate, ExecutionPlan, ExecutionReceipt, ExecutionScope, ExecutionStep, FamilyVariant, FieldDef, FieldType, FileCategory, type FileChange, type FileDiff, FileFramework, GrantRejection, GraphNode, HandoffCapability, HandoffComments, HandoffOffer, LEGACY_CSS_LAYER, LIST_ITEMS_SLOT, LOCALE_PATTERN, LOCAL_CAPABILITIES, LayoutFamily, LocaleStrategy, MIGRATION_CONTRACT_VERSION, MODEL_EXTENSION_KEYS, MODEL_FIELD_ORDER, type MediaAsset, type MediaIngestInput, type MediaListOptions, type MediaListResult, type MediaProvider, type MediaUpdateInput, type MergeResult, MigrationHandoff, MisfirePolicy, ModelDefinition, ModelKind, ModelSummary, NormalizePlan, NormalizePlanExtraction, NormalizePlanModel, NormalizePlanPatch, OfferProvider, PAGE_BODY_KINDS, PATH_PATTERNS, PageBodyKind, PaginationKind, Platform, PolymorphicRelationRef, ProjectGraph, ProjectIR, ProjectRef, type ProviderCapabilities, QueryBinding, RAW_MARK_SUFFIX, RESERVED_PATHS, RISK_CLASSES, RUN_STATUSES, RawAcfValue, RawAttachment, RawAuthor, RawComment, RawIR, RawLanguagePair, RawMenu, RawMenuItem, RawMenuTarget, RawPost, RawProvenance, RawRedirect, RawSite, RawTerm, RawTermRef, RejectedGrant, type RemotePush, type RepoProvider, type RepoReader, type RepoWriter, RiskClass, RollbackPlan, RootAttrs, RouteKind, RouteModel, RouteParamDef, RouteVariantRule, RunStatus, RuntimeBinding, SCHEDULE_KINDS, SECRET_PATTERNS, SLUG_PATTERN, SOURCE_ACCESS_LADDER, SOURCE_DELTA_OPS, ScaffoldTemplate, ScanCandidate, ScanCandidatesResult, ScanSummaryResult, ScheduleKind, SingletonContentFile, SingletonMeta, SlotBinding, SlotKind, SourceAccessKind, SourceCursor, SourceDeltaEntry, SourceDeltaOp, SourceDeltaPlan, StackType, StringContext, SyncResult, TERMINAL_RUN_STATUSES, TITLE_FIELD_TYPES, ValidationError, ValidationResult, VerificationCheck, ViewportStrategy, Vocabulary, WorkflowMode, type WriteReadiness, approversFor, canonicalStringify, componentSlot, computePlanHash, conflictId, detectSecrets, effectiveRisk, evaluateApproval, generateEntryId, highestRisk, isMediaType, isReservedPath, isTerminalRunStatus, isTitleFieldType, looksLikeCredential, orderedFieldNames, parseFrontmatterScalar, parseFrontmatterScalarString, parseMarkdownFrontmatter, planHashPayload, requiredApprovals, resolveFieldLabel, riskRank, serializeMarkdownFrontmatter, sortKeys, splitFrontmatterList, stableHash, validateAccept, validateEntryId, validateFieldValue, validateLocale, validateSemanticType, validateSlug };
2989
+ export { APPROVAL_GATES, APPROVAL_MODES, ActorRef, type ApplyPlanInput, ApprovalDecision, ApprovalEvaluation, ApprovalGate, ApprovalGrant, ApprovalMode, ApprovalPolicyFile, ApprovalRequirement, ApprovalRule, ApprovalStatus, AssetEntry, AutomationDefinition, AutomationSchedule, type BaseAdvance, type Branch, CANONICAL_JSON, CAPABILITY_KEYS, CHROME_BODY_SLOT, CHROME_COMPONENT_CLOSE, CHROME_COMPONENT_OPEN, CHROME_IF_CLOSE, CHROME_IF_OPEN, CHROME_REPEAT_CLOSE, CHROME_REPEAT_OPEN, COMMENTS_EXPORT_FORMAT, COMPONENT_TYPES, CONTENTRAIN_BRANCH, CONTENTRAIN_DIR, CapabilityDetection, CapabilityDisposition, CapabilityKey, CapabilityManifest, Checkpoint, ChromeChunk, CollectionContentFile, CollectionContentOutput, CollectionEntry, CollectionMeta, CollectionRuntimeFormat, CommentsExport, type Commit, type CommitAuthor, ComponentDef, ComponentPlacement, ComponentSource, ComponentType, ComponentVariantDef, type ConflictCode, type ConflictItem, type ConflictResolution, ContentSource, ContentStatus, ContentrainConfig, ContentrainError, ContextJson, ContextSource, CostComparison, CostEstimate, CssStrategy, DEFAULT_APPROVAL_POLICY, DICTIONARY_TITLE_FIELD, DeploymentTarget, DesignTokens, DictionaryContentFile, DictionaryMeta, DocumentContentOutput, DocumentEntry, DocumentMeta, DuplicateGroup, ENTRY_ID_PATTERN, EXECUTION_CONTRACT_VERSION, EntryMeta, EntrySourceMap, EntrySourceRef, ExcerptSource, ExecutionCost, ExecutionEstimate, ExecutionPlan, ExecutionReceipt, ExecutionScope, ExecutionStep, FamilyVariant, FieldDef, FieldType, FileCategory, type FileChange, type FileDiff, FileFramework, GrantRejection, GraphNode, HandoffCapability, HandoffComments, HandoffOffer, INTEGRATION_CATEGORIES, INTEGRATION_EVIDENCE_KINDS, IntegrationCategory, IntegrationEvidenceKind, IntegrationReconnectRequiredIssue, LEGACY_CSS_LAYER, LIST_ITEMS_SLOT, LOCALE_PATTERN, LOCAL_CAPABILITIES, LayoutFamily, LocaleStrategy, MIGRATION_CONTRACT_VERSION, MODEL_EXTENSION_KEYS, MODEL_FIELD_ORDER, type MediaAsset, type MediaIngestInput, type MediaListOptions, type MediaListResult, type MediaProvider, type MediaUpdateInput, type MergeResult, MigrationHandoff, MisfirePolicy, ModelDefinition, ModelKind, ModelLocaleScope, ModelSummary, NormalizePlan, NormalizePlanExtraction, NormalizePlanModel, NormalizePlanPatch, OfferProvider, PAGE_BODY_KINDS, PATH_PATTERNS, PageBodyKind, PaginationKind, Platform, PolymorphicRelationRef, ProjectGraph, ProjectIR, ProjectRef, type ProviderCapabilities, QueryBinding, RAW_MARK_SUFFIX, RESERVED_PATHS, RISK_CLASSES, RUN_STATUSES, RawAcfValue, RawAttachment, RawAuthor, RawComment, RawHardcodedText, RawIR, RawIntegration, RawIntegrationScan, RawLanguagePair, RawMenu, RawMenuItem, RawMenuTarget, RawPost, RawProvenance, RawRedirect, RawRedirectExcluded, RawRedirectMatch, RawRoutedPage, RawRouting, RawSeo, RawSeoEntry, RawSeoSettings, RawSite, RawTerm, RawTermRef, RawTextCandidate, RawTextCandidateKind, RawTextExcludeReason, RawTextOccurrence, RawTextTarget, RejectedGrant, type RemotePush, type RepoProvider, type RepoReader, type RepoWriter, RiskClass, RollbackPlan, RootAttrs, RouteKind, RouteModel, RouteParamDef, RouteVariantRule, RunStatus, RuntimeBinding, SCHEDULE_KINDS, SECRET_PATTERNS, SEO_PROVIDERS, SLUG_PATTERN, SOURCE_ACCESS_LADDER, SOURCE_DELTA_OPS, ScaffoldTemplate, ScanCandidate, ScanCandidatesResult, ScanSummaryResult, ScheduleKind, SeoProvider, SingletonContentFile, SingletonMeta, SlotBinding, SlotKind, SourceAccessKind, SourceCursor, SourceDeltaEntry, SourceDeltaOp, SourceDeltaPlan, SourceInventory, SourceInventoryRecord, StackType, StringContext, SyncResult, TERMINAL_RUN_STATUSES, TEXT_CANDIDATE_KINDS, TEXT_EXCLUDE_REASONS, TITLE_FIELD_TYPES, ValidationError, ValidationResult, VerificationCheck, ViewportStrategy, Vocabulary, WorkflowMode, type WriteReadiness, approversFor, canonicalStringify, componentSlot, computePlanHash, conflictId, describeModelLocaleScope, detectSecrets, effectiveRisk, evaluateApproval, generateEntryId, highestRisk, isMediaType, isReservedPath, isTerminalRunStatus, isTitleFieldType, looksLikeCredential, orderedFieldNames, parseFrontmatterScalar, parseFrontmatterScalarString, parseMarkdownFrontmatter, planHashPayload, requiredApprovals, resolveFieldLabel, resolveModelLocales, riskRank, serializeMarkdownFrontmatter, sortKeys, splitFrontmatterList, stableHash, validateAccept, validateEntryId, validateFieldValue, validateLocale, validateModelLocales, validateSemanticType, validateSlug };
2488
2990
  //# sourceMappingURL=index.d.mts.map