@contentrain/types 1.13.0 → 1.14.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.mjs CHANGED
@@ -1,3 +1,26 @@
1
+ //#region src/canonical.ts
2
+ /** Recursively sort object keys — respects optional fieldOrder for top-level keys */
3
+ function sortKeys(obj, fieldOrder) {
4
+ if (obj === null || obj === void 0) return void 0;
5
+ if (Array.isArray(obj)) return obj.map((item) => sortKeys(item, fieldOrder));
6
+ if (typeof obj !== "object") return obj;
7
+ const record = obj;
8
+ const sorted = {};
9
+ const keys = fieldOrder ? [...new Set([...fieldOrder, ...Object.keys(record).toSorted()])] : Object.keys(record).toSorted();
10
+ for (const key of keys) {
11
+ if (!(key in record)) continue;
12
+ const val = record[key];
13
+ if (val === null || val === void 0) continue;
14
+ sorted[key] = sortKeys(val);
15
+ }
16
+ return sorted;
17
+ }
18
+ /** Canonical JSON serialization — deterministic output for stable git diffs */
19
+ function canonicalStringify(data, fieldOrder) {
20
+ const sorted = sortKeys(data, fieldOrder);
21
+ return `${JSON.stringify(sorted, null, 2)}\n`;
22
+ }
23
+ //#endregion
1
24
  //#region src/migration.ts
