@contentrain/types 1.13.1 → 1.15.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 = {
@@ -171,6 +526,10 @@ const PATH_PATTERNS = {
171
526
  context: ".contentrain/context.json",
172
527
  vocabulary: ".contentrain/vocabulary.json",
173
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",
174
533
  content: {
175
534
  singleton: ".contentrain/content/{domain}/{modelId}/{locale}.json",
176
535
  collection: ".contentrain/content/{domain}/{modelId}/{locale}.json",
@@ -186,6 +545,37 @@ const PATH_PATTERNS = {
186
545
  dictionary: ".contentrain/meta/{modelId}/{locale}.json"
187
546
  }
188
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
+ }
189
579
  /** Slug: lowercase alphanumeric with hyphens */
190
580
  const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
191
581
  /** Entry ID: alphanumeric, 1-40 chars, starts with alphanumeric */
@@ -603,27 +993,6 @@ function validateFieldValue(value, fieldDef) {
603
993
  }
604
994
  return issues;
605
995
  }
606
- /** Recursively sort object keys — respects optional fieldOrder for top-level keys */
607
- function sortKeys(obj, fieldOrder) {
608
- if (obj === null || obj === void 0) return void 0;
609
- if (Array.isArray(obj)) return obj.map((item) => sortKeys(item, fieldOrder));
610
- if (typeof obj !== "object") return obj;
611
- const record = obj;
612
- const sorted = {};
613
- const keys = fieldOrder ? [...new Set([...fieldOrder, ...Object.keys(record).toSorted()])] : Object.keys(record).toSorted();
614
- for (const key of keys) {
615
- if (!(key in record)) continue;
616
- const val = record[key];
617
- if (val === null || val === void 0) continue;
618
- sorted[key] = sortKeys(val);
619
- }
620
- return sorted;
621
- }
622
- /** Canonical JSON serialization — deterministic output for stable git diffs */
623
- function canonicalStringify(data, fieldOrder) {
624
- const sorted = sortKeys(data, fieldOrder);
625
- return `${JSON.stringify(sorted, null, 2)}\n`;
626
- }
627
996
  /** Generate a 12-character hex entry ID */
628
997
  function generateEntryId() {
629
998
  const hex = "0123456789abcdef";
@@ -669,18 +1038,128 @@ function conflictId(conflict) {
669
1038
  sortKeys(conflict.theirs) ?? null
670
1039
  ]));
671
1040
  }
672
- 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) {
673
1105
  if (raw === "true") return true;
674
1106
  if (raw === "false") return false;
675
1107
  if (raw === "null") return null;
676
1108
  if (/^-?\d+$/.test(raw)) return parseInt(raw, 10);
677
1109
  if (/^-?\d+\.\d+$/.test(raw)) return parseFloat(raw);
678
- 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;
679
1112
  return raw;
680
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
+ }
681
1160
  function yamlScalar(value) {
682
1161
  const str = String(value);
683
- 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")}"`;
684
1163
  return str;
685
1164
  }
686
1165
  function serializeYamlValue(value, indent) {
@@ -728,6 +1207,10 @@ function serializeYamlFields(data) {
728
1207
  lines.push(`${key}:`);
729
1208
  lines.push(...serializeYamlValue(value, 1));
730
1209
  } else if (Array.isArray(value)) {
1210
+ if (value.length === 0) {
1211
+ lines.push(`${key}: []`);
1212
+ continue;
1213
+ }
731
1214
  lines.push(`${key}:`);
732
1215
  for (const item of value) if (typeof item === "object" && item !== null && !Array.isArray(item)) {
733
1216
  const nested = serializeYamlValue(item, 2);
@@ -755,7 +1238,7 @@ function parseMarkdownFrontmatter(content) {
755
1238
  let currentArray = null;
756
1239
  for (const line of frontmatterStr.split("\n")) {
757
1240
  if (/^\s+-\s+/.test(line) && currentKey) {
758
- const value = line.replace(/^\s+-\s+/, "").trim();
1241
+ const value = parseFrontmatterScalarString(line.replace(/^\s+-\s+/, "").trim());
759
1242
  if (!currentArray) currentArray = [];
760
1243
  currentArray.push(value);
761
1244
  continue;
@@ -775,10 +1258,11 @@ function parseMarkdownFrontmatter(content) {
775
1258
  continue;
776
1259
  }
777
1260
  if (rawValue.startsWith("[") && rawValue.endsWith("]")) {
778
- 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()));
779
1263
  continue;
780
1264
  }
781
- frontmatter[key] = parseYamlValue(rawValue);
1265
+ frontmatter[key] = parseFrontmatterScalar(rawValue);
782
1266
  }
783
1267
  if (currentKey && currentArray) frontmatter[currentKey] = currentArray;
784
1268
  return {
@@ -824,6 +1308,6 @@ function serializeMarkdownFrontmatter(data, body) {
824
1308
  return lines.join("\n");
825
1309
  }
826
1310
  //#endregion
827
- 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 };
828
1312
 
829
1313
  //# sourceMappingURL=index.mjs.map