@contentrain/types 1.17.0 → 1.18.1

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 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
  /**
@@ -1534,6 +1974,11 @@ interface ApplyPlanInput {
1534
1974
  * repository's default branch. This is the single source of truth for
1535
1975
  * content state; every feature branch forks from it. Pass an explicit
1536
1976
  * `base` only when you know you want to bypass the invariant.
1977
+ *
1978
+ * The GitHub provider also takes a full 40-hex commit SHA here, as a
1979
+ * compare-and-set: a missing `branch` forks from that commit, and an
1980
+ * existing one must still point at it or the write is refused with a
1981
+ * 409 (`PROVIDER_CONFLICT`).
1537
1982
  */
1538
1983
  base?: string;
1539
1984
  /**
@@ -1903,6 +2348,23 @@ interface ModelDefinition {
1903
2348
  kind: ModelKind;
1904
2349
  domain: string;
1905
2350
  i18n: boolean;
2351
+ /**
2352
+ * The project locales this model's content actually covers — a subset of
2353
+ * `config.locales.supported`.
2354
+ *
2355
+ * Absent means every project locale, which is the behaviour every model had
2356
+ * before this field existed: parity is checked against the whole supported
2357
+ * list. Present, it narrows that check to the locales named here, so a
2358
+ * partially-translated site can declare the truth instead of failing.
2359
+ *
2360
+ * A locale not in `config.locales.supported` is a validation error — see
2361
+ * {@link validateModelLocales}. Meaningless on a non-i18n model, which has
2362
+ * only the default locale.
2363
+ *
2364
+ * It narrows validation; it never grants a locale. Writing content in a
2365
+ * locale outside this list is still governed by `config.locales.supported`.
2366
+ */
2367
+ locales?: string[];
1906
2368
  /**
1907
2369
  * Name of the field whose value is shown as an entry's title — in a Studio row,
1908
2370
  * a picker, a relation reference. Declared, never guessed: a consumer that infers
@@ -2152,7 +2614,7 @@ declare const CANONICAL_JSON: {
2152
2614
  * the `keyof` assertion in index.test.ts catches a `ModelDefinition` key missing
2153
2615
  * from this array. Between them, drift fails `tsc`, which runs on every commit.
2154
2616
  */
2155
- declare const MODEL_FIELD_ORDER: readonly ["id", "name", "kind", "domain", "i18n", "title_field", "description", "content_path", "locale_strategy", "fields", "form", "comments"];
2617
+ declare const MODEL_FIELD_ORDER: readonly ["id", "name", "kind", "domain", "i18n", "locales", "title_field", "description", "content_path", "locale_strategy", "fields", "form", "comments"];
2156
2618
  interface ScaffoldTemplate {
2157
2619
  id: string;
2158
2620
  models: ModelDefinition[];
@@ -2338,6 +2800,51 @@ declare function validateSlug(slug: string): string | null;
2338
2800
  declare function validateEntryId(id: string): string | null;
2339
2801
  /** Validate a locale code against pattern and config — returns error message or null if valid */
2340
2802
  declare function validateLocale(locale: string, config: ContentrainConfig): string | null;
2803
+ /**
2804
+ * The locales one model's content is checked against, and where that list came
2805
+ * from — `'model'` when the model narrowed it with `locales`, `'project'` when
2806
+ * it is the project-wide list (or, for a non-i18n model, the default locale).
2807
+ *
2808
+ * The provenance travels with the list because every message derived from it
2809
+ * has to say which list it evaluated: "missing tr" means something different
2810
+ * when tr is one of four project locales than when the model declares it.
2811
+ */
2812
+ interface ModelLocaleScope {
2813
+ locales: string[];
2814
+ source: 'model' | 'project';
2815
+ }
2816
+ /**
2817
+ * The locales a model's content is expected to cover.
2818
+ *
2819
+ * A non-i18n model stores content locale-agnostically, so its scope is the
2820
+ * default locale alone — unchanged, and `locales` never applies to it. An i18n
2821
+ * model narrows to `model.locales` when it declares one, otherwise it covers
2822
+ * every supported locale, which is what every model did before the field
2823
+ * existed.
2824
+ *
2825
+ * Locales are returned in `config.locales.supported` order, not the order the
2826
+ * model happened to list them: a message that names the list should read the
2827
+ * same for two models that declare the same locales differently. Anything the
2828
+ * model names that the project does not support is dropped here and reported
2829
+ * by {@link validateModelLocales} — resolution never invents coverage.
2830
+ */
2831
+ declare function resolveModelLocales(model: Pick<ModelDefinition, 'i18n' | 'locales'>, config: ContentrainConfig): ModelLocaleScope;
2832
+ /** Name a locale scope the way a validation message should quote it. */
2833
+ declare function describeModelLocaleScope(scope: ModelLocaleScope): string;
2834
+ /**
2835
+ * Check a model's `locales` declaration against the project config.
2836
+ *
2837
+ * Absent is valid and means every supported locale, so this returns nothing for
2838
+ * the models that predate the field. What it rejects is a declaration that
2839
+ * cannot be honoured: a locale the project does not support (the coverage would
2840
+ * be unreachable), an empty list (a model that covers nothing is a deleted
2841
+ * model, not a narrowed one), a duplicate, or a non-string entry.
2842
+ *
2843
+ * Declaring `locales` on a non-i18n model is a warning, not an error: the field
2844
+ * has no effect there, but the model is still readable and its content still
2845
+ * validates, so failing the project over it would be out of proportion.
2846
+ */
2847
+ declare function validateModelLocales(model: Pick<ModelDefinition, 'i18n' | 'locales'>, config: ContentrainConfig): ValidationError[];
2341
2848
  /**
2342
2849
  * Does a value assigned to an API-key setting look like a credential rather
2343
2850
  * than prose about one?
@@ -2484,5 +2991,5 @@ declare function parseMarkdownFrontmatter(content: string): {
2484
2991
  /** Serialize data + body into markdown frontmatter format */
2485
2992
  declare function serializeMarkdownFrontmatter(data: Record<string, unknown>, body: string): string;
2486
2993
  //#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 };
2994
+ 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
2995
  //# sourceMappingURL=index.d.mts.map