2
25
  /**
3
26
  * Version stamped into every migration document (`version` field on each root).
@@ -127,6 +150,338 @@ const COMPONENT_TYPES = [
127
150
  ];
128
151
  const COMMENTS_EXPORT_FORMAT = "contentrain-comments@1";
129
152
  //#endregion
153
+ //#region src/execution.ts
154
+ /**
155
+ * Version stamped into every execution document (`version` field on each root).
156
+ * A reader that sees a higher version than it knows should refuse rather than
157
+ * guess — a misread plan is a plan executed with the wrong scope.
158
+ */
159
+ const EXECUTION_CONTRACT_VERSION = 1;
160
+ /**
161
+ * What is at stake in an operation, in ascending severity.
162
+ *
163
+ * The order is the contract, not a convenience: `RISK_CLASSES` is a ladder and
164
+ * a policy written for `bulk_content` is expected to also cover everything
165
+ * above it. The seven rungs come from the approval model — read-only work is
166
+ * automatic, content edits follow project policy, and anything that leaves the
167
+ * repository (money, third parties, production) is a separate decision from
168
+ * whether the content is correct.
169
+ */
170
+ const RISK_CLASSES = [
171
+ "read_only",
172
+ "low_risk_content",
173
+ "bulk_content",
174
+ "destructive_schema",
175
+ "external_effect",
176
+ "financially_material",
177
+ "deployment"
178
+ ];
179
+ /** Position on the ladder; higher is more severe. */
180
+ function riskRank(risk) {
181
+ return RISK_CLASSES.indexOf(risk);
182
+ }
183
+ /**
184
+ * The most severe of the given classes — the class a multi-step plan is
185
+ * evaluated against. A plan is exactly as safe as its worst step, so a
186
+ * read-only survey that ends in a deploy is a deploy.
187
+ */
188
+ function highestRisk(risks) {
189
+ let worst = "read_only";
190
+ for (const risk of risks) if (riskRank(risk) > riskRank(worst)) worst = risk;
191
+ return worst;
192
+ }
193
+ /**
194
+ * The three moments at which a decision is asked for. They are distinct
195
+ * questions and collapsing them is the mistake this type exists to prevent:
196
+ * approving *what will be done* is not approving *what was produced*, and
197
+ * neither is permission to put it in front of the public.
198
+ */
199
+ const APPROVAL_GATES = [
200
+ "plan",
201
+ "change",
202
+ "release"
203
+ ];
204
+ /**
205
+ * How many people, and which, must say yes. Deliberately three modes: a
206
+ * generic workflow canvas was considered and rejected — policy-based approval
207
+ * covers every case the product actually has.
208
+ */
209
+ const APPROVAL_MODES = [
210
+ "auto",
211
+ "single",
212
+ "quorum"
213
+ ];
214
+ /**
215
+ * Run lifecycle. The main line is `draft → planned → awaiting_approval →
216
+ * approved → scheduled → queued → running → verifying → completed`; the rest
217
+ * are the states a run can sit in when something interrupts it.
218
+ */
219
+ const RUN_STATUSES = [
220
+ "draft",
221
+ "planned",
222
+ "awaiting_approval",
223
+ "approved",
224
+ "scheduled",
225
+ "queued",
226
+ "running",
227
+ "verifying",
228
+ "completed",
229
+ "paused",
230
+ "needs_attention",
231
+ "retrying",
232
+ "failed",
233
+ "cancelled",
234
+ "expired",
235
+ "rolled_back"
236
+ ];
237
+ /** Statuses from which a run will not proceed on its own. */
238
+ const TERMINAL_RUN_STATUSES = [
239
+ "completed",
240
+ "failed",
241
+ "cancelled",
242
+ "expired",
243
+ "rolled_back"
244
+ ];
245
+ function isTerminalRunStatus(status) {
246
+ return TERMINAL_RUN_STATUSES.includes(status);
247
+ }
248
+ /** Approvers recorded on a receipt for one gate, in grant order. */
249
+ function approversFor(receipt, gate) {
250
+ return (receipt.approvals ?? []).filter((grant) => grant.gate === gate).map((grant) => grant.approver);
251
+ }
252
+ const SCHEDULE_KINDS = [
253
+ "once",
254
+ "recurring",
255
+ "content_event",
256
+ "git_event",
257
+ "runtime_event",
258
+ "external_webhook",
259
+ "manual"
260
+ ];
261
+ const SOURCE_DELTA_OPS = [
262
+ "created",
263
+ "updated",
264
+ "deleted",
265
+ "moved"
266
+ ];
267
+ /**
268
+ * The exact bytes `plan_hash` is computed over. Exposed because a hash is only
269
+ * useful if a disagreement can be investigated: when two sides compute
270
+ * different hashes for the same plan, comparing payloads shows which field
271
+ * differs, and comparing hashes shows nothing.
272
+ *
273
+ * Excluded: `plan_hash` itself (it cannot cover itself), and `id`,
274
+ * `created_at`, `created_by` and `idempotency_key` — who built a plan, when,
275
+ * under which run id, and with which deduplication key do not change what the
276
+ * plan will do. Everything else is covered, so a changed step, a widened
277
+ * scope, a raised estimate or a withdrawn rollback all invalidate every
278
+ * approval the plan had collected.
279
+ */
280
+ function planHashPayload(plan) {
281
+ const { plan_hash: _hash, id: _id, created_at: _at, created_by: _by, idempotency_key: _key, ...semantic } = plan;
282
+ return canonicalStringify(semantic);
283
+ }
284
+ /**
285
+ * SHA-256 of {@link planHashPayload}, lowercase hex.
286
+ *
287
+ * Async because it uses Web Crypto (`globalThis.crypto.subtle`), which is
288
+ * available in Node 18+, Deno, Bun, workers and browsers alike — this package
289
+ * is consumed in all of them and must not reach for `node:crypto`. A non-
290
+ * cryptographic hash was considered and rejected: approvals are pinned to this
291
+ * value, so a collision is an approval bypass, not a cache miss.
292
+ */
293
+ async function computePlanHash(plan) {
294
+ const bytes = new TextEncoder().encode(planHashPayload(plan));
295
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
296
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
297
+ }
298
+ //#endregion
299
+ //#region src/approval.ts
300
+ /**
301
+ * Used when a project has no `.contentrain/approval-policies.json`.
302
+ *
303
+ * Read-only work proceeds; everything else wants one reviewer on the produced
304
+ * diff. Deliberately stated as a value rather than buried in the evaluator so
305
+ * a project can inspect it, extend it, or reject it — and so "what happens
306
+ * without a policy file" has one answer instead of one per caller.
307
+ *
308
+ * Note this is a *default*, not the only gate in the system: whether a project
309
+ * consults the evaluator at all is still governed by its `workflow` setting.
310
+ */
311
+ const DEFAULT_APPROVAL_POLICY = {
312
+ version: 1,
313
+ rules: [{
314
+ risk: "low_risk_content",
315
+ gate: "change",
316
+ mode: "single"
317
+ }]
318
+ };
319
+ /** Ascending strictness — used to keep the stricter of two modes. */
320
+ const MODE_RANK = {
321
+ auto: 0,
322
+ single: 1,
323
+ quorum: 2
324
+ };
325
+ /** How many distinct approvers a mode asks for. */
326
+ function approvalsNeeded(rule) {
327
+ if (rule.mode === "auto") return 0;
328
+ if (rule.mode === "single") return 1;
329
+ return Math.max(1, rule.min_approvals ?? 2);
330
+ }
331
+ /** Whether a rule's narrowing clauses match what the plan touches. */
332
+ function ruleMatchesScope(rule, plan) {
333
+ if (rule.models && rule.models.length > 0) {
334
+ const touched = plan.scope.models ?? [];
335
+ if (!rule.models.some((model) => touched.includes(model))) return false;
336
+ }
337
+ if (rule.locales && rule.locales.length > 0) {
338
+ const touched = plan.scope.locales ?? [];
339
+ if (!rule.locales.some((locale) => touched.includes(locale))) return false;
340
+ }
341
+ return true;
342
+ }
343
+ /**
344
+ * The class a plan is judged at: its declared risk, or its worst step's if
345
+ * that is higher. A plan cannot understate itself — declaring `read_only`
346
+ * while carrying a deploy step would otherwise walk straight past the policy.
347
+ */
348
+ function effectiveRisk(plan) {
349
+ return highestRisk([plan.risk, ...plan.steps.map((step) => step.risk)]);
350
+ }
351
+ /**
352
+ * The requirements a policy produces for a plan, before any decision is
353
+ * collected. This is what a plan card renders: "this needs one reviewer on the
354
+ * diff and two on release, because it deploys".
355
+ *
356
+ * Each matching rule contributes its own requirement and all of them must be
357
+ * met. Merging two rules into one would need a rule for combining their modes,
358
+ * roles and counts — and every such rule has a case where the result is
359
+ * *looser* than one of its inputs. Keeping them separate is what makes the
360
+ * policy additive in fact and not just in intent.
361
+ *
362
+ * `auto` rules are dropped rather than kept as zero-approver requirements: a
363
+ * requirement nobody has to satisfy is noise on the card, and a stricter rule
364
+ * matching the same gate still stands on its own.
365
+ */
366
+ function requiredApprovals(plan, policy = DEFAULT_APPROVAL_POLICY) {
367
+ const risk = effectiveRisk(plan);
368
+ const requirements = [];
369
+ let matched = false;
370
+ for (const rule of policy.rules) {
371
+ if (!ruleMatchesScope(rule, plan)) continue;
372
+ if (!(rule.mode !== "auto" ? riskRank(risk) >= riskRank(rule.risk) : risk === rule.risk)) continue;
373
+ matched = true;
374
+ const min_approvals = approvalsNeeded(rule);
375
+ if (min_approvals === 0) continue;
376
+ requirements.push({
377
+ gate: rule.gate,
378
+ mode: rule.mode,
379
+ min_approvals,
380
+ ...rule.roles && rule.roles.length > 0 ? { roles: [...rule.roles] } : {},
381
+ because: rule.risk
382
+ });
383
+ }
384
+ if (!matched && policy.default_mode && policy.default_mode !== "auto") requirements.push({
385
+ gate: "change",
386
+ mode: policy.default_mode,
387
+ min_approvals: approvalsNeeded({
388
+ risk,
389
+ gate: "change",
390
+ mode: policy.default_mode
391
+ }),
392
+ because: risk
393
+ });
394
+ return requirements.toSorted((a, b) => APPROVAL_GATES.indexOf(a.gate) - APPROVAL_GATES.indexOf(b.gate) || MODE_RANK[b.mode] - MODE_RANK[a.mode] || b.min_approvals - a.min_approvals);
395
+ }
396
+ /** Whether a grant may be counted at all, independent of any requirement. */
397
+ function grantDisqualification(grant, plan, policy, commit_sha, now) {
398
+ if (grant.plan_hash !== plan.plan_hash) return "plan_hash_mismatch";
399
+ if (grant.expires_at && grant.expires_at <= now) return "expired";
400
+ if (grant.approver.kind === "agent") return "agent_approver";
401
+ if (!policy.allow_self_approval && plan.created_by && grant.approver.id === plan.created_by.id && grant.approver.kind === plan.created_by.kind) return "self_approval";
402
+ if (grant.gate === "change" && commit_sha && grant.commit_sha !== commit_sha) return "commit_mismatch";
403
+ }
404
+ /**
405
+ * May this plan proceed?
406
+ *
407
+ * Answers with the full picture rather than a boolean: which requirements
408
+ * exist, which are met, who met them, and — for every decision that was
409
+ * supplied but did not count — why it did not. The last part is what makes
410
+ * "I approved this, why is it still blocked?" answerable without reading the
411
+ * evaluator's source.
412
+ */
413
+ function evaluateApproval(input) {
414
+ const { plan, commit_sha } = input;
415
+ const policy = input.policy ?? DEFAULT_APPROVAL_POLICY;
416
+ const grants = input.grants ?? [];
417
+ const now = input.now ?? (/* @__PURE__ */ new Date()).toISOString();
418
+ const risk = effectiveRisk(plan);
419
+ const requirements = requiredApprovals(plan, policy).map((requirement) => Object.assign(requirement, {
420
+ approvers: [],
421
+ remaining: requirement.min_approvals
422
+ }));
423
+ const rejected_grants = [];
424
+ for (const grant of grants) {
425
+ const disqualified = grantDisqualification(grant, plan, policy, commit_sha, now);
426
+ if (disqualified) {
427
+ rejected_grants.push({
428
+ grant,
429
+ reason: disqualified
430
+ });
431
+ continue;
432
+ }
433
+ let counted = false;
434
+ let sawGate = false;
435
+ let roleBlocked = false;
436
+ let duplicate = false;
437
+ for (const requirement of requirements) {
438
+ if (requirement.gate !== grant.gate) continue;
439
+ sawGate = true;
440
+ if (requirement.roles && !requirement.roles.includes(grant.approver.role ?? "")) {
441
+ roleBlocked = true;
442
+ continue;
443
+ }
444
+ if (requirement.approvers.some((approver) => approver.id === grant.approver.id)) {
445
+ duplicate = true;
446
+ continue;
447
+ }
448
+ requirement.approvers.push(grant.approver);
449
+ requirement.remaining = Math.max(0, requirement.min_approvals - requirement.approvers.length);
450
+ counted = true;
451
+ }
452
+ if (counted) continue;
453
+ if (!sawGate) rejected_grants.push({
454
+ grant,
455
+ reason: "no_matching_requirement"
456
+ });
457
+ else if (duplicate) rejected_grants.push({
458
+ grant,
459
+ reason: "duplicate_approver"
460
+ });
461
+ else if (roleBlocked) rejected_grants.push({
462
+ grant,
463
+ reason: "role_not_permitted"
464
+ });
465
+ }
466
+ const outstanding = requirements.filter((requirement) => requirement.remaining > 0);
467
+ const reasons = [];
468
+ const expired = Boolean(plan.expires_at && plan.expires_at <= now);
469
+ if (expired) reasons.push(`The plan expired at ${plan.expires_at}; rebuild it rather than running it.`);
470
+ for (const requirement of outstanding) {
471
+ const who = requirement.roles ? ` from ${requirement.roles.join(" or ")}` : "";
472
+ const have = requirement.approvers.length;
473
+ reasons.push(`${requirement.gate} approval: ${requirement.remaining} more${who} needed (${have}/${requirement.min_approvals}), because the plan is ${requirement.because}.`);
474
+ }
475
+ return {
476
+ allowed: !expired && outstanding.length === 0,
477
+ risk,
478
+ requirements,
479
+ outstanding,
480
+ rejected_grants,
481
+ reasons
482
+ };
483
+ }
484
+ //#endregion
130
485
  //#region src/provider.ts
