@taprootio/site-authoring 0.20.0 → 0.22.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.
Files changed (47) hide show
  1. package/README.md +128 -29
  2. package/examples/riverbend-wellness/nav.json +3 -6
  3. package/examples/riverbend-wellness/pages/visit.md +1 -1
  4. package/package.json +2 -2
  5. package/src/api.js +118 -31
  6. package/src/changelog.js +48 -0
  7. package/src/changelog.json +83 -0
  8. package/src/cli-release.js +9 -1
  9. package/src/cli.js +129 -14
  10. package/src/constants.js +19 -0
  11. package/src/errors.js +3 -0
  12. package/src/footer-contract.js +5 -3
  13. package/src/forms-workspace.js +22 -0
  14. package/src/heading-outline.js +76 -0
  15. package/src/index.js +1 -0
  16. package/src/output.js +9 -0
  17. package/src/page-metadata.js +2 -1
  18. package/src/page-structure-checks.js +356 -0
  19. package/src/presentation-reference.js +41 -6
  20. package/src/problems.js +6 -1
  21. package/src/redirects-contract.js +50 -140
  22. package/src/reference-help.js +77 -33
  23. package/src/reserved-paths.js +4 -10
  24. package/src/setting-reference.js +438 -0
  25. package/src/settings-catalog.js +2 -3
  26. package/src/site-check-findings.js +188 -0
  27. package/src/site-check-rules.json +35 -0
  28. package/src/site-checks.js +353 -0
  29. package/src/staging-check.js +48 -9
  30. package/src/staging-served.js +320 -0
  31. package/src/theme-projection.js +28 -55
  32. package/src/theme-validation.js +2 -1
  33. package/src/transport.js +13 -0
  34. package/src/typed-page-reference.js +128 -4
  35. package/src/verbs/changelog.js +62 -0
  36. package/src/verbs/deploy.js +38 -10
  37. package/src/verbs/index.js +3 -0
  38. package/src/verbs/nav-push.js +49 -1
  39. package/src/verbs/pages-push.js +17 -5
  40. package/src/verbs/plan.js +2 -1
  41. package/src/verbs/pull.js +5 -1
  42. package/src/verbs/redirects-check.js +4 -1
  43. package/src/verbs/staging-review.js +1 -1
  44. package/src/verbs/status.js +35 -28
  45. package/src/verbs/validate.js +24 -0
  46. package/src/workspace-check.js +26 -1
  47. package/src/workspace.js +5 -0
@@ -1,5 +1,20 @@
1
1
  import { SiteAuthoringError } from "./errors.js";
2
- import { RUNTIME_MIRROR_PATH_REASON, isRuntimeMirrorPath } from "./reserved-paths.js";
2
+ import { RUNTIME_MIRROR_PATH_REASON } from "./reserved-paths.js";
3
+ import {
4
+ isRequestSpelling,
5
+ normalizeRedirectPath,
6
+ redirectSourceRule,
7
+ resolvesAway,
8
+ RULE_REDIRECT_SOURCE_GENERATED_FILE,
9
+ RULE_REDIRECT_SOURCE_INDEX_HTML,
10
+ RULE_REDIRECT_SOURCE_INTEGRATION_DATA,
11
+ RULE_REDIRECT_SOURCE_INVALID,
12
+ RULE_REDIRECT_SOURCE_NOT_STORABLE,
13
+ RULE_REDIRECT_SOURCE_RUNTIME_MIRROR,
14
+ RULE_REDIRECT_SOURCE_SHARE_LINK,
15
+ } from "./site-checks.js";
16
+
17
+ export { normalizeRedirectPath };
3
18
 
4
19
  /**
5
20
  * The redirect map's workspace contract (TR00702).
@@ -64,64 +79,14 @@ const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F-\u009F]/u;
64
79
  const ENTRY_KEYS = new Set(["path", "kind", "target", "status", "origin"]);
65
80
  const SHA256 = /^[0-9a-f]{64}$/u;
66
81
 
67
- // An empty interior segment, or a dot or slash hidden behind percent-encoding.
68
- // '//' is also where a protocol-relative URL hides, which is why the leading
69
- // case is covered by this rule rather than by a separate prefix check.
70
- const RESOLVABLE_SPELLING = /\/\/|%2e|%2f/iu;
71
-
72
- // A spelling no browser would send as a pathname: anything outside printable
73
- // ASCII, one of the characters a URL parser escapes on sight, or a '%' that is
74
- // not an escape.
75
- // No `i` flag: under Unicode case folding it would let U+212A (KELVIN SIGN)
76
- // and U+017F (LONG S) through `[^!-~]`, which the site refuses.
77
- const UNSENDABLE_SPELLING = /[^!-~]|["<>`{}]|%(?![0-9a-fA-F]{2})/u;
78
-
79
- /**
80
- * Whether a path is already written the way a browser sends a pathname.
81
- *
82
- * The published-site edge keys on `url.pathname`, which the URL parser has
83
- * already percent-encoded, and its `normalizePathname` decodes nothing — while
84
- * every rule here compares the string as written. So '/old%20page -> /old page'
85
- * is two different strings to loop detection and one path to the edge, which
86
- * matches '/old%20page', resolves the target with `new URL` back to
87
- * '/old%20page', and serves a permanent self-loop. A source written with a
88
- * literal space or a non-ASCII character is the same mismatch the other way,
89
- * stored under a key no request pathname ever matches.
90
- *
91
- * Refused rather than encoded here. URL escaping is not reproducibly canonical
92
- * between the API's .NET and the edge's WHATWG parser, so a spelling this pass
93
- * encoded would still not be the one the Worker computes and the layers would
94
- * go on comparing different strings. Write what a browser sends and every layer
95
- * compares it unchanged — and the byte bounds then measure that same spelling.
96
- */
97
- function isRequestSpelling(value) {
98
- return !UNSENDABLE_SPELLING.test(value);
99
- }
100
-
101
- /**
102
- * Whether a path names one thing to this pass and another wherever it is
103
- * resolved.
104
- *
105
- * Every rule here compares the string as written, and nothing downstream does:
106
- * the generator resolves a source with path.resolve, so '/x/../visit' writes
107
- * over the page rendered at '/visit', and the published-site edge resolves a
108
- * target with `new URL`, so '/a -> /x/../a' is the self-loop that loop
109
- * detection just passed. Percent-encoded dots and slashes are the same trick
110
- * spelled so a segment scan cannot see it, so they are refused wherever they
111
- * appear rather than decoded and re-examined.
112
- */
113
- function resolvesAway(value) {
114
- if (RESOLVABLE_SPELLING.test(value)) return true;
115
- return value.split("/").some((segment) => segment === "." || segment === "..");
116
- }
117
82
 
