@taprootio/site-authoring 0.6.3 → 0.7.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.
@@ -2,6 +2,14 @@ import { readFileSync } from "node:fs";
2
2
 
3
3
  import { TEMPLATE_TYPE_FREE_FORM } from "./api.js";
4
4
  import { CLI_BINARY_NAME } from "./constants.js";
5
+ import {
6
+ canonicalizeComponentData,
7
+ COMPONENT_TYPES,
8
+ getComponentDefinition,
9
+ getComponentPropertyReference,
10
+ } from "./content/components.js";
11
+ import { FREE_FORM_SECTION_REGISTRY } from "./content/free-form-sections.js";
12
+ import { CONTENT_ERROR_CODES, CONTENT_LIMITS, MARK_TYPES, NODE_TYPES } from "./content/vocabulary.js";
5
13
  import {
6
14
  FIXTURE_CONTRACT_VERSION,
7
15
  FIXTURE_DELIVERY_ORIGIN_DOMAIN,
@@ -13,6 +21,12 @@ import {
13
21
  SHIPPED_FIXTURE_NAME,
14
22
  shippedFixtureDirectory,
15
23
  } from "./fixture-contract.js";
24
+ import {
25
+ formatPresentationReference,
26
+ getAppearanceReference,
27
+ getFooterReference,
28
+ getThemeReference,
29
+ } from "./presentation-reference.js";
16
30
  import {
17
31
  DEFAULT_REDIRECT_STATUS,
18
32
  GONE_STATUS,
@@ -21,25 +35,6 @@ import {
21
35
  REDIRECTS_FILE_NAME,
22
36
  } from "./redirects-contract.js";
23
37
  import { SETTINGS_GROUPS } from "./settings-catalog.js";
24
- import {
25
- canonicalizeComponentData,
26
- COMPONENT_TYPES,
27
- getComponentDefinition,
28
- getComponentPropertyReference,
29
- } from "./content/components.js";
30
- import { FREE_FORM_SECTION_REGISTRY } from "./content/free-form-sections.js";
31
- import {
32
- CONTENT_ERROR_CODES,
33
- CONTENT_LIMITS,
34
- MARK_TYPES,
35
- NODE_TYPES,
36
- } from "./content/vocabulary.js";
37
- import {
38
- formatPresentationReference,
39
- getAppearanceReference,
40
- getFooterReference,
41
- getThemeReference,
42
- } from "./presentation-reference.js";
43
38
  import {
44
39
  INTERNAL_PAGE_BASELINE_DIRECTORY,
45
40
  MANIFEST_VERSION,
@@ -48,15 +43,15 @@ import {
48
43
  PAGE_SOURCE_EXTENSIONS,
49
44
  PAGE_WORKSPACE_MODE_EDITABLE,
50
45
  PAGE_WORKSPACE_MODE_READ_ONLY,
51
- pageSourceFormat,
52
46
  PAGES_DIRECTORY,
47
+ pageSourceFormat,
53
48
  SETTINGS_DIRECTORY,
54
49
  SYSTEM_PAGE_NOT_FOUND_PATH,
55
50
  } from "./workspace.js";
56
51
 
57
52
  export { getAppearanceReference, getFooterReference, getThemeReference };
58
53
 
59
- export const REFERENCE_VERSION = 19;
54
+ export const REFERENCE_VERSION = 20;
60
55
  export const PAGE_TYPES = Object.freeze(["free-form"]);
61
56
  export const REFERENCE_TOPICS = Object.freeze([
62
57
  Object.freeze({ name: "pages", usage: `${CLI_BINARY_NAME} help pages`, summary: "List authorable page types." }),
@@ -139,12 +134,21 @@ const WORKFLOW_REFERENCES = Object.freeze({
139
134
  }),
140
135
  redirects: Object.freeze({
141
136
  title: "Redirect map workspace contract",
142
- summary:
143
- `${REDIRECTS_FILE_NAME} is the site's whole redirect map. 'redirects push' replaces it, fenced by the `
137
+ summary: `${REDIRECTS_FILE_NAME} is the site's whole redirect map. 'redirects push' replaces it, fenced by the `
144
138
  + "revision 'redirects pull' recorded, so a stale write is refused rather than deleting an entry a page "
145
139
  + "rename added.",
146
- usage: `${CLI_BINARY_NAME} redirects pull | ${CLI_BINARY_NAME} redirects push`,
140
+ usage: `${CLI_BINARY_NAME} redirects pull | ${CLI_BINARY_NAME} redirects push | ${CLI_BINARY_NAME} redirects check`,
147
141
  details: Object.freeze([
142
+ "Run 'redirects check' after staging, or read deploy --staging's automatic check. The Node CLI consumes "
143
+ + "a separate staging handoff and checks the current map at the real edge, using an in-memory cookie and "
144
+ + "manual redirects. It reports each path, HTTP status and Location without following targets.",
145
+ "verified is true only if every entry matched and the map revision stayed unchanged during the check. "
146
+ + "The check proves the current map at staging, not a historical deployment. A mismatch can be propagation "
147
+ + "delay; re-run redirects check before promoting. Requests are bounded to 90 seconds, eight at a time; "
148
+ + "large JSON lists are truncated explicitly with failed entries first. Human output reports every entry.",
149
+ "stagingPreview.url is a fresh, single-use two-minute handoff, minted after checks. It is emitted only "
150
+ + "in final JSON, excluded from progress and GITHUB_OUTPUT; keep it private. The staging cookie lasts "
151
+ + "five minutes and remains subject to current site/host/credential permission.",
148
152
  `${REDIRECTS_FILE_NAME} is { siteId, revision, entries }, not a bare array. 'redirects pull' writes it and `
149
153
  + "records the revision in .taproot-site-manifest.json; 'pull' writes it too.",
150
154
  "An entry is { path, kind, target, status }. kind is 'redirect' (the default) or 'gone'; a gone entry "
@@ -239,6 +243,13 @@ const WORKFLOW_REFERENCES = Object.freeze({
239
243
  summary: "Render one persisted draft behind the normal staging gate.",
240
244
  usage: `${CLI_BINARY_NAME} preview page <page-path-or-id>`,
241
245
  details: Object.freeze([
246
+ "Preview failures carry safe error class, page ID, and node/attribute labels when known. Raw renderer "
247
+ + "messages and page content are not returned. A deploy refuses a retained failed preview matching a "
248
+ + "selected page's content until a newer matching preview is ready, the content changes, or the operator "
249
+ + "explicitly supplies --allow-failed-preview. Pending retries do not clear a known failure; previews "
250
+ + "remain optional and old snapshots without content identity do not block deployment.",
251
+ "deploy --staging and redirects check return a separate fresh single-use staging handoff in "
252
+ + "stagingPreview.url, after authenticating headless redirect checks. Handoffs never go to GITHUB_OUTPUT.",
242
253
  "A page path resolves through .taproot-site-manifest.json; a canonical page UUID works directly.",
243
254
  "The homepage's manifest path is empty; address it as '/', which resolves to that empty root path.",
244
255
  "Preview before approving: approve consumes the draft, so an approved page has no draft left to render and answers preview.no_draft. Review it on staging after a deploy instead.",
@@ -259,8 +270,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
259
270
  usage: `${CLI_BINARY_NAME} validate <fixture-directory>`,
260
271
  details: Object.freeze([
261
272
  `Required root fields: ${FIXTURE_REQUIRED_ROOT_FIELDS.join(", ")}.`,
262
- `Accepted and not read: ${FIXTURE_OPTIONAL_ROOT_FIELDS.join(", ")} — pull writes them, and neither the snapshot `
263
- + "time nor a recorded deployment can be checked offline. Any other root field is refused.",
273
+ `Optional root fields: ${FIXTURE_OPTIONAL_ROOT_FIELDS.join(", ")}. Appearance and footer metadata, when present, `
274
+ + "must match the local settings. Snapshot times and deployments are not live evidence. Other root fields are refused.",
264
275
  `manifestVersion must be ${MANIFEST_VERSION}; fixture.contractVersion must be ${FIXTURE_CONTRACT_VERSION}.`,
265
276
  "siteId, every pageId and resourceId, every settings entityId, and every fixture.imageIds entry is a canonical "
266
277
  + "lowercase UUID. They are deterministic fixture identities, not identities from a live site.",
@@ -295,6 +306,16 @@ const WORKFLOW_REFERENCES = Object.freeze({
295
306
  + "with no duplicates. Fixtures are copied and shipped, so a real delivery host in one would be a live "
296
307
  + "reference in every copy.",
297
308
  "validate reads the fixture and writes nothing to it. Copy the directory somewhere writable before editing it.",
309
+ "validate --init <new-directory> exports the current pulled workspace as a validated version-6 fixture with "
310
+ + "appearance and footer metadata. Run from the workspace or its configured project; --config may select the source.",
311
+ "Initialization keeps editable free-form pages as ProseMirror sources and reports excluded metadata/read-only pages. "
312
+ + "References to excluded pages must be resolved before the fixture can validate. It never copies credentials, "
313
+ + "internal reconciliation state, or deployment receipts. UUIDs and HTTP(S) origins are replaced with deterministic "
314
+ + "fixture identities and example.test origins; URL credentials, queries and fragments are removed. Authored prose is retained.",
315
+ "The destination must be outside the source workspace and must not exist; its parent must already exist without symlinks. Exports are bounded to 64 MiB. "
316
+ + "The source is unchanged; a failed fixture validation removes the newly created output.",
317
+ "Missing required settings are reported together in error.details with field paths and the pull remedy, "
318
+ + "separately from present-but-invalid values.",
298
319
  ]),
299
320
  }),
300
321
  });
@@ -416,32 +437,40 @@ function registryFieldReference(name, definition) {
416
437
  minItems: definition.urls.minItems,
417
438
  maxItems: definition.urls.maxItems,
418
439
  itemAdditionalProperties: !definition.urls.closedItems,
419
- fields: Object.freeze(Object.entries(definition.urls.fields).map(([fieldName, field]) =>
420
- registryFieldReference(fieldName, field)
421
- )),
440
+ fields: Object.freeze(
441
+ Object.entries(definition.urls.fields).map(([fieldName, field]) =>
442
+ registryFieldReference(fieldName, field)
443
+ ),
444
+ ),
422
445
  }),
423
446
  }
424
447
  : {}),
425
448
  ...(definition.srcMustMatchUrls === true ? { srcMustMatchUrls: true } : {}),
426
449
  ...(definition.fields
427
450
  ? {
428
- fields: Object.freeze(Object.entries(definition.fields).map(([fieldName, field]) => Object.freeze({
429
- ...registryFieldReference(fieldName, field),
430
- ...(field.requiredKeys ? { requiredKeys: [...field.requiredKeys] } : {}),
431
- ...(field.optionalKeys ? { optionalKeys: [...field.optionalKeys] } : {}),
432
- ...(field.tokenByValue ? { tokenByValue: { ...field.tokenByValue } } : {}),
433
- }))),
451
+ fields: Object.freeze(
452
+ Object.entries(definition.fields).map(([fieldName, field]) =>
453
+ Object.freeze({
454
+ ...registryFieldReference(fieldName, field),
455
+ ...(field.requiredKeys ? { requiredKeys: [...field.requiredKeys] } : {}),
456
+ ...(field.optionalKeys ? { optionalKeys: [...field.optionalKeys] } : {}),
457
+ ...(field.tokenByValue ? { tokenByValue: { ...field.tokenByValue } } : {}),
458
+ })
459
+ ),
460
+ ),
434
461
  }
435
462
  : {}),
436
463
  });
437
464
  }
438
465
 
439
466
  function sectionAttributeReference() {
440
- return Object.entries(FREE_FORM_SECTION_REGISTRY.section.attrs).map(([name, definition]) => Object.freeze({
441
- ...registryFieldReference(name, definition),
442
- label: definition.label,
443
- help: definition.help,
444
- }));
467
+ return Object.entries(FREE_FORM_SECTION_REGISTRY.section.attrs).map(([name, definition]) =>
468
+ Object.freeze({
469
+ ...registryFieldReference(name, definition),
470
+ label: definition.label,
471
+ help: definition.help,
472
+ })
473
+ );
445
474
  }
446
475
 
447
476
  const SECTION_IMAGE_EXAMPLE = Object.freeze({
@@ -490,7 +519,9 @@ function backgroundReference() {
490
519
  compactBehavior:
491
520
  "At the shared compact breakpoint, portraitImage and portraitFocus replace the landscape image and focus when supplied.",
492
521
  example: BACKGROUND_EXAMPLE,
493
- markdownExample: `:::section ${JSON.stringify({ background: BACKGROUND_EXAMPLE })}\n## Practice with us\n\nFind your next class.\n:::`,
522
+ markdownExample: `:::section ${
523
+ JSON.stringify({ background: BACKGROUND_EXAMPLE })
524
+ }\n## Practice with us\n\nFind your next class.\n:::`,
494
525
  });
495
526
  }
496
527
 
@@ -522,7 +553,9 @@ function decorationReference() {
522
553
  "Use the complete site-owned processed-image result from media upload. src and every urls[].url must be HTTPS or non-protocol-relative root-relative delivery URLs; credentials, whitespace, controls, and backslashes are rejected.",
523
554
  tintTokens: Object.freeze({ ...DECORATION_DEFINITION.fields.tint.tokenByValue }),
524
555
  example: DECORATION_EXAMPLE,
525
- markdownExample: `:::section ${JSON.stringify({ decoration: DECORATION_EXAMPLE })}\n## Rooted in warmth\n\nMove with confidence.\n:::`,
556
+ markdownExample: `:::section ${
557
+ JSON.stringify({ decoration: DECORATION_EXAMPLE })
558
+ }\n## Rooted in warmth\n\nMove with confidence.\n:::`,
526
559
  maskGuidance:
527
560
  "Use a transparent PNG or WebP whose alpha channel contains only the mark. The active section context supplies the tint in both schemes.",
528
561
  opaqueWarning:
@@ -562,18 +595,20 @@ function inlineFactsReference() {
562
595
  }),
563
596
  }),
564
597
  placement: Object.freeze({ ...INLINE_FACTS_DEFINITION.placement }),
565
- valuePolicy:
566
- `Required non-whitespace plain text; maximum ${items.fields.value.maxScalars} Unicode scalar values.`,
567
- labelPolicy:
568
- `Optional non-whitespace plain text; maximum ${items.fields.label.maxScalars} Unicode scalar values. `
598
+ valuePolicy: `Required non-whitespace plain text; maximum ${items.fields.value.maxScalars} Unicode scalar values. `
599
+ + "Unicode spaces, tabs, and LF/CRLF line breaks are allowed. Line breaks display on separate lines inside the value's link. Other C0/C1 controls, U+2028/U+2029, U+FEFF, and literal <br> tags are rejected.",
600
+ labelPolicy: `Optional non-whitespace plain text; maximum ${items.fields.label.maxScalars} Unicode scalar values. `
569
601
  + "Omit it when the value already names itself, such as a rating, an address, or a phone number; a row may "
570
- + "mix labelled and standalone facts. An empty string is rejected, so omitting a label stays explicit.",
602
+ + "mix labelled and standalone facts. An empty string is rejected, so omitting a label stays explicit. "
603
+ + "Unicode spaces, tabs, and LF/CRLF line breaks are allowed; other C0/C1 controls, U+2028/U+2029, U+FEFF, and literal <br> tags are rejected. The label follows the value in reading order and is outside its link.",
571
604
  urlPolicy:
572
- "Optional safe HTTP(S), mailto, tel, non-protocol-relative root, fragment, query, or relative URL. Empty/whitespace-only values, other schemes, backslashes, and ASCII controls are rejected; tel: remains a native link.",
605
+ "Optional safe HTTP(S), mailto, tel, non-protocol-relative root, fragment, query, or relative URL. Empty/whitespace-only values, other schemes, backslashes, and ASCII controls are rejected; tel: remains a native link. Contact URLs require a body and refuse tel:// and mailto:// authority forms or control characters, consistently with navigation and footer links.",
573
606
  markdown: Object.freeze({
574
607
  fence: INLINE_FACTS_DEFINITION.markdown.fence,
575
608
  body: INLINE_FACTS_DEFINITION.markdown.body,
576
- example: `\`\`\`${INLINE_FACTS_DEFINITION.markdown.fence}\n${JSON.stringify(INLINE_FACT_ITEMS_EXAMPLE, null, 2)}\n\`\`\``,
609
+ example: `\`\`\`${INLINE_FACTS_DEFINITION.markdown.fence}\n${
610
+ JSON.stringify(INLINE_FACT_ITEMS_EXAMPLE, null, 2)
611
+ }\n\`\`\``,
577
612
  }),
578
613
  examples: Object.freeze({ items: INLINE_FACT_ITEMS_EXAMPLE, proseMirror }),
579
614
  });
@@ -709,7 +744,8 @@ function tableReference() {
709
744
  captionMustImmediatelyPrecedeHeader: TABLE_DEFINITION.markdown.captionImmediate,
710
745
  outerPipes: TABLE_DEFINITION.markdown.outerPipes,
711
746
  literalPipeEscape: TABLE_DEFINITION.markdown.literalPipeEscape,
712
- delimiter: `one or more hyphens per column (minimum ${TABLE_DEFINITION.markdown.delimiterMinHyphens}); no other text`,
747
+ delimiter:
748
+ `one or more hyphens per column (minimum ${TABLE_DEFINITION.markdown.delimiterMinHyphens}); no other text`,
713
749
  alignment: TABLE_DEFINITION.markdown.alignment,
714
750
  blankLineTerminates: TABLE_DEFINITION.markdown.blankLineTerminates,
715
751
  }),