131
486
  /** Capability set for the LocalProvider (simple-git + worktree). */
132
487
  const LOCAL_CAPABILITIES = {
@@ -153,17 +508,35 @@ const CONTENTRAIN_DIR = ".contentrain";
153
508
  * `{projectRoot}/{content_path}/` instead — but meta files always
154
509
  * remain under `.contentrain/meta/`.
155
510
  */
511
+ /**
512
+ * Where each kind of file lives. These are the paths a reader can rely on and
513
+ * a writer must produce; `@contentrain/mcp` resolves exactly these, and a
514
+ * parity test there holds the two together.
515
+ *
516
+ * They show the default `locale_strategy: 'file'`. The `suffix`, `directory`
517
+ * and `none` strategies move the locale within the same directory — see
518
+ * `LocaleStrategy` — and `content_path` replaces the
519
+ * `.contentrain/content/{domain}/{modelId}` prefix entirely.
520
+ *
521
+ * Meta always carries a locale in its file name: a non-i18n model keeps one
522
+ * meta record, pinned to the project's default locale.
523
+ */
156
524
  const PATH_PATTERNS = {
157
525
  config: ".contentrain/config.json",
158
526
  context: ".contentrain/context.json",
159
527
  vocabulary: ".contentrain/vocabulary.json",
160
528
  model: ".contentrain/models/{modelId}.json",
529
+ capabilities: ".contentrain/capabilities.json",
530
+ automations: ".contentrain/automations.json",
531
+ approvalPolicies: ".contentrain/approval-policies.json",
532
+ redirects: ".contentrain/redirects.json",
161
533
  content: {
162
534
  singleton: ".contentrain/content/{domain}/{modelId}/{locale}.json",
163
535
  collection: ".contentrain/content/{domain}/{modelId}/{locale}.json",
164
- document: ".contentrain/content/{domain}/{slug}/{locale}.md",
536
+ document: ".contentrain/content/{domain}/{modelId}/{slug}/{locale}.md",
165
537
  dictionary: ".contentrain/content/{domain}/{modelId}/{locale}.json",
166
- noLocale: ".contentrain/content/{domain}/{modelId}/data.json"
538
+ noLocale: ".contentrain/content/{domain}/{modelId}/data.json",
539
+ noLocaleDocument: ".contentrain/content/{domain}/{modelId}/{slug}.md"
167
540
  },
168
541
  meta: {
169
542
  singleton: ".contentrain/meta/{modelId}/{locale}.json",
@@ -172,6 +545,37 @@ const PATH_PATTERNS = {
172
545
  dictionary: ".contentrain/meta/{modelId}/{locale}.json"
173
546
  }
174
547
  };
548
+ /**
549
+ * Paths this repository reserves but does not yet write.
550
+ *
551
+ * Reserving them is the point. `.contentrain/` is a shared namespace: the
552
+ * migration engine, Studio and a customer's own tooling all write into it. A
553
+ * name claimed here cannot later be taken for something else, and — more
554
+ * usefully — the tools that walk the directory know these four are *expected*
555
+ * rather than stray.
556
+ *
557
+ * The contract for them is deliberately narrow and must stay that way until a
558
+ * tool actually owns one:
559
+ *
560
+ * - `contentrain_doctor` and `contentrain_validate` ignore them. They are not
561
+ * orphans, not broken content, and not the validator's business.
562
+ * - `contentrain_reconcile` treats each as one opaque file: it takes the side
563
+ * that changed, and reports `file_conflict` when both sides did. It never
564
+ * merges their interiors, because it does not know their interiors.
565
+ *
566
+ * Both behaviours are pinned by tests in `@contentrain/mcp`, so a future
567
+ * change to directory walking cannot quietly start flagging or auto-merging
568
+ * these files.
569
+ */
570
+ const RESERVED_PATHS = [
571
+ PATH_PATTERNS.capabilities,
572
+ PATH_PATTERNS.automations,
573
+ PATH_PATTERNS.approvalPolicies,
574
+ PATH_PATTERNS.redirects
575
+ ];
576
+ function isReservedPath(path) {
577
+ return RESERVED_PATHS.includes(path);
578
+ }
175
579
  /** Slug: lowercase alphanumeric with hyphens */
176
580
  const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
177
581
  /** Entry ID: alphanumeric, 1-40 chars, starts with alphanumeric */
@@ -589,27 +993,6 @@ function validateFieldValue(value, fieldDef) {
589
993
  }
590
994
  return issues;
591
995
  }
592
- /** Recursively sort object keys — respects optional fieldOrder for top-level keys */
593
- function sortKeys(obj, fieldOrder) {
594
- if (obj === null || obj === void 0) return void 0;
595
- if (Array.isArray(obj)) return obj.map((item) => sortKeys(item, fieldOrder));
596
- if (typeof obj !== "object") return obj;
597
- const record = obj;
598
- const sorted = {};
599
- const keys = fieldOrder ? [...new Set([...fieldOrder, ...Object.keys(record).toSorted()])] : Object.keys(record).toSorted();
600
- for (const key of keys) {
601
- if (!(key in record)) continue;
602
- const val = record[key];
603
- if (val === null || val === void 0) continue;
604
- sorted[key] = sortKeys(val);
605
- }
606
- return sorted;
607
- }
608
- /** Canonical JSON serialization — deterministic output for stable git diffs */
609
- function canonicalStringify(data, fieldOrder) {
610
- const sorted = sortKeys(data, fieldOrder);
611
- return `${JSON.stringify(sorted, null, 2)}\n`;
612
- }
613
996
  /** Generate a 12-character hex entry ID */
614
997
  function generateEntryId() {
615
998
  const hex = "0123456789abcdef";
@@ -655,18 +1038,128 @@ function conflictId(conflict) {
655
1038
  sortKeys(conflict.theirs) ?? null
656
1039
  ]));