118
83
  /** Whether the value is a redirect-map revision as the server mints one. */
119
84
  export function isRedirectMapRevision(value) {
120
85
  return typeof value === "string" && SHA256.test(value);
121
86
  }
122
87
 
123
- function entryError(code, message, field) {
124
- return new SiteAuthoringError(code, `${field}: ${message}`, { field });
88
+ function entryError(code, message, field, rule) {
89
+ return new SiteAuthoringError(code, `${field}: ${message}`, { field, rule });
125
90
  }
126
91
 
127
92
  function isPlainObject(value) {
@@ -133,93 +98,32 @@ function utf8Length(value) {
133
98
  }
134
99
 
135
100
  /**
136
- * Normalizes a source path exactly as the published-site Worker's
137
- * `normalizePathname` does: require the leading slash, strip trailing slashes,
138
- * change nothing else. A `.html` suffix survives, which is the whole point —
139
- * `/faqs.html` is a real legacy URL and has to be representable. A spelling
140
- * some other layer would resolve away is refused rather than repaired (see
141
- * `resolvesAway`), and so is one no browser would send as a pathname (see
142
- * `isRequestSpelling`, which subsumes the control-character refusal).
101
+ * Why a source rule refuses a path, in the words the CLI shows. The rule ids are
102
+ * the site checks' (shared/site-check-rules.json); the decisions themselves are
103
+ * made by `redirectSourceRule` in the shared source, which the API's C#
104
+ * implementation also passes the same corpus for.
143
105
  */
144
- export function normalizeRedirectPath(value) {
145
- if (typeof value !== "string") return undefined;
146
- const candidate = value.trim();
147
- if (
148
- candidate === ""
149
- || /[?#\\]/u.test(candidate)
150
- || !isRequestSpelling(candidate)
151
- || resolvesAway(candidate)
152
- ) {
153
- return undefined;
154
- }
155
- const withSlash = candidate.startsWith("/") ? candidate : `/${candidate}`;
156
- const trimmed = withSlash.replace(/\/+$/u, "");
157
- return trimmed === "" ? "/" : trimmed;
158
- }
159
-
160
- // The root segment the published output reserves for integration data; the release
161
- // inventory leaves everything under it out.
162
- const RESERVED_INTEGRATION_DATA_SEGMENT = "integrations";
163
-
164
- // Root-level files the site generates. A redirect is a marker file inside the
165
- // published site (TR00968), so a source that names one has nowhere to live.
166
- const GENERATED_ROOT_FILES = Object.freeze(["404.html", "sitemap.xml", "robots.txt", "favicon.ico"]);
167
- // The longest decoded path segment a marker file name may have; the API rule and the
168
- // generator use the same 200 bytes.
169
- const MAX_MARKER_SEGMENT_BYTES = 200;
170
- const WINDOWS_DEVICE_STEMS = new Set(["con", "prn", "aux", "nul"]);
171
-
172
- function isWindowsDeviceSegment(segment) {
173
- const stem = segment.split(".", 1)[0].toLowerCase();
174
- return WINDOWS_DEVICE_STEMS.has(stem) || /^(?:com|lpt)[1-9]$/u.test(stem);
175
- }
106
+ const SOURCE_RULE_REASONS = Object.freeze({
107
+ [RULE_REDIRECT_SOURCE_INDEX_HTML]: () =>
108
+ "the platform already redirects every '…/index.html' address to its directory, so it cannot also be "
109
+ + "a redirect source, and the page or home file there is generated.",
110
+ [RULE_REDIRECT_SOURCE_INTEGRATION_DATA]: (first) =>
111
+ `'/${first}' is reserved for integration data, so nothing under it can be a redirect source.`,
112
+ [RULE_REDIRECT_SOURCE_SHARE_LINK]: () =>
113
+ "'/s/' is reserved for share links, so nothing under it can be a redirect source.",
114
+ [RULE_REDIRECT_SOURCE_RUNTIME_MIRROR]: () => RUNTIME_MIRROR_PATH_REASON,
115
+ [RULE_REDIRECT_SOURCE_GENERATED_FILE]: (first) =>
116
+ `'/${first}' is a file the site generates, so neither it nor anything under it can be a redirect source.`,
117
+ [RULE_REDIRECT_SOURCE_NOT_STORABLE]: () =>
118
+ "a redirect is stored as a file in the published site, and this path cannot be one (no dot-prefixed "
119
+ + "segments, reserved device names, '..', escaped percent signs, or segments over 200 bytes).",
120
+ });
176
121
 
177
- /**
178
- * Why a normalized source path cannot be a redirect, or undefined when it can.
179
- * A mirror of `SiteRedirectMapContract.SourceRefusalReason` on the server (the
180
- * API is the authority; both run the same test vectors): `…/index.html` is
181
- * answered by the platform for every page, the generator writes the files
182
- * named above, and the marker is stored under the decoded spelling, so a path
183
- * that cannot be stored once decoded (an escaped percent sign, a dot-prefixed
184
- * segment, a reserved device name) is refused. The site root is left to the
185
- * occupancy rule.
186
- */
187
- export function redirectSourceRefusalReason(path) {
188
- if (path === "/") return undefined;
189
- // Every rule reads the decoded spelling: it is the name the marker is stored
190
- // under, and the name the generator's own files use (`/%69ndex.html` is
191
- // `/index.html`).
192
- let decoded;
193
- try {
194
- decoded = path.replace(/^\/+|\/+$/gu, "").split("/").map(decodeURIComponent).join("/");
195
- } catch {
196
- return "it holds a percent escape that does not decode.";
197
- }
198
- const segments = decoded.split("/");
199
- const first = segments[0].toLowerCase();
200
- if (segments.some((segment) => segment.toLowerCase() === "index.html")) {
201
- return "the platform already redirects every '…/index.html' address to its directory, so it cannot also be "
202
- + "a redirect source, and the page or home file there is generated.";
203
- }
204
- if (first === RESERVED_INTEGRATION_DATA_SEGMENT) {
205
- return `'/${segments[0]}' is reserved for integration data, so nothing under it can be a redirect source.`;
206
- }
207
- if (isRuntimeMirrorPath(decoded)) return RUNTIME_MIRROR_PATH_REASON;
208
- if (GENERATED_ROOT_FILES.includes(first)) {
209
- return `'/${segments[0]}' is a file the site generates, so neither it nor anything under it can be a redirect source.`;
210
- }
211
- if (
212
- decoded.includes("..")
213
- || decoded !== decoded.normalize("NFC")
214
- || segments.some((segment) =>
215
- segment === "" || segment.startsWith(".") || /[%\\?#\u0000-\u001F\u007F-\u009F]/u.test(segment)
216
- || isWindowsDeviceSegment(segment) || new TextEncoder().encode(segment).length > MAX_MARKER_SEGMENT_BYTES
217
- )
218
- ) {
219
- return "a redirect is stored as a file in the published site, and this path cannot be one (no dot-prefixed "
220
- + "segments, reserved device names, '..', escaped percent signs, or segments over 200 bytes).";
221
- }
222
- return undefined;
122
+ /** The refusal for a normalized source: its site-check rule id and the reason, or undefined when it can be a redirect. */
123
+ export function redirectSourceRefusal(path) {
124
+ const refusal = redirectSourceRule(path);
125
+ if (refusal === undefined) return undefined;
126
+ return { ruleId: refusal.ruleId, reason: SOURCE_RULE_REASONS[refusal.ruleId](refusal.detail) };
223
127
  }
224
128
 
225
129
  function looksAbsolute(value) {
@@ -281,6 +185,7 @@ function validateEntry(value, index, seen) {
281
185
  + "or '..' segment, no empty segment, and no percent-encoded dot or slash. Write the percent-encoded "
282
186
  + "spelling a browser sends (/caf%C3%A9, /old%20page), never the raw one.",
283
187
  `${field}.path`,
188
+ RULE_REDIRECT_SOURCE_INVALID,
284
189
  );
285
190
  }
286
191
  // The site root is not refused here. A home page rename records an entry at
@@ -303,9 +208,14 @@ function validateEntry(value, index, seen) {
303
208
  `${field}.path`,
304
209
  );
305
210
  }
306
- const sourceRefusal = redirectSourceRefusalReason(path);
211
+ const sourceRefusal = redirectSourceRefusal(path);
307
212
  if (sourceRefusal !== undefined) {
308
- throw entryError("redirects.path_unpublishable", `'${path}': ${sourceRefusal}`, `${field}.path`);
213
+ throw entryError(
214
+ "redirects.path_unpublishable",
215
+ `'${path}': ${sourceRefusal.reason}`,
216
+ `${field}.path`,
217
+ sourceRefusal.ruleId,
218
+ );
309
219
  }
310
220
  // The marker is stored under the decoded spelling, so `/a%41` and `/aA` are one
311
221
  // file, and the second would silently replace the first.
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
 
3
- import { CLI_BINARY_NAME } from "./constants.js";
3
+ import { CLI_BINARY_NAME, LIMITS } from "./constants.js";
4
4
  import {
5
5
  canonicalizeComponentData,
6
6
  COMPONENT_TYPES,
@@ -64,9 +64,11 @@ import {
64
64
  REDIRECTS_FILE_NAME,
65
65
  } from "./redirects-contract.js";
66
66
  import { SETTINGS_GROUPS } from "./settings-catalog.js";
67
+ import { getSettingsReference } from "./setting-reference.js";
67
68
  import {
68
69
  formatTypedPageReference,
69
70
  getTypedPageReference,
71
+ LEGAL_PLATFORM_LINK_GUIDANCE,
70
72
  MARKDOWN_PAGE_TYPES,
71
73
  TYPED_PAGE_TYPES,
72
74
  } from "./typed-page-reference.js";
@@ -84,9 +86,9 @@ import {
84
86
  SYSTEM_PAGE_NOT_FOUND_PATH,
85
87
  } from "./workspace.js";
86
88
 
87
- export { getAppearanceReference, getFooterReference, getThemeReference };
89
+ export { getAppearanceReference, getFooterReference, getSettingsReference, getThemeReference };
88
90
 
89
- export const REFERENCE_VERSION = 37;
91
+ export const REFERENCE_VERSION = 38;
90
92
 
91
93
  // The date-limit examples `help forms` prints; tests run them through the form validator.
92
94
  export const FORM_DATE_WINDOW_FIELD = Object.freeze({
@@ -387,7 +389,7 @@ export function getDesignBlueprint(type) {
387
389
  return DESIGN_BLUEPRINTS.find((blueprint) => blueprint.type === type);
388
390
  }
389
391
  export const REFERENCE_TOPICS = Object.freeze([
390
- Object.freeze({ name: "pages", usage: `${CLI_BINARY_NAME} help pages`, summary: "List authorable page types: free-form, article, recipe, album, place-review." }),
392
+ Object.freeze({ name: "pages", usage: `${CLI_BINARY_NAME} help pages`, summary: "List page types: free-form, article, recipe, album, place-review, generated, and the read-only legal page." }),
391
393
  Object.freeze({
392
394
  name: "page",
393
395
  usage: `${CLI_BINARY_NAME} help page <page-type>`,
@@ -450,6 +452,11 @@ export const REFERENCE_TOPICS = Object.freeze([
450
452
  usage: `${CLI_BINARY_NAME} help appearance`,
451
453
  summary: "Map site presentation decisions to theme-push workspace fields.",
452
454
  }),
455
+ Object.freeze({
456
+ name: "settings",
457
+ usage: `${CLI_BINARY_NAME} help settings`,
458
+ summary: "Map every pulled site setting to its app control, say what it does and where it renders, and which verb writes it.",
459
+ }),
453
460
  Object.freeze({
454
461
  name: "footer",
455
462
  usage: `${CLI_BINARY_NAME} help footer`,
@@ -501,7 +508,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
501
508
  + "part of wp:post_date). Categories beyond the first, and all tags, have no Taproot equivalent (tags are "
502
509
  + "generated per page type): list them. The featured image becomes coverImage on typed pages, and the same image must also appear in the page's own content (put it at the top of the body), or the site refuses the cover; a free-form page "
503
510
  + "has none, so list it.",
504
- "Content. Convert the HTML to the Markdown subset. Keep headings from h2 to h4 (the title is the h1; deeper levels are refused, so make h5 and h6 an h4). Decode HTML entities (&amp;, &#8217;) in titles, excerpts, menu labels and term names; WordPress stores them encoded. An image "
511
+ "Content. Convert the HTML to the Markdown subset. Keep body headings from h2 to h4 (deeper levels are refused, so make h5 and h6 an h4). The h1 depends on the template: an article or place review prints its title as the h1, so its body starts at h2 (a recipe and an album print their titles too; their fixed level-2 headings are set out below); a free-form page prints no title, so open its Markdown with a '# ' line holding the page title (the first level-1 heading becomes the h1; see help page free-form). Decode HTML entities (&amp;, &#8217;) in titles, excerpts, menu labels and term names; WordPress stores them encoded. An image "
505
512
  + "must stand alone in its own block, so split paragraphs around images and lift images out of links; a link "
506
513
  + "to the full-size file is dropped, so list it. Tables need a header row and 2 to 12 columns, else write "
507
514
  + "a list and report it. Remove Gutenberg block comments and the more-tag marker. In a recipe, times and servings "
@@ -604,7 +611,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
604
611
  + "contexts and intents paired across schemes. Set appearance scalars (default scheme, header, logos, favicon) "
605
612
  + "in the appearance files: help appearance.",
606
613
  "Prepare media locally: PNG, JPEG, GIF and WebP raster files only, addressed relative to the workspace root. Make "
607
- + "the variants yourself with any image tool (a transparent logo, a @2x copy, a square favicon); there is no "
614
+ + "the variants yourself with any image tool (a transparent logo at the highest resolution you have, a square favicon); there is no "
608
615
  + "recolor, crop or SVG upload command. Upload each with media upload, then assign lightLogoId and darkLogoId "
609
616
  + "(and brand.faviconId) from the image ids .taproot-site-media.json records for those files, so each scheme carries the artwork that reads on its header.",
610
617
  `Validate offline: ${CLI_BINARY_NAME} validate checks the whole workspace at once — the complete pair, the `
@@ -623,10 +630,12 @@ const WORKFLOW_REFERENCES = Object.freeze({
623
630
  + "(theme.concurrent_modification) and nothing is written; keep copies of the edited files, pull, re-apply, "
624
631
  + "and push again. A lost response is safe to retry — the site answers applied=false when it already holds "
625
632
  + "the change set. footer push owns footer prose, links and imagery.",
626
- `Review on staging: ${CLI_BINARY_NAME} deploy --staging stages the presentation and returns a single-use review handoff `
627
- + "in stagingPreview.url (on a managed Docs site too). Open it once, then use the site's theme toggle to check "
628
- + "both schemes: text, headings, links and hover, actions and focus, the header logos, the favicon. When no "
629
- + "handoff could be minted, stagingPreview.reason says why and staging review mints another. A page draft is "
633
+ `Review on staging: ${CLI_BINARY_NAME} deploy --staging stages the presentation; it reports no review URL by default `
634
+ + `because the URL is a live credential, so run ${CLI_BINARY_NAME} staging review (or add --handoff to deploy) for `
635
+ + "a single-use handoff in stagingPreview.url (on a managed Docs site too). Open it once, then use the site's "
636
+ + "theme toggle to check both schemes: text, headings, links and hover, actions and focus, the header logos, the "
637
+ + "favicon. When a requested handoff could not be minted, stagingPreview.reason says why and staging review mints "
638
+ + "another (add --path <page> to open it on the page under review). A page draft is "
630
639
  + "not needed to review a presentation change; preview page renders one persisted draft and is a different tool.",
631
640
  `Promote only after review: ${CLI_BINARY_NAME} deploy --production promotes the completed staging deployment. `
632
641
  + "Never use production to discover what staging should have shown.",
@@ -650,7 +659,10 @@ const WORKFLOW_REFERENCES = Object.freeze({
650
659
  summary: "nav push replaces the complete tree in nav.json; item IDs are author-minted canonical lowercase UUIDs.",
651
660
  usage: `${CLI_BINARY_NAME} nav push`,
652
661
  details: Object.freeze([
653
- "nav.json is { siteId, navItems }, not a bare array.",
662
+ "nav.json is { siteId, navItems }, not a bare array. Defaults are left out: an item has no resourceId or "
663
+ + "externalUrl unless its kind uses one, and no children when it has none. pull and nav push both write that "
664
+ + "form (nav push rewrites the file after it saves), and either form is accepted on the way in, so a pull after "
665
+ + "a push shows no change you did not make.",
654
666
  "kind is NAV_ITEM_KIND_PAGE, NAV_ITEM_KIND_EXTERNAL_URL, or NAV_ITEM_KIND_GROUP_HEADER.",
655
667
  "PAGE uses resourceId from .taproot-site-manifest.json, never pageId or path.",
656
668
  "EXTERNAL_URL uses externalUrl; GROUP_HEADER has no target; every kind may carry children.",
@@ -664,7 +676,6 @@ const WORKFLOW_REFERENCES = Object.freeze({
664
676
  kind: "NAV_ITEM_KIND_PAGE",
665
677
  title: "Classes",
666
678
  resourceId: "<resourceId-from-manifest>",
667
- children: Object.freeze([]),
668
679
  })]),
669
680
  }),
670
681
  }),
@@ -688,9 +699,12 @@ const WORKFLOW_REFERENCES = Object.freeze({
688
699
  + "release does not carry the entry (yet); re-run redirects check after the deployment completes, before promoting. Requests are bounded to 90 seconds, eight at a time; "
689
700
  + "large JSON lists are truncated explicitly with failed entries first (redirects.itemsTruncated). Progress "
690
701
  + "output reports every entry on stderr; --quiet hides it.",
691
- "stagingPreview.url is a fresh, single-use two-minute handoff, minted after checks. It is emitted only "
692
- + "in final JSON, excluded from progress and GITHUB_OUTPUT; keep it private. The staging cookie lasts "
693
- + "five minutes and remains subject to current site/host/credential permission.",
702
+ "redirects check and deploy --staging report no review URL unless you pass --handoff; the check "
703
+ + "authenticates with its own handoff either way. With --handoff, stagingPreview.url is a fresh, single-use "
704
+ + "two-minute handoff, minted after checks. It is emitted only in final JSON, excluded from progress and "
705
+ + "GITHUB_OUTPUT; keep it private. Without it, stagingPreview.reason is staging.handoff_not_requested and "
706
+ + "'staging review' mints one. The staging cookie lasts five minutes and remains subject to current "
707
+ + "site/host/credential permission.",
694
708
  `${REDIRECTS_FILE_NAME} is { siteId, revision, entries }, not a bare array. 'redirects pull' writes it and `
695
709
  + "records the revision in .taproot-site-manifest.json; 'pull' writes it too.",
696
710
  "An entry is { path, kind, target, status }. kind is 'redirect' (the default) or 'gone'; a gone entry "
@@ -738,9 +752,10 @@ const WORKFLOW_REFERENCES = Object.freeze({
738
752
  + "purpose: a real migration list needs judgement about which old URLs still deserve a destination and "
739
753
  + "which are simply gone.",
740
754
  "Entries take effect on the next deploy, staging first. Each is a file inside that release, so it goes live "
741
- + "exactly when the release does: staging with the staged release, production when it is promoted. There is "
742
- + "no separate propagation delay; an entry a check does not see belongs to a release that is not being "
743
- + "served yet.",
755
+ + "exactly when the release does: staging with the staged release, production when it is promoted. The edge "
756
+ + `can keep serving the previous release for up to ${LIMITS.stagingPropagationCeilingSeconds} seconds after a deployment completes, so an entry a `
757
+ + "check does not see may belong to a release the edge has not switched to yet; deploy --staging waits for "
758
+ + "staging to serve the new release and reports stagingServed.",
744
759
  "A source cannot name a file the site generates: '/index.html' or any page's own 'index.html', '/404.html', "
745
760
  + "'/sitemap.xml', '/robots.txt', '/favicon.ico', or a path under the sitemap or robots names. Nothing under "
746
761
  + "'/taproot/<version>/' (for example '/taproot/5.0.62/manifest.json') can be a source either: the platform "
@@ -790,8 +805,9 @@ const WORKFLOW_REFERENCES = Object.freeze({
790
805
  + "Delivery also requires Taproot's rollout and email provider readiness; opt-in enables neither, and earlier submissions are not backfilled.",
791
806
  `definition is { fields, contact_field?, submit_label?, after_submit? } (contact_field only with several email fields). Each field is { id, type, label, hint?, ...constraints }; the closed `
792
807
  + `field types are ${FIELD_TYPES.join(", ")}. hint is optional helper text of up to ${MAX_HINT_LENGTH} `
793
- + "characters, drawn under the field on the published form; it is the only author-defined text printed there, so a required "
794
- + "field is not marked 'Required' and a visitor learns it from the error on submit. "
808
+ + "characters, drawn under the field on the published form. The published form marks a required field itself: its label "
809
+ + "ends with ' *' and a '* Required' legend sits above the fields, so do not write 'required' into a label or hint; the "
810
+ + "stored label stays exactly as written. "
795
811
  + `At most ${MAX_FIELDS} fields and ${MAX_SUBMISSION_BYTES} bytes `
796
812
  + "per submission. There are no author-supplied regular expressions: use 'format' from its closed list. "
797
813
  + "The exact constraints per type are in shared/field-schema.json, which 'forms validate' enforces by "
@@ -824,7 +840,7 @@ const WORKFLOW_REFERENCES = Object.freeze({
824
840
  + "a deploy whose release does not publish that page is refused, naming the form and the page. The path in "
825
841
  + "each release is the one the page has in that release, so the published form never asks the site where "
826
842
  + "to go. The page must be one you created, the home page included: a generated page (folder, tag or "
827
- + "archive index), the 404 page, a legal page, a profile home and an integration-managed page are refused as forms.page_not_allowed. The button text and the confirmation belong to the form, not to the page that places it: "
843
+ + "archive index), the 404 page, a legal page (help page legal), a profile home and an integration-managed page are refused as forms.page_not_allowed. The button text and the confirmation belong to the form, not to the page that places it: "
828
844
  + "the 'form' component takes only formKey.",
829
845
  "A form's contact address is the value of one email field, stored with each submission. That is what lets the "
830
846
  + "owner find and delete every row for a visitor who asks. With exactly one email field, that field is the "
@@ -881,9 +897,9 @@ const WORKFLOW_REFERENCES = Object.freeze({
881
897
  usage: `${CLI_BINARY_NAME} media upload [path...]`,
882
898
  details: Object.freeze([
883
899
  "Paths are relative to the configured workspace root, not the shell's current directory.",
884
- "PNG, JPEG, GIF, and WebP are accepted; retina names such as logo@2x.png and logo@3x.png are supported.",
900
+ "PNG, JPEG, GIF, and WebP are accepted. Each file uploads as its own image: a name such as logo@2x.png is only a filename, nothing pairs it with logo.png, and the platform generates the display sizes from the one file you assign (see help appearance for logos).",
885
901
  "For header logos prefer genuine transparent PNG/WebP, check contrast in both themes, and include a simplified square favicon. See help appearance for branding guidance.",
886
- "Prepare variants locally with any image tool before uploading: a transparent logo, its @2x copy, a scheme-specific dark logo when one asset does not read on both headers, and a square favicon. The CLI uploads what you give it; it has no recolor, crop, or SVG upload command.",
902
+ "Prepare variants locally with any image tool before uploading: a transparent logo at the highest resolution you have (never a 1x and a 2x pair: assign one file and the platform generates the sizes; keep it under the 32 MiB upload limit), a scheme-specific dark logo when one asset does not read on both headers, and a square favicon. The CLI uploads what you give it; it has no recolor, crop, or SVG upload command.",
887
903
  "Assign both schemes after uploading: lightLogoId and darkLogoId in settings/taproot-styles.json and brand.faviconId in settings/brand.json take the image ids .taproot-site-media.json records for those files; theme push writes them. See help walkthrough for the full design pass.",
888
904
  "Each result item reports file, imageId, deduplicated, width, height and processingState; the delivery URLs (src, urls) are in .taproot-site-media.json, not the result, so an upload of any size reports within its bound. media.total and videos.total count every file, and media.itemsTruncated or videos.itemsTruncated says that list stopped short.",
889
905
  "MP4, MOV, and WebM files are uploaded as videos (up to 250 MB each; the site's licence sets the cap, and the container is read from the file's bytes, not its name). The CLI reads each file with mediabunny. A file that is H.264 and AAC but not already an MP4 with its index first is rewritten as one without re-encoding; anything else is sent as it is and judged by the server. When the server refuses a file, its one-line message is printed as media.video_refused. A video is ready to play as soon as the upload is confirmed. The CLI sets no poster, because Node has no video decoder: the Videos page picks one automatically for a video uploaded there, and lets the owner choose another frame, so set a CLI-uploaded video's poster there, with its title and caption. The command reports videos.items: { file, videoId, title, caption, fileName, contentType, byteLength, deduplicated, durationMilliseconds, component }, where component.markdown is the component:video fence and component.block the componentBlock node that place it. Needs the site's licence to include video.",
@@ -891,8 +907,8 @@ const WORKFLOW_REFERENCES = Object.freeze({
891
907
  "In Markdown, name an uploaded image by its path (such as media/photo.webp) wherever an image goes: body images, coverImage, album images, component image fields and section images. pages push resolves each path through .taproot-site-media.json, which also keeps the src/urls delivery fields a .pm.json image record carries.",
892
908
  ]),
893
909
  example: Object.freeze({
894
- command: `${CLI_BINARY_NAME} media upload media/logo@2x.png`,
895
- markdownImage: "media/logo@2x.png",
910
+ command: `${CLI_BINARY_NAME} media upload media/logo.png`,
911
+ markdownImage: "media/logo.png",
896
912
  pmJsonImageRecord: Object.freeze({
897
913
  imageId: "<image-uuid>",
898
914
  src: "<preferred-delivery-url>",
@@ -913,11 +929,15 @@ const WORKFLOW_REFERENCES = Object.freeze({
913
929
  + "selected page's content until a newer matching preview is ready, the content changes, or the operator "
914
930
  + "explicitly supplies --allow-failed-preview. Pending retries do not clear a known failure; previews "
915
931
  + "remain optional and old snapshots without content identity do not block deployment.",
916
- "deploy --staging and redirects check return a separate fresh single-use staging handoff in "
917
- + "stagingPreview.url, after authenticating headless redirect checks. Handoffs never go to GITHUB_OUTPUT.",
918
- "On a managed Docs site deploy --staging stages settings only and still returns stagingPreview.url: open it "
919
- + "once, then use the site's theme toggle to review both schemes; no page draft is needed to preview a "
920
- + "presentation change. When no handoff can be minted the result carries stagingPreview.reason "
932
+ "deploy --staging and redirects check report no review URL by default, because it is a live single-use "
933
+ + "credential and their JSON is often saved to logs (stagingPreview.reason staging.handoff_not_requested). Run "
934
+ + "staging review for one, or pass --handoff to get a fresh single-use handoff in stagingPreview.url, minted "
935
+ + "after the headless redirect checks. staging review --path <page> (and --review-path <page> beside --handoff) "
936
+ + "opens the review on that page instead of the home page. Handoffs never go to GITHUB_OUTPUT.",
937
+ "On a managed Docs site deploy --staging stages settings only: run staging review (or deploy --staging "
938
+ + "--handoff) and open the URL once, then use the site's theme toggle to review both schemes; no page draft is "
939
+ + "needed to preview a presentation change. When a requested handoff can't be minted the result carries "
940
+ + "stagingPreview.reason "
921
941
  + "(staging.host_unavailable, staging.authority_denied, staging.surface_refused, or staging.handoff_unavailable) "
922
942
  + "and a recovery step; run staging review to mint another (it works on every surface). Never verify a theme "
923
943
  + "on production instead.",
@@ -1514,7 +1534,8 @@ const BACKGROUND_REFERENCE = backgroundReference();
1514
1534
  const FREE_FORM_REFERENCE = Object.freeze({
1515
1535
  type: "free-form",
1516
1536
  displayName: "Free-form page",
1517
- summary: "A page whose body is authored as validated ProseMirror, directly or through the supported Markdown subset.",
1537
+ summary:
1538
+ "A page whose body is authored as validated ProseMirror, directly or through the supported Markdown subset. It renders no automatic heading: open the body with a '# ' title line.",
1518
1539
  workspace: Object.freeze({
1519
1540
  directory: "pages/",
1520
1541
  sourceRule: Object.freeze({
@@ -1674,6 +1695,27 @@ const FREE_FORM_REFERENCE = Object.freeze({
1674
1695
  `Any other rel token, opener included, is refused (${CONTENT_ERROR_CODES.linkRel}); so is another target `
1675
1696
  + `(${CONTENT_ERROR_CODES.linkTarget}). The editor's old default, "noopener noreferrer nofollow", reads as no choice.`,
1676
1697
  }),
1698
+ // TR01220: a free-form page prints no title, so its first level-1 heading is the h1.
1699
+ headings: Object.freeze({
1700
+ rule:
1701
+ "A free-form page renders no automatic heading: its title is not printed on the page. "
1702
+ + "The first level-1 heading in the body becomes the page's h1, so open the Markdown with '# Page title'. A "
1703
+ + "structured page (article, recipe, album, place review) prints its title as the h1, so its body starts at '## '.",
1704
+ claimedFirst:
1705
+ "A hero-section with a title claims the h1, and so does an image-banner with overlay text that opens the page; "
1706
+ + "a '# ' heading after either is then not a second h1. A titled hero-section renders its own h1 wherever it sits, so a '# ' line before one gives the page two h1s: use one or the other. Without a hero or banner, write the '# ' heading yourself.",
1707
+ laterHeadings:
1708
+ "Any later '# ' heading renders as an h2, like the other top-level sections, never as a second h1, "
1709
+ + "and the other headings are renumbered so none skips a level.",
1710
+ depth: `The Markdown subset accepts levels 1 to 4; a deeper heading is refused (${CONTENT_ERROR_CODES.headingDepth}).`,
1711
+ example: "# Visit the studio\n\nWe are open every day.\n\n## Hours\n\nMonday to Friday, 6 AM to 7:30 PM.",
1712
+ }),
1713
+ // TR01224: the default for a policy written as a free-form page.
1714
+ legalAndPrivacyPages: Object.freeze({
1715
+ ...LEGAL_PLATFORM_LINK_GUIDANCE,
1716
+ choosing:
1717
+ `A policy that needs a version and an effective date is a legal page, which the CLI cannot author: see '${CLI_BINARY_NAME} help page legal'.`,
1718
+ }),
1677
1719
  componentNode: Object.freeze({
1678
1720
  type: "componentBlock",
1679
1721
  attrs: Object.freeze({
@@ -1889,7 +1931,7 @@ export function formatReferenceResult(result) {
1889
1931
  result.topics.map((topic) => ` ${topic.usage.padEnd(48)} ${topic.summary}`).join("\n")
1890
1932
  }\n\nAdd --json for stable machine-readable output.\n`;
1891
1933
  case "page-types":
1892
- return `Authorable page types:\n${
1934
+ return `Page types:\n${
1893
1935
  result.pageTypes.map((page) =>
1894
1936
  ` ${page.type.padEnd(16)} ${page.summary}\n${" ".repeat(19)}Help: ${page.helpCommand}`
1895
1937
  ).join("\n")
@@ -1933,7 +1975,9 @@ export function formatReferenceResult(result) {
1933
1975
  page.document.marks.join(", ")
1934
1976
  }\n\nLinks:\n markdown ${page.document.links.markdown}\n rel ${
1935
1977
  page.document.links.rel.join(", ")
1936
- }\n default ${page.document.links.default}\n in Markdown ${page.document.links.markdownRule}\n in .pm.json ${page.document.links.pmJson}\n refusals ${page.document.links.refusals}\n\nTop-level section container:\n${page.sections.example}\n\nSection attributes:\n${
1978
+ }\n default ${page.document.links.default}\n in Markdown ${page.document.links.markdownRule}\n in .pm.json ${page.document.links.pmJson}\n refusals ${page.document.links.refusals}\n\nHeadings and the h1:\n rule ${page.document.headings.rule}\n banner and hero ${page.document.headings.claimedFirst}\n later headings ${page.document.headings.laterHeadings}\n depth ${page.document.headings.depth}\n example\n${
1979
+ page.document.headings.example.split("\n").map((line) => ` ${line}`).join("\n")
1980
+ }\n\nLegal and privacy pages:\n linking Taproot ${page.document.legalAndPrivacyPages.rule}\n example ${page.document.legalAndPrivacyPages.example}\n versioned policies ${page.document.legalAndPrivacyPages.choosing}\n\nTop-level section container:\n${page.sections.example}\n\nSection attributes:\n${
1937
1981
  page.document.sectionNode.attrs.map(formatSectionAttr).join("\n")
1938
1982
  }\n\nSection photo background (closed object):\n${
1939
1983
  page.sections.background.fields.map((field) => formatRegistryField(field)).join("\n")
@@ -5,17 +5,11 @@
5
5
  * `/taproot/<semver>/...` (TR01144), and Cloudflare answers a matching request
6
6
  * before the Worker runs, so a page or redirect authored there could never be
7
7
  * reached. This is the one place the package owns that prefix; page push and
8
- * the redirect map both ask here. The API is the authority and mirrors the rule
9
- * in `PublishedOutputPaths.IsRuntimeMirrorPath`; both are pinned by the same
10
- * test vectors.
8
+ * the redirect map both ask here. The API is the authority
9
+ * (`PublishedOutputPaths.IsRuntimeMirrorPath`); both pass the site-check corpus.
11
10
  */
12
- const RUNTIME_MIRROR_PATH =
13
- /^taproot\/[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\/|$)/iu;
14
-
15
- /** Whether a site-relative path (no leading slash) is `taproot/<semver>` or under it. */
16
- export function isRuntimeMirrorPath(path) {
17
- return RUNTIME_MIRROR_PATH.test(path);
18
- }
11
+ // The rule itself lives in the shared site-check source, which the redirect source rules also read.
12
+ export { isRuntimeMirrorPath } from "./site-checks.js";
19
13
 
20
14
  export const RUNTIME_MIRROR_PATH_REASON =
21
15
  "'/taproot/<version>/' is reserved for the published-site runtime, which the platform answers before any page "