@@ -722,31 +758,38 @@ function tableReference() {
722
758
  corrections: Object.freeze([
723
759
  Object.freeze({
724
760
  code: CONTENT_ERROR_CODES.childNotAllowed,
725
- correction: "Move a table to the document root or directly into a top-level section; cells contain a paragraph, never another block.",
761
+ correction:
762
+ "Move a table to the document root or directly into a top-level section; cells contain a paragraph, never another block.",
726
763
  }),
727
764
  Object.freeze({
728
765
  code: CONTENT_ERROR_CODES.attrUnknown,
729
- correction: "Keep only the optional caption attribute on table; tableRow, tableHeader, and tableCell have no attributes or appearance controls.",
766
+ correction:
767
+ "Keep only the optional caption attribute on table; tableRow, tableHeader, and tableCell have no attributes or appearance controls.",
730
768
  }),
731
769
  Object.freeze({
732
770
  code: CONTENT_ERROR_CODES.tableHeader,
733
- correction: "Put exactly one tableRow first, use only tableHeader cells in it, and give every header non-whitespace text.",
771
+ correction:
772
+ "Put exactly one tableRow first, use only tableHeader cells in it, and give every header non-whitespace text.",
734
773
  }),
735
774
  Object.freeze({
736
775
  code: CONTENT_ERROR_CODES.tableShape,
737
- correction: "Use tableRow children, tableHeader only in the first row, tableCell after it, and keep tables at root or directly in a section.",
776
+ correction:
777
+ "Use tableRow children, tableHeader only in the first row, tableCell after it, and keep tables at root or directly in a section.",
738
778
  }),
739
779
  Object.freeze({
740
780
  code: CONTENT_ERROR_CODES.tableRagged,
741
- correction: "Add or remove cells so the delimiter and every data row have exactly the header row's column count.",
781
+ correction:
782
+ "Add or remove cells so the delimiter and every data row have exactly the header row's column count.",
742
783
  }),
743
784
  Object.freeze({
744
785
  code: CONTENT_ERROR_CODES.tableBounds,
745
- correction: `Use ${TABLE_DEFINITION.rows.minData}-${TABLE_DEFINITION.rows.maxData} data rows, ${TABLE_DEFINITION.columns.min}-${TABLE_DEFINITION.columns.max} columns, captions up to ${TABLE_DEFINITION.attrs.caption.maxScalars} scalars, and cells up to ${TABLE_DEFINITION.cells.maxTextScalars} text scalars.`,
786
+ correction:
787
+ `Use ${TABLE_DEFINITION.rows.minData}-${TABLE_DEFINITION.rows.maxData} data rows, ${TABLE_DEFINITION.columns.min}-${TABLE_DEFINITION.columns.max} columns, captions up to ${TABLE_DEFINITION.attrs.caption.maxScalars} scalars, and cells up to ${TABLE_DEFINITION.cells.maxTextScalars} text scalars.`,
746
788
  }),
747
789
  Object.freeze({
748
790
  code: CONTENT_ERROR_CODES.tableCellContent,
749
- correction: "Give every tableHeader and tableCell exactly one paragraph containing only text, hardBreak, and the supported marks/links.",
791
+ correction:
792
+ "Give every tableHeader and tableCell exactly one paragraph containing only text, hardBreak, and the supported marks/links.",
750
793
  }),
751
794
  Object.freeze({
752
795
  code: CONTENT_ERROR_CODES.tableSpan,
@@ -754,7 +797,8 @@ function tableReference() {
754
797
  }),
755
798
  Object.freeze({
756
799
  code: CONTENT_ERROR_CODES.markdownTable,
757
- correction: "Use a pipe header, a matching hyphen-only delimiter, at least one data row, and a blank line before the next block.",
800
+ correction:
801
+ "Use a pipe header, a matching hyphen-only delimiter, at least one data row, and a blank line before the next block.",
758
802
  }),
759
803
  Object.freeze({
760
804
  code: CONTENT_ERROR_CODES.markdownTableAlignment,
@@ -1045,12 +1089,15 @@ function formatSectionAttr(attr) {
1045
1089
 
1046
1090
  function formatRegistryField(field, indent = " ") {
1047
1091
  if (field.type === "processed-image") {
1048
- return `${indent}${field.name.padEnd(Math.max(1, 20 - indent.length))} processed-image${field.nullable ? " | null" : ""}; ${
1049
- field.required ? "required" : `default ${JSON.stringify(field.default)}`
1050
- }; required keys ${field.requiredKeys.join(", ")}; optional keys ${
1092
+ return `${indent}${field.name.padEnd(Math.max(1, 20 - indent.length))} processed-image${
1093
+ field.nullable ? " | null" : ""
1094
+ }; ${field.required ? "required" : `default ${JSON.stringify(field.default)}`}; required keys ${
1095
+ field.requiredKeys.join(", ")
1096
+ }; optional keys ${
1051
1097
  field.optionalKeys.join(", ")
1052
1098
  }; responsive candidates ${field.responsiveUrls.minItems} through ${field.responsiveUrls.maxItems}; minWidth positive integer; optional type ${
1053
- field.responsiveUrls.fields.find((item) => item.name === "type").values.map((value) => JSON.stringify(value)).join(" | ")
1099
+ field.responsiveUrls.fields.find((item) => item.name === "type").values.map((value) => JSON.stringify(value))
1100
+ .join(" | ")
1054
1101
  }; src must match one candidate; unlisted keys rejected`;
1055
1102
  }
1056
1103
  const type = field.values ? field.values.map((value) => JSON.stringify(value)).join(" | ") : field.type;
@@ -1058,10 +1105,12 @@ function formatRegistryField(field, indent = " ") {
1058
1105
  const itemBounds = field.minItems === undefined ? "" : `; items ${field.minItems} through ${field.maxItems}`;
1059
1106
  const scalarBounds = field.maxScalars === undefined
1060
1107
  ? ""
1061
- : `; ${field.minScalars} through ${field.maxScalars} Unicode scalar values${field.nonWhitespace ? "; non-whitespace" : ""}`;
1062
- const line = `${indent}${field.name.padEnd(Math.max(1, 20 - indent.length))} ${type}${field.nullable ? " | null" : ""}; ${
1063
- field.required ? "required" : `default ${JSON.stringify(field.default)}`
1064
- }${bounds}${itemBounds}${scalarBounds}`;
1108
+ : `; ${field.minScalars} through ${field.maxScalars} Unicode scalar values${
1109
+ field.nonWhitespace ? "; non-whitespace" : ""
1110
+ }`;
1111
+ const line = `${indent}${field.name.padEnd(Math.max(1, 20 - indent.length))} ${type}${
1112
+ field.nullable ? " | null" : ""
1113
+ }; ${field.required ? "required" : `default ${JSON.stringify(field.default)}`}${bounds}${itemBounds}${scalarBounds}`;
1065
1114
  return [line, ...(field.fields ?? []).map((child) => formatRegistryField(child, `${indent} `))].join("\n");
1066
1115
  }
1067
1116
 
@@ -1098,23 +1147,11 @@ export function formatReferenceResult(result) {
1098
1147
  page.workspace.formats.map((format) => ` ${format.extension.padEnd(9)} ${format.purpose}`).join("\n")
1099
1148
  }\n\nOne source per page:\n rule ${sourceRule.rule}\n manifest fields ${
1100
1149
  sourceRule.manifestFields.join(", ")
1101
- }\n pull ${sourceRule.pull}\n internal state ${
1102
- sourceRule.internalState
1103
- }\n format change ${sourceRule.formatChange} (${sourceRule.formatChangeError})\n renamed source ${
1104
- sourceRule.renamedSource
1105
- } (${sourceRule.renamedSourceError})\n conflicts ${
1106
- sourceRule.conflict
1107
- } (${sourceRule.conflictError})\n conflict detail ${
1108
- sourceRule.conflictDetail
1109
- }\n push conflicts ${
1110
- sourceRule.pushConflict
1111
- } (${sourceRule.pushConflictError})\n revision ${
1112
- sourceRule.revisionSource
1113
- }\n recovery ${
1114
- sourceRule.conflictRecovery
1115
- }\n push selection ${sourceRule.pushSelection}\n\nSystem page projections:\n${
1150
+ }\n pull ${sourceRule.pull}\n internal state ${sourceRule.internalState}\n format change ${sourceRule.formatChange} (${sourceRule.formatChangeError})\n renamed source ${sourceRule.renamedSource} (${sourceRule.renamedSourceError})\n conflicts ${sourceRule.conflict} (${sourceRule.conflictError})\n conflict detail ${sourceRule.conflictDetail}\n push conflicts ${sourceRule.pushConflict} (${sourceRule.pushConflictError})\n revision ${sourceRule.revisionSource}\n recovery ${sourceRule.conflictRecovery}\n push selection ${sourceRule.pushSelection}\n\nSystem page projections:\n${
1116
1151
  page.workspace.systemPages.map((systemPage) =>
1117
- ` ${systemPage.path.padEnd(9)} ${systemPage.mode}; ${systemPage.projection}.\n${" ".repeat(12)}${systemPage.unchangedPush} ${systemPage.approval}\n${" ".repeat(12)}${systemPage.guidance}`
1152
+ ` ${systemPage.path.padEnd(9)} ${systemPage.mode}; ${systemPage.projection}.\n${
1153
+ " ".repeat(12)
1154
+ }${systemPage.unchangedPush} ${systemPage.approval}\n${" ".repeat(12)}${systemPage.guidance}`
1118
1155
  ).join("\n")
1119
1156
  }\n home editable; ${page.workspace.systemHome}\n\nMarkdown front matter:\n${
1120
1157
  markdownFormat.metadata.map(formatMetadata).join("\n")
@@ -1127,18 +1164,31 @@ export function formatReferenceResult(result) {
1127
1164
  }\n\nSection photo background (closed object):\n${
1128
1165
  page.sections.background.fields.map((field) => formatRegistryField(field)).join("\n")
1129
1166
  }\n delivery URLs ${page.sections.background.deliveryUrlPolicy}\n compact behavior ${page.sections.background.compactBehavior}\n\nScrim opacity mapping:\n${
1130
- Object.entries(page.sections.background.scrimOpacityByValue).map(([name, opacity]) => ` ${name.padEnd(20)} ${opacity}`).join("\n")
1167
+ Object.entries(page.sections.background.scrimOpacityByValue).map(([name, opacity]) =>
1168
+ ` ${name.padEnd(20)} ${opacity}`
1169
+ ).join("\n")
1131
1170
  }\n\nResponsive background example:\n${page.sections.background.markdownExample}\n\nSection decoration (closed object):\n${
1132
1171
  page.sections.decoration.fields.map((field) => formatRegistryField(field)).join("\n")
1133
1172
  }\n delivery URLs ${page.sections.decoration.deliveryUrlPolicy}\n\nTint mapping:\n${
1134
- Object.entries(page.sections.decoration.tintTokens).map(([name, token]) => ` ${name.padEnd(20)} ${token}`).join("\n")
1135
- }\n\nTransparent-mask example:\n${page.sections.decoration.markdownExample}\n\nMask guidance: ${
1136
- page.sections.decoration.maskGuidance
1137
- }\nWarning: ${page.sections.decoration.opaqueWarning}\n\nRoot defaults and placement:\n${
1173
+ Object.entries(page.sections.decoration.tintTokens).map(([name, token]) => ` ${name.padEnd(20)} ${token}`)
1174
+ .join("\n")
1175
+ }\n\nTransparent-mask example:\n${page.sections.decoration.markdownExample}\n\nMask guidance: ${page.sections.decoration.maskGuidance}\nWarning: ${page.sections.decoration.opaqueWarning}\n\nRoot defaults and placement:\n${
1138
1176
  page.sections.placementMatrix.map(formatPlacement).join("\n")
1139
- }\n\nSemantic tables:\n placement document root or directly inside a top-level section; wide/full-well measure\n rows exactly ${page.tables.structure.headerRows} header row, then ${page.tables.limits.dataRows.min} through ${page.tables.limits.dataRows.max} data rows\n columns ${page.tables.limits.columns.min} through ${page.tables.limits.columns.max}; delimiter and every row must match the header width\n caption optional non-empty plain text; maximum ${page.tables.limits.captionScalars.max} Unicode scalar values; syntax ${page.tables.markdown.captionSyntax}\n caption guidance ${page.tables.captionGuidance}\n cells exactly ${page.tables.structure.paragraphChildrenPerCell} paragraph; headers need non-whitespace text; data cells may be empty; maximum ${page.tables.limits.cellTextScalars.max} text scalars\n inline ${page.tables.structure.inlineNodes.join(", ")}; marks ${page.tables.structure.marks.join(", ")}\n pipes outer pipes ${page.tables.markdown.outerPipes}; write a literal pipe as ${page.tables.markdown.literalPipeEscape}\n spans unsupported: ${page.tables.structure.unsupportedSpanAttrs.join(", ")}\n alignment ${page.tables.markdown.alignment}; delimiter cells contain hyphens only\n termination a blank line ends the table before another block\n\nRate table example:\n${page.tables.examples.rateTable}\n\nComparison table example:\n${page.tables.examples.comparisonTable}\n\nRejected alignment example (${CONTENT_ERROR_CODES.markdownTableAlignment}):\n${page.tables.examples.rejectedAlignment}\nRemove the ':' characters from the delimiter row.\n\nDirect ProseMirror table example:\n${JSON.stringify(page.tables.examples.proseMirror, null, 2)}\n\nTable error corrections:\n${
1177
+ }\n\nSemantic tables:\n placement document root or directly inside a top-level section; wide/full-well measure\n rows exactly ${page.tables.structure.headerRows} header row, then ${page.tables.limits.dataRows.min} through ${page.tables.limits.dataRows.max} data rows\n columns ${page.tables.limits.columns.min} through ${page.tables.limits.columns.max}; delimiter and every row must match the header width\n caption optional non-empty plain text; maximum ${page.tables.limits.captionScalars.max} Unicode scalar values; syntax ${page.tables.markdown.captionSyntax}\n caption guidance ${page.tables.captionGuidance}\n cells exactly ${page.tables.structure.paragraphChildrenPerCell} paragraph; headers need non-whitespace text; data cells may be empty; maximum ${page.tables.limits.cellTextScalars.max} text scalars\n inline ${
1178
+ page.tables.structure.inlineNodes.join(", ")
1179
+ }; marks ${
1180
+ page.tables.structure.marks.join(", ")
1181
+ }\n pipes outer pipes ${page.tables.markdown.outerPipes}; write a literal pipe as ${page.tables.markdown.literalPipeEscape}\n spans unsupported: ${
1182
+ page.tables.structure.unsupportedSpanAttrs.join(", ")
1183
+ }\n alignment ${page.tables.markdown.alignment}; delimiter cells contain hyphens only\n termination a blank line ends the table before another block\n\nRate table example:\n${page.tables.examples.rateTable}\n\nComparison table example:\n${page.tables.examples.comparisonTable}\n\nRejected alignment example (${CONTENT_ERROR_CODES.markdownTableAlignment}):\n${page.tables.examples.rejectedAlignment}\nRemove the ':' characters from the delimiter row.\n\nDirect ProseMirror table example:\n${
1184
+ JSON.stringify(page.tables.examples.proseMirror, null, 2)
1185
+ }\n\nTable error corrections:\n${
1140
1186
  page.tables.corrections.map((entry) => ` ${entry.code.padEnd(34)} ${entry.correction}`).join("\n")
1141
- }\n\nInline facts:\n placement document root or directly inside a top-level section; ${page.inlineFacts.placement.measure} measure\n items ${page.inlineFacts.attrs.items.minItems} through ${page.inlineFacts.attrs.items.maxItems}; closed objects in ${page.inlineFacts.attrs.items.itemFieldOrder.join(", ")} order\n value ${page.inlineFacts.valuePolicy}\n label ${page.inlineFacts.labelPolicy}\n url ${page.inlineFacts.urlPolicy}\n Markdown fenced ${page.inlineFacts.markdown.fence} block whose body is a JSON array\n\nInline-facts Markdown example:\n${page.inlineFacts.markdown.example}\n\nDirect ProseMirror inlineFacts example:\n${JSON.stringify(page.inlineFacts.examples.proseMirror, null, 2)}\n\nRaw HTML: ${page.document.rawHtml.default} by default. For a tracked ${page.document.rawHtml.trackedProseMirror.format} document only: ${page.document.rawHtml.trackedProseMirror.behavior} Opt in with ${page.document.rawHtml.trackedProseMirror.optIn}. For ${page.document.rawHtml.markdown.format}, ${page.document.rawHtml.markdown.behavior} ${page.document.rawHtml.warning}\n\nComponents:\n${
1187
+ }\n\nInline facts:\n placement document root or directly inside a top-level section; ${page.inlineFacts.placement.measure} measure\n items ${page.inlineFacts.attrs.items.minItems} through ${page.inlineFacts.attrs.items.maxItems}; closed objects in ${
1188
+ page.inlineFacts.attrs.items.itemFieldOrder.join(", ")
1189
+ } order\n value ${page.inlineFacts.valuePolicy}\n label ${page.inlineFacts.labelPolicy}\n url ${page.inlineFacts.urlPolicy}\n Markdown fenced ${page.inlineFacts.markdown.fence} block whose body is a JSON array\n\nInline-facts Markdown example:\n${page.inlineFacts.markdown.example}\n\nDirect ProseMirror inlineFacts example:\n${
1190
+ JSON.stringify(page.inlineFacts.examples.proseMirror, null, 2)
1191
+ }\n\nRaw HTML: ${page.document.rawHtml.default} by default. For a tracked ${page.document.rawHtml.trackedProseMirror.format} document only: ${page.document.rawHtml.trackedProseMirror.behavior} Opt in with ${page.document.rawHtml.trackedProseMirror.optIn}. For ${page.document.rawHtml.markdown.format}, ${page.document.rawHtml.markdown.behavior} ${page.document.rawHtml.warning}\n\nComponents:\n${
1142
1192
  page.components.map((component) => ` ${component.type.padEnd(16)} ${component.summary}`).join("\n")
1143
1193
  }\n\nWorkflow:\n${
1144
1194
  page.workflow.map((step) => ` ${step.command}: ${step.result}`).join("\n")
@@ -0,0 +1,145 @@
1
+ import { getSiteRedirectMap, mintStagingPreviewHandoff } from "./api.js";
2
+ import { sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
3
+
4
+ const COOKIE = "__Host-taproot_staging_preview";
5
+ const CHECK = "__taproot_preview_check";
6
+ const TIMEOUT = 5_000;
7
+ const REPORT_BYTES = 28_000;
8
+
9
+ async function request(client, url, cookie, signal) {
10
+ const response = await client.fetch(url, {
11
+ method: "GET",
12
+ redirect: "manual",
13
+ signal: AbortSignal.any([
14
+ client.timeoutSignal(TIMEOUT),
15
+ ...(signal ? [signal] : []),
16
+ ...(client.signal ? [client.signal] : []),
17
+ ]),
18
+ headers: {
19
+ accept: "text/html",
20
+ "user-agent": "taproot-site/staging-redirect-check",
21
+ ...(cookie ? { cookie } : {}),
22
+ },
23
+ });
24
+ await response.body?.cancel().catch(() => {});
25
+ return response;
26
+ }
27
+
28
+ function unavailable() {
29
+ return new SiteAuthoringError("staging.gate_unavailable", "The staging gate could not establish authorized access.");
30
+ }
31
+
32
+ /** Consume only the checker's handoff; never forward a platform bearer or follow a redirect. */
33
+ async function authorize(client, handoff, signal) {
34
+ const response = await request(client, handoff.url, undefined, signal);
35
+ const origin = new URL(handoff.stagingUrl).origin;
36
+ const checkUrl = `${origin}/?${CHECK}=1`;
37
+ const cookies = response.headers.getSetCookie?.() ?? [response.headers.get("set-cookie") ?? ""];
38
+ const matching = cookies.filter((value) => value.startsWith(`${COOKIE}=`));
39
+ if (response.status !== 302 || response.headers.get("location") !== checkUrl || matching.length !== 1) {
40
+ throw unavailable();
41
+ }
42
+ const match = new RegExp(`^${COOKIE}=([A-Za-z0-9_-]{43});`).exec(matching[0]);
43
+ if (!match || !/;\s*Secure(?:;|$)/i.test(matching[0]) || !/;\s*HttpOnly(?:;|$)/i.test(matching[0])) {
44
+ throw unavailable();
45
+ }
46
+ const cookie = `${COOKIE}=${match[1]}`;
47
+ const checked = await request(client, checkUrl, cookie, signal);
48
+ if (checked.status !== 302 || checked.headers.get("location") !== `${origin}/`) throw unavailable();
49
+ return cookie;
50
+ }
51
+
52
+ function safeLocation(value) {
53
+ if (value === null) return "";
54
+ // The edge could be misconfigured. Never reflect handoff credentials from a
55
+ // redirect response into JSON, stderr or Actions, even if the check fails.
56
+ if (
57
+ value.length > 4096 || /[\u0000-\u001f\u007f-\u009f]/u.test(value)
58
+ || /__taproot_preview_|[?&]handoff=/iu.test(value)
59
+ ) return "[withheld]";
60
+ return sanitizeDiagnostic(value, "");
61
+ }
62
+
63
+ /** Check the current redirect-map revision at the real edge from the Node CLI. */
64
+ export async function checkStagingRedirects(client, siteId, onProgress, now) {
65
+ const map = await getSiteRedirectMap(client, siteId);
66
+ const handoff = await mintStagingPreviewHandoff(client, siteId, { now });
67
+ const signal = client.timeoutSignal(90_000);
68
+ const cookie = await authorize(client, handoff, signal);
69
+ const origin = new URL(handoff.stagingUrl).origin;
70
+ const rows = new Array(map.entries.length);
71
+ let next = 0;
72
+ await Promise.all(Array.from({ length: Math.min(8, map.entries.length) }, async () => {
73
+ while (next < map.entries.length) {
74
+ const index = next++;
75
+ const entry = map.entries[index];
76
+ if (
77
+ !entry.path.startsWith("/") || entry.path.startsWith("//") || entry.path.includes("\\")
78
+ || /[?#\u0000-\u001f\u007f]/u.test(entry.path)
79
+ ) throw unavailable();
80
+ const url = new URL(entry.path, `${origin}/`);
81
+ if (
82
+ url.origin !== origin || url.pathname !== entry.path || url.username || url.password || url.hash
83
+ || [...url.searchParams.keys()].some((key) => key.startsWith("__taproot_preview_"))
84
+ ) throw unavailable();
85
+ let row;
86
+ try {
87
+ const response = await request(client, url.href, cookie, signal);
88
+ const location = safeLocation(response.headers.get("location"));
89
+ const expectedLocation = entry.kind === "gone" ? "" : entry.target;
90
+ // Compare URL semantics while retaining the literal Location evidence.
91
+ const targetMatches = entry.kind === "gone"
92
+ ? location === ""
93
+ : location !== "[withheld]" && location !== ""
94
+ && new URL(location, url).href === new URL(expectedLocation, url).href;
95
+ row = {
96
+ path: entry.path,
97
+ status: response.status,
98
+ location,
99
+ expectedStatus: entry.status,
100
+ matches: response.status === entry.status && targetMatches,
101
+ };
102
+ } catch {
103
+ row = {
104
+ path: entry.path,
105
+ status: 0,
106
+ location: "",
107
+ expectedStatus: entry.status,
108
+ matches: false,
109
+ error: "request_failed",
110
+ };
111
+ }
112
+ rows[index] = row;
113
+ onProgress(
114
+ `${sanitizeDiagnostic(row.path)} → ${row.status || "unavailable"} → ${row.location || "(no Location)"}`,
115
+ );
116
+ }
117
+ }));
118
+ const latest = await getSiteRedirectMap(client, siteId);
119
+ const revisionUnchanged = latest.revision === map.revision;
120
+ const failed = rows.filter((row) => !row.matches).length;
121
+ const items = [];
122
+ let bytes = 2;
123
+ // Include failures first when a large map exceeds the JSON output budget.
124
+ for (const row of [...rows.filter((row) => !row.matches), ...rows.filter((row) => row.matches)]) {
125
+ const size = Buffer.byteLength(JSON.stringify(row), "utf8") + 1;
126
+ if (bytes + size > REPORT_BYTES) break;
127
+ items.push(row);
128
+ bytes += size;
129
+ }
130
+ return {
131
+ routeCheck: "resolved",
132
+ stagingUrl: handoff.stagingUrl,
133
+ redirects: {
134
+ revision: map.revision,
135
+ revisionUnchanged,
136
+ total: rows.length,
137
+ checked: rows.length,
138
+ matched: rows.length - failed,
139
+ failed,
140
+ verified: revisionUnchanged && failed === 0,
141
+ items,
142
+ ...(items.length < rows.length ? { itemsTruncated: true } : {}),
143
+ },
144
+ };
145
+ }
@@ -1,9 +1,4 @@
1
- import {
2
- encodeTheme,
3
- LIGHTNESS_KEYS,
4
- SEMANTIC_COLOR_NAMES,
5
- validateThemePair,
6
- } from "@taprootio/espalier/shared/theme";
1
+ import { encodeTheme, LIGHTNESS_KEYS, SEMANTIC_COLOR_NAMES, validateThemePair } from "@taprootio/espalier/shared/theme";
7
2
 
8
3
  import { hasControlCharacter, sanitizeDiagnostic, SiteAuthoringError } from "./errors.js";
9
4
 
@@ -138,6 +133,30 @@ function hasThemePath(theme, path) {
138
133
  return true;
139
134
  }
140
135
 
136
+ /** Collect absent contract keys without treating present invalid values as omissions. */
137
+ export function missingThemeFields(theme, scheme) {
138
+ if (!isPlainObject(theme)) return [];
139
+ const fields = REQUIRED_THEME_PROPERTIES
140
+ .filter((property) => !Object.hasOwn(theme, property))
141
+ .map((property) => `${scheme}Theme.${property}`);
142
+ for (const path of REQUIRED_THEME_PATHS) {
143
+ const segments = path.split(".");
144
+ // A missing root property is already listed. A present null, array, or
145
+ // scalar remains a value-validation fault, not a request for a newer key.
146
+ if (!Object.hasOwn(theme, segments[0])) continue;
147
+ let current = theme;
148
+ for (const segment of segments) {
149
+ if (!isPlainObject(current)) break;
150
+ if (!Object.hasOwn(current, segment)) {
151
+ fields.push(`${scheme}Theme.${path}`);
152
+ break;
153
+ }
154
+ current = current[segment];
155
+ }
156
+ }
157
+ return fields;
158
+ }
159
+
141
160
  function requireBoundedOpenMaps(theme, scheme) {
142
161
  for (const property of ["anchors", "contexts", "dataRamps"]) {
143
162
  const collection = theme[property];
@@ -155,9 +174,7 @@ function requireBoundedOpenMaps(theme, scheme) {
155
174
  Object.keys(collection).some((key) => [...key].length > MAXIMUM_THEME_OPEN_MAP_KEY_SCALARS)
156
175
  || Object.values(collection).some((anchor) =>
157
176
  isPlainObject(anchor)
158
- && Object.keys(anchor).some((key) =>
159
- key !== "color" && [...key].length > MAXIMUM_THEME_OPEN_MAP_KEY_SCALARS
160
- )
177
+ && Object.keys(anchor).some((key) => key !== "color" && [...key].length > MAXIMUM_THEME_OPEN_MAP_KEY_SCALARS)
161
178
  )
162
179
  )
163
180
  ) {
@@ -302,9 +319,11 @@ export function validateAndEncodeThemePair(lightTheme, darkTheme) {
302
319
  refuseAgentStylesheets(darkTheme, "dark");
303
320
  const warnings = result.warnings
304
321
  .slice(0, MAXIMUM_THEME_WARNINGS)
305
- .map((warning) => [...sanitizeDiagnostic(warning, "Theme validation warning.")]
306
- .slice(0, MAXIMUM_THEME_WARNING_SCALARS)
307
- .join(""));
322
+ .map((warning) =>
323
+ [...sanitizeDiagnostic(warning, "Theme validation warning.")]
324
+ .slice(0, MAXIMUM_THEME_WARNING_SCALARS)
325
+ .join("")
326
+ );
308
327
  return {
309
328
  light,
310
329
  dark,