657
1040
  }
658
- function parseYamlValue(raw) {
1041
+ /**
1042
+ * Decode the interior of a double-quoted scalar, or `undefined` when the text
1043
+ * is not one scalar after all — an unescaped `"` inside it, or a backslash with
1044
+ * nothing after it.
1045
+ *
1046
+ * Returning `undefined` rather than guessing matters: `"a" and "b"` starts and
1047
+ * ends with a quote without being a quoted scalar, and slicing the ends off it
1048
+ * would silently corrupt the value.
1049
+ *
1050
+ * An unrecognised escape keeps its backslash instead of erroring. Our own
1051
+ * serializer only ever emits `\\`, `\"`, `\n`, `\r` and `\t`, so round-tripping is
1052
+ * unaffected either way — but hand-written frontmatter says `"C:\Users"` and
1053
+ * losing that to strictness would be a worse trade than keeping the bytes.
1054
+ */
1055
+ function decodeDoubleQuoted(inner) {
1056
+ let out = "";
1057
+ for (let i = 0; i < inner.length; i += 1) {
1058
+ const char = inner[i];
1059
+ if (char === "\"") return void 0;
1060
+ if (char !== "\\") {
1061
+ out += char;
1062
+ continue;
1063
+ }
1064
+ const next = inner[i + 1];
1065
+ if (next === void 0) return void 0;
1066
+ i += 1;
1067
+ if (next === "n") out += "\n";
1068
+ else if (next === "r") out += "\r";
1069
+ else if (next === "t") out += " ";
1070
+ else if (next === "\\" || next === "\"" || next === "/") out += next;
1071
+ else if (next === "u" && /^[0-9a-fA-F]{4}$/.test(inner.slice(i + 1, i + 5))) {
1072
+ out += String.fromCharCode(parseInt(inner.slice(i + 1, i + 5), 16));
1073
+ i += 4;
1074
+ } else out += `\\${next}`;
1075
+ }
1076
+ return out;
1077
+ }
1078
+ /** Decode a single-quoted scalar, where YAML escapes a quote by doubling it. */
1079
+ function decodeSingleQuoted(inner) {
1080
+ let out = "";
1081
+ for (let i = 0; i < inner.length; i += 1) {
1082
+ const char = inner[i];
1083
+ if (char !== "'") {
1084
+ out += char;
1085
+ continue;
1086
+ }
1087
+ if (inner[i + 1] !== "'") return void 0;
1088
+ out += "'";
1089
+ i += 1;
1090
+ }
1091
+ return out;
1092
+ }
1093
+ /**
1094
+ * Read one frontmatter scalar: booleans, null, numbers, quoted strings with
1095
+ * their escapes decoded, or the text as written.
1096
+ *
1097
+ * Exported because it is a **contract**, not a utility. Two readers open these
1098
+ * documents — this package (through `parseMarkdownFrontmatter`, which
1099
+ * `@contentrain/mcp` re-exports) and `@contentrain/query`'s generator and Astro
1100
+ * loader. When each had its own copy they disagreed about what `"He said
1101
+ * \"Hi\""` says, which means the generated client and the content engine
1102
+ * disagreed about the same file. One implementation, imported by both.
1103
+ */
1104
+ function parseFrontmatterScalar(raw) {
659
1105
  if (raw === "true") return true;
660
1106
  if (raw === "false") return false;
661
1107
  if (raw === "null") return null;
662
1108
  if (/^-?\d+$/.test(raw)) return parseInt(raw, 10);
663
1109
  if (/^-?\d+\.\d+$/.test(raw)) return parseFloat(raw);
664
- if (raw.startsWith("\"") && raw.endsWith("\"") || raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1);
1110
+ if (raw.length >= 2 && raw.startsWith("\"") && raw.endsWith("\"")) return decodeDoubleQuoted(raw.slice(1, -1)) ?? raw;
1111
+ if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) return decodeSingleQuoted(raw.slice(1, -1)) ?? raw;
665
1112
  return raw;
666
1113
  }
1114
+ /**
1115
+ * A scalar read as a string: quoting and escapes are resolved, but a value that
1116
+ * merely looks like a number or a boolean stays the text it was. Array items
1117
+ * use this, and so does any reader that knows from the model that a field is
1118
+ * string-typed — a SKU of `"007"` must not become the number 7.
1119
+ */
1120
+ function parseFrontmatterScalarString(raw) {
1121
+ const value = parseFrontmatterScalar(raw);
1122
+ return typeof value === "string" ? value : raw;
1123
+ }
1124
+ /**
1125
+ * Split an inline array's interior on commas that are not inside a quoted
1126
+ * item — `["a, b", c]` is two items, not three. Exported for the same reason
1127
+ * as {@link parseFrontmatterScalar}: both readers must split it the same way.
1128
+ */
1129
+ function splitFrontmatterList(inner) {
1130
+ const items = [];
1131
+ let current = "";
1132
+ let quote;
1133
+ for (let i = 0; i < inner.length; i += 1) {
1134
+ const char = inner[i];
1135
+ if (quote) {
1136
+ if (char === "\\" && quote === "\"" && i + 1 < inner.length) {
1137
+ current += char + inner[i + 1];
1138
+ i += 1;
1139
+ continue;
1140
+ }
1141
+ current += char;
1142
+ if (char === quote) quote = void 0;
1143
+ continue;
1144
+ }
1145
+ if (char === "\"" || char === "'") {
1146
+ quote = char;
1147
+ current += char;
1148
+ continue;
1149
+ }
1150
+ if (char === ",") {
1151
+ items.push(current);
1152
+ current = "";
1153
+ continue;
1154
+ }
1155
+ current += char;
1156
+ }
1157
+ items.push(current);
1158
+ return items;
1159
+ }
667
1160
  function yamlScalar(value) {
668
1161
  const str = String(value);
669
- if (str.includes(":") || str.includes("#") || str.includes("{") || str.includes("}") || str.includes("[") || str.includes("]") || str.includes("*") || str.includes("&") || str.includes("!") || str.includes("|") || str.includes(">") || str.includes("'") || str.includes("\"") || str.includes("%") || str.includes("@") || str.includes("`") || str.startsWith("-") || str.startsWith("?") || str === "true" || str === "false" || str === "null" || str === "yes" || str === "no" || str === "") return `"${str.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
1162
+ if (str.includes(":") || str.includes("#") || str.includes("{") || str.includes("}") || str.includes("[") || str.includes("]") || str.includes("*") || str.includes("&") || str.includes("!") || str.includes("|") || str.includes(">") || str.includes("'") || str.includes("\"") || str.includes("%") || str.includes("@") || str.includes("`") || str.includes("\\") || str.includes("\n") || str.includes("\r") || str.includes(" ") || str.startsWith("-") || str.startsWith("?") || str !== str.trim() || str === "" || typeof value === "string" && (parseFrontmatterScalar(str) !== str || str === "yes" || str === "no")) return `"${str.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`;
670
1163
  return str;
671
1164
  }
672
1165
  function serializeYamlValue(value, indent) {
@@ -714,6 +1207,10 @@ function serializeYamlFields(data) {
714
1207
  lines.push(`${key}:`);
715
1208
  lines.push(...serializeYamlValue(value, 1));
716
1209
  } else if (Array.isArray(value)) {
1210
+ if (value.length === 0) {
1211
+ lines.push(`${key}: []`);
1212
+ continue;
1213
+ }
717
1214
  lines.push(`${key}:`);
718
1215
  for (const item of value) if (typeof item === "object" && item !== null && !Array.isArray(item)) {
719
1216
  const nested = serializeYamlValue(item, 2);
@@ -741,7 +1238,7 @@ function parseMarkdownFrontmatter(content) {
741
1238
  let currentArray = null;
742
1239
  for (const line of frontmatterStr.split("\n")) {
743
1240
  if (/^\s+-\s+/.test(line) && currentKey) {
744
- const value = line.replace(/^\s+-\s+/, "").trim();
1241
+ const value = parseFrontmatterScalarString(line.replace(/^\s+-\s+/, "").trim());
745
1242
  if (!currentArray) currentArray = [];
746
1243
  currentArray.push(value);
747
1244
  continue;
@@ -761,10 +1258,11 @@ function parseMarkdownFrontmatter(content) {
761
1258
  continue;
762
1259
  }
763
1260
  if (rawValue.startsWith("[") && rawValue.endsWith("]")) {
764
- frontmatter[key] = rawValue.slice(1, -1).split(",").map((s) => s.trim()).filter(Boolean);
1261
+ const inner = rawValue.slice(1, -1).trim();
1262
+ frontmatter[key] = inner === "" ? [] : splitFrontmatterList(inner).map((item) => parseFrontmatterScalarString(item.trim()));
765
1263
  continue;
766
1264
  }
767
- frontmatter[key] = parseYamlValue(rawValue);
1265
+ frontmatter[key] = parseFrontmatterScalar(rawValue);
768
1266
  }
769
1267
  if (currentKey && currentArray) frontmatter[currentKey] = currentArray;
770
1268
  return {
@@ -810,6 +1308,6 @@ function serializeMarkdownFrontmatter(data, body) {
810
1308
  return lines.join("\n");
811
1309
  }
812
1310
  //#endregion
813
- export { 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, DICTIONARY_TITLE_FIELD, ENTRY_ID_PATTERN, LEGACY_CSS_LAYER, LIST_ITEMS_SLOT, LOCALE_PATTERN, LOCAL_CAPABILITIES, MIGRATION_CONTRACT_VERSION, MODEL_EXTENSION_KEYS, MODEL_FIELD_ORDER, PATH_PATTERNS, RAW_MARK_SUFFIX, SECRET_PATTERNS, SLUG_PATTERN, SOURCE_ACCESS_LADDER, TITLE_FIELD_TYPES, canonicalStringify, componentSlot, conflictId, detectSecrets, generateEntryId, isMediaType, isTitleFieldType, looksLikeCredential, orderedFieldNames, parseMarkdownFrontmatter, resolveFieldLabel, serializeMarkdownFrontmatter, sortKeys, stableHash, validateAccept, validateEntryId, validateFieldValue, validateLocale, validateSemanticType, validateSlug };
1311
+ export { APPROVAL_GATES, APPROVAL_MODES, 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, DEFAULT_APPROVAL_POLICY, DICTIONARY_TITLE_FIELD, ENTRY_ID_PATTERN, EXECUTION_CONTRACT_VERSION, LEGACY_CSS_LAYER, LIST_ITEMS_SLOT, LOCALE_PATTERN, LOCAL_CAPABILITIES, MIGRATION_CONTRACT_VERSION, MODEL_EXTENSION_KEYS, MODEL_FIELD_ORDER, PATH_PATTERNS, RAW_MARK_SUFFIX, RESERVED_PATHS, RISK_CLASSES, RUN_STATUSES, SCHEDULE_KINDS, SECRET_PATTERNS, SLUG_PATTERN, SOURCE_ACCESS_LADDER, SOURCE_DELTA_OPS, TERMINAL_RUN_STATUSES, TITLE_FIELD_TYPES, 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 };
814
1312
 
815
1313
  //# sourceMappingURL=index.mjs.map