@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,4 +1,5 @@
1
1
  import {
2
+ changelogCommandSince,
2
3
  CLI_UPGRADE_COMMAND,
3
4
  CLI_UPGRADE_REFUSAL_FIELD,
4
5
  CLI_VERSION,
@@ -131,6 +132,11 @@ export function isBehindLatest(latestVersion, currentVersion = CLI_VERSION) {
131
132
  * `api.request_rejected`.
132
133
  */
133
134
  class CliOutdatedError extends SiteAuthoringError {
135
+ constructor(code, message, options, latestVersion) {
136
+ super(code, message, options);
137
+ this.latestVersion = latestVersion;
138
+ }
139
+
134
140
  refusalKind() {
135
141
  return REFUSAL_CLI_OUTDATED;
136
142
  }
@@ -146,7 +152,9 @@ export function cliOutdatedError(latestVersion) {
146
152
  `This CLI is version ${CLI_VERSION}, and the last Taproot sign-in exchange reported ${latestVersion} as the `
147
153
  + "latest published release. Taproot accepts only the latest, so every online verb is refused until this "
148
154
  + `package is upgraded. Upgrade with: ${CLI_UPGRADE_COMMAND}. If ${latestVersion} was published in the last `
149
- + "few minutes, npm may not serve it yet — wait a moment and retry the upgrade.",
155
+ + "few minutes, npm may not serve it yet — wait a moment and retry the upgrade. Once upgraded, run "
156
+ + `'${changelogCommandSince(CLI_VERSION)}' to see what changed.`,
150
157
  { field: CLI_UPGRADE_REFUSAL_FIELD },
158
+ latestVersion,
151
159
  );
152
160
  }
package/src/cli.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { normalizeStagingReviewPath } from "./api.js";
2
+ import { parseCliVersion } from "./cli-release.js";
1
3
  import { CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS, CAPABILITY_DESIGN } from "./capabilities.js";
2
4
  import { capabilitiesForSurface } from "./surface.js";
3
5
  import {
@@ -22,6 +24,7 @@ import {
22
24
  VERB_DELIVERY_CHECK,
23
25
  VERB_DEPLOY,
24
26
  VERB_STAGING_REVIEW,
27
+ VERB_CHANGELOG,
25
28
  VERB_ENV,
26
29
  VERB_FOOTER_PUSH,
27
30
  VERB_FORMS_PULL,
@@ -69,6 +72,7 @@ import {
69
72
  getDesignBlueprint,
70
73
  getFooterReference,
71
74
  getPageTypeReference,
75
+ getSettingsReference,
72
76
  getThemeReference,
73
77
  getWorkflowReference,
74
78
  listComponentTypeReferences,
@@ -118,7 +122,18 @@ const VERBS = Object.freeze([
118
122
  + "rather than one per run. A pulled workspace is checked offline against the pages (generated ones included, "
119
123
  + "so navigation to a section or tag page resolves), images, and baselines its manifests recorded at the pull. "
120
124
  + "A link whose address is only http(s):// plus its own text (left by older editor autolinking) is reported in "
121
- + "linkWarnings without refusing; pages push reports the same for the pages it sends. This proves local structure and semantics only. It does not prove "
125
+ + "linkWarnings without refusing; pages push reports the same for the pages it sends. "
126
+ + "The site checks that can run offline are reported in findings (a rule id, severity, file, and the value "
127
+ + "found, such as link.page_missing for a link to a page the workspace does not have) and never refuse a "
128
+ + "warning. For a free-form page they include the page-structure rules: seo.h1_missing when no heading, banner "
129
+ + "title or hero title is the page's h1, seo.h1_demoted when a # heading is published as an h2 because the "
130
+ + "banner's overlay text, a hero title or an earlier # heading already is the h1 (detail names which), "
131
+ + "seo.heading_renumbered and seo.heading_before_title for headings published at another level, "
132
+ + "a11y.image_alt_missing and a11y.link_text_empty; for any page seo.title_long and seo.description_missing. "
133
+ + "siteChecks.checkedInEditor names the rules only the page editor evaluates (a cover, a description field, or a description's length, which descriptionWarnings reports here); "
134
+ + "siteChecks.checkedBySite names the rules that need the site's own records (images, forms), which "
135
+ + "'status' reports under siteHealth. A redirect source the site would refuse names its rule in "
136
+ + "error.problems[].rule. This proves local structure and semantics only. It does not prove "
122
137
  + "authorization, live site ownership, concurrency, persisted round trips, or rendering: 'plan' checks a "
123
138
  + "workspace against the live site, and 'pages push --dry-run' does that for pages alone. "
124
139
  + `See '${CLI_BINARY_NAME} help fixture' for the fixture manifest contract and for the path of the complete `
@@ -200,6 +215,21 @@ const VERBS = Object.freeze([
200
215
  + "its value. The sign-in secret is never printed; the credential is named by id and display prefix, which are "
201
216
  + "what an owner needs to revoke it.",
202
217
  },
218
+ {
219
+ name: VERB_CHANGELOG,
220
+ tokens: ["changelog"],
221
+ summary: "List what changed in each CLI release, offline.",
222
+ credentialFree: true,
223
+ configFree: true,
224
+ offline: true,
225
+ sinceOption: true,
226
+ note: "Only the latest CLI is accepted, so an upgrade can be forced at any time. After one, run "
227
+ + "'changelog --since <the version you upgraded from>' to see every release's note and the behavior it changed, "
228
+ + "and which help topics (help <topic>) to read again. With no --since it prints the installed release's entry. "
229
+ + "It reads the package's own record, needs no sign-in or site, and still runs when this CLI is outdated. "
230
+ + "The result lists entries newest first, each { version, note, behavior, help }; complete is false when "
231
+ + "--since starts before the oldest release the record covers. 'status' reports the installed release's note.",
232
+ },
203
233
  {
204
234
  name: VERB_ENV,
205
235
  tokens: ["env"],
@@ -308,7 +338,7 @@ const VERBS = Object.freeze([
308
338
  note:
309
339
  "Positional page paths narrow the push to those pages; with none, every workspace page is validated and sent. "
310
340
  + "A page names a form by key (formKey); the push looks each key up on the site, sends the form's id, and "
311
- + "refuses a key the site does not have or has archived (pages.form_unknown, pages.form_archived) before writing. "
341
+ + "refuses a key the site does not have or has archived (pages.form_unknown, pages.form_archived) before writing, and says whether the key's forms/<key>.json is in the workspace. validate refuses the same key offline (rule form.key_unknown) when the workspace has a forms/ directory and no file in it is for that key. "
312
342
  + "A link in a page being sent whose address is only http(s):// plus its own text is reported in linkWarnings "
313
343
  + "(content.link_autolinked) without refusing. "
314
344
  + "A page is created without an author unless its source names one ('author:' in the front matter, or 'pages "
@@ -440,9 +470,12 @@ const VERBS = Object.freeze([
440
470
  capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
441
471
  tokens: ["redirects", "check"],
442
472
  summary: "Check the current redirect map through the authenticated staging edge.",
473
+ handoffOptions: true,
443
474
  note: "Reports each entry's path, HTTP status and Location in redirects.items, and redirects.verified, without "
444
475
  + "following redirects. A redirect is live only with the release that carries it, so this reads the staged release. "
445
- + "The result includes a fresh single-use staging handoff in stagingPreview.url; keep it private.",
476
+ + "The check authenticates with its own handoff and never reports it. The result carries no review URL unless "
477
+ + "you pass --handoff, which adds a fresh single-use one in stagingPreview.url (keep it private); "
478
+ + "'staging review' mints one at any time.",
446
479
  },
447
480
  {
448
481
  name: VERB_REDIRECTS_PULL,
@@ -589,11 +622,19 @@ const VERBS = Object.freeze([
589
622
  tokens: ["deploy"],
590
623
  summary: "Deploy to staging, or promote staging to production.",
591
624
  target: true,
592
- note: "deploy --staging returns a single-use review handoff in stagingPreview.url, on a managed Docs site too, "
593
- + "where it stages settings only: open the URL once, then switch the site's theme toggle to review both "
594
- + "schemes before deploy --production. If no handoff could be minted, stagingPreview.reason names why and "
625
+ handoffOptions: true,
626
+ note: "deploy --staging does not report a review URL, because the URL is a live single-use credential and this "
627
+ + "JSON is often saved to logs. Run 'staging review' to mint one, or pass --handoff to get a fresh "
628
+ + "single-use one in stagingPreview.url (on a managed Docs site too, where it stages settings only): open it "
629
+ + "once, then switch the site's theme toggle to review both schemes before deploy --production. Without "
630
+ + "--handoff, stagingPreview.reason is staging.handoff_not_requested and stagingPreview.recovery names the "
631
+ + "command. If a requested handoff could not be minted, stagingPreview.reason names why and "
595
632
  + "stagingPreview.recovery the next step; 'staging review' mints another. On a Standard site the result also "
596
- + "carries the automatic redirect check: routeCheck and redirects (see 'help redirects').",
633
+ + "carries the automatic redirect check: routeCheck and redirects (see 'help redirects'). The edge can keep "
634
+ + `serving the previous release for up to ${LIMITS.stagingPropagationCeilingSeconds} seconds after a deployment completes, so a Standard site's `
635
+ + "deploy --staging waits until staging serves the new release and reports stagingServed: state served, "
636
+ + "propagating (still the previous release at the bound) or unverified (the change cannot be attributed to this deployment, or the last read did not name the previous release), "
637
+ + "with the time to wait or the seconds waited.",
597
638
  },
598
639
  {
599
640
  name: VERB_PREVIEW_PAGE,
@@ -653,20 +694,23 @@ const VERBS = Object.freeze([
653
694
  capabilities: [CAPABILITY_DEPLOYMENTS],
654
695
  tokens: ["staging", "review"],
655
696
  summary: "Mint a fresh single-use review handoff for the site's staging host.",
697
+ reviewPath: true,
656
698
  note: "The handoff URL is reported only in the final JSON as stagingPreview.url, is single-use, and expires two "
657
- + "minutes after it is minted; keep it private. Open it once, then use the site's theme toggle to review both "
658
- + "schemes. This is the recovery when deploy --staging could not mint a handoff, on a managed Docs site too.",
699
+ + "minutes after it is minted; keep it private. --path <page> opens the review on that page instead of the "
700
+ + "home page: a page path as 'pull' records it ('about', '/posts/one'), optionally with a query. Open it once, then use the site's theme toggle to review both "
701
+ + "schemes. deploy --staging and redirects check report no review URL unless you pass --handoff, so this is "
702
+ + "the usual way to get one, on a managed Docs site too.",
659
703
  },
660
704
  {
661
705
  name: VERB_STATUS,
662
706
  surface: SURFACE_DOCS_PRESENTATION,
663
707
  // Readiness and the deployment log are Deployments; the image list is
664
- // Content, as is the broken-reference report.
708
+ // Content, as is the site-health read.
665
709
  capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
666
710
  tokens: ["status"],
667
711
  summary:
668
- "Report the platform authoring switch, CLI release, deployments, readiness, image processing, and broken references.",
669
- note: "Broken references list pages with missing image IDs or page paths in their latest editable body."
712
+ "Report the platform authoring switch, CLI release, deployments, readiness, image processing, and site health.",
713
+ note: "Site health lists the site checks' findings (a rule id, severity, page, and the value found) for the latest editable body of each page."
670
714
  + " Content permission is required; a refused or failed read fails status rather than reporting a clean site.",
671
715
  },
672
716
  ]);
@@ -733,7 +777,8 @@ credentials, and it expires 24 hours after it is issued.
733
777
 
734
778
  Verbs write one JSON result to stdout and human progress to stderr. validate,
735
779
  help, whoami, and env are offline and read-only. login, logout, sites, use,
736
- whoami, and env need no configuration and no site. The offline help family is
780
+ whoami, and env need no configuration and no site. So does changelog, which
781
+ also still runs when this CLI is outdated. The offline help family is
737
782
  human-readable by default; add --json for stable reference data. Exit codes: 0
738
783
  success, 1 failure, 2 usage fault.
739
784
 
@@ -817,6 +862,18 @@ function verbHelp(verb) {
817
862
  --production Promote the completed staging deployment to production.
818
863
  --allow-failed-preview Explicitly override the matching candidate's failed preview.`
819
864
  : "";
865
+ const handoffOption = verb.handoffOptions
866
+ ? `\n --handoff Also mint a fresh single-use review URL and report it in stagingPreview.url.
867
+ --no-handoff Report no review URL (the default); accepted so scripts can say so explicitly.`
868
+ : "";
869
+ const sinceOption = verb.sinceOption
870
+ ? "\n --since <version> List the releases after this one, up to the installed release."
871
+ : "";
872
+ const reviewPathOption = verb.reviewPath
873
+ ? "\n --path <page> Open the review on this page instead of the home page, such as 'about' or '/posts/one'."
874
+ : verb.handoffOptions
875
+ ? "\n --review-path <page> With --handoff, open the review URL on this page instead of the home page."
876
+ : "";
820
877
  const metaOptions = verb.metaOptions
821
878
  ? `\n --title <text> The page's title.
822
879
  --description <text> The page's description for search results and link previews; '' clears it.
@@ -907,7 +964,7 @@ function verbHelp(verb) {
907
964
  ${verb.summary}
908
965
  ${boundary}
909
966
 
910
- ${options}${targetOption}${deliveryTargetOption}${metaOptions}${authorOptions}${jsonOption}${dryRunOption}${planOption}${nameOption}${note}
967
+ ${options}${targetOption}${handoffOption}${reviewPathOption}${sinceOption}${deliveryTargetOption}${metaOptions}${authorOptions}${jsonOption}${dryRunOption}${planOption}${nameOption}${note}
911
968
  `;
912
969
  }
913
970
 
@@ -968,6 +1025,7 @@ function parseReferenceArguments(arguments_) {
968
1025
  || topic === "preview"
969
1026
  || topic === "theme"
970
1027
  || topic === "appearance"
1028
+ || topic === "settings"
971
1029
  || topic === "footer"
972
1030
  || topic === "fixture"
973
1031
  || topic === "redirects"
@@ -1070,6 +1128,8 @@ function referenceResult(parsed) {
1070
1128
  return { ...result, topic: "presentation", referenceKind: "theme", reference: getThemeReference() };
1071
1129
  case "appearance":
1072
1130
  return { ...result, topic: "presentation", referenceKind: "appearance", reference: getAppearanceReference() };
1131
+ case "settings":
1132
+ return { ...result, topic: "presentation", referenceKind: "settings", reference: getSettingsReference() };
1073
1133
  case "footer":
1074
1134
  return { ...result, topic: "presentation", referenceKind: "footer", reference: getFooterReference() };
1075
1135
  default:
@@ -1249,6 +1309,9 @@ function parseArguments(arguments_) {
1249
1309
  let propagationWaitSeconds;
1250
1310
  let browser;
1251
1311
  let allowFailedPreview = false;
1312
+ let handoff;
1313
+ let reviewPath;
1314
+ let since;
1252
1315
  let dryRun = false;
1253
1316
  let json = false;
1254
1317
  let keyName;
@@ -1314,6 +1377,42 @@ function parseArguments(arguments_) {
1314
1377
  allowFailedPreview = true;
1315
1378
  continue;
1316
1379
  }
1380
+ if (verb.handoffOptions && (argument === "--handoff" || argument === "--no-handoff")) {
1381
+ if (handoff !== undefined) {
1382
+ throw usageError(
1383
+ handoff === (argument === "--handoff") ? "cli.duplicate_option" : "cli.handoff_conflict",
1384
+ handoff === (argument === "--handoff")
1385
+ ? `${argument} may be supplied only once.`
1386
+ : "--handoff and --no-handoff cannot be used together.",
1387
+ );
1388
+ }
1389
+ handoff = argument === "--handoff";
1390
+ continue;
1391
+ }
1392
+ if ((verb.reviewPath && argument === "--path") || (verb.handoffOptions && argument === "--review-path")) {
1393
+ if (reviewPath !== undefined) {
1394
+ throw usageError("cli.duplicate_option", `${argument} may be supplied only once.`);
1395
+ }
1396
+ // An option-like value would otherwise become the page '/--json' and mint a handoff to a 404.
1397
+ const candidate = rest[index + 1];
1398
+ reviewPath = normalizeStagingReviewPath(typeof candidate === "string" && candidate.startsWith("--") ? "" : candidate);
1399
+ index += 1;
1400
+ continue;
1401
+ }
1402
+ if (verb.sinceOption && argument === "--since") {
1403
+ if (since !== undefined) throw usageError("cli.duplicate_option", "--since may be supplied only once.");
1404
+ const candidate = rest[index + 1];
1405
+ if (parseCliVersion(candidate) === undefined) {
1406
+ throw usageError(
1407
+ "changelog.since_invalid",
1408
+ "--since requires a version such as 0.20.0 (the version you upgraded from).",
1409
+ { field: "since" },
1410
+ );
1411
+ }
1412
+ since = candidate;
1413
+ index += 1;
1414
+ continue;
1415
+ }
1317
1416
  if (verb.dryRun && argument === "--dry-run") {
1318
1417
  if (dryRun) throw usageError("cli.duplicate_option", "--dry-run may be supplied only once.");
1319
1418
  dryRun = true;
@@ -1394,6 +1493,16 @@ function parseArguments(arguments_) {
1394
1493
  `${verb.tokens.join(" ")} requires exactly one of --staging or --production.`,
1395
1494
  );
1396
1495
  }
1496
+ if (verb.handoffOptions && reviewPath !== undefined && handoff !== true) {
1497
+ throw usageError(
1498
+ "cli.review_path_requires_handoff",
1499
+ "--review-path chooses the page a --handoff review URL opens on; pass --handoff with it, or use 'staging review --path'.",
1500
+ { field: "reviewPath" },
1501
+ );
1502
+ }
1503
+ if (handoff === true && verb.target && deployTarget === DEPLOY_TARGET_PRODUCTION) {
1504
+ throw usageError("cli.handoff_target", "--handoff applies only to deploy --staging.", { field: "handoff" });
1505
+ }
1397
1506
  if (verb.positionals === "fixturePath" && (positionals.length > 1 || (init && positionals.length !== 1))) {
1398
1507
  throw usageError(
1399
1508
  "validate.fixture_path_invalid",
@@ -1507,6 +1616,9 @@ function parseArguments(arguments_) {
1507
1616
  quiet,
1508
1617
  deployTarget,
1509
1618
  allowFailedPreview,
1619
+ ...(handoff === undefined ? {} : { handoff }),
1620
+ ...(reviewPath === undefined ? {} : { reviewPath }),
1621
+ ...(since === undefined ? {} : { since }),
1510
1622
  ...(deliveryUrl === undefined ? {} : { deliveryUrl }),
1511
1623
  ...(propagationWaitSeconds === undefined ? {} : { propagationWaitSeconds }),
1512
1624
  ...(browser === undefined ? {} : { browser }),
@@ -1599,6 +1711,9 @@ export async function runCli({
1599
1711
  configPath: parsed.configPath,
1600
1712
  deployTarget: parsed.deployTarget,
1601
1713
  allowFailedPreview: parsed.allowFailedPreview,
1714
+ ...(parsed.handoff === undefined ? {} : { handoff: parsed.handoff }),
1715
+ ...(parsed.reviewPath === undefined ? {} : { reviewPath: parsed.reviewPath }),
1716
+ ...(parsed.since === undefined ? {} : { since: parsed.since }),
1602
1717
  init: parsed.init,
1603
1718
  quiet: parsed.quiet,
1604
1719
  dryRun: parsed.dryRun,
package/src/constants.js CHANGED
@@ -106,6 +106,12 @@ export const VERB_STAGING_REVIEW = "staging review";
106
106
  export const VERB_SITES = "sites";
107
107
  export const VERB_USE = "use";
108
108
  export const VERB_WHOAMI = "whoami";
109
+ export const VERB_CHANGELOG = "changelog";
110
+
111
+ /** The command that lists what changed since a version, for the CLI to name after an upgrade (TR01225). */
112
+ export function changelogCommandSince(version) {
113
+ return `${CLI_BINARY_NAME} ${VERB_CHANGELOG} --since ${version}`;
114
+ }
109
115
  export const VERB_PLAN = "plan";
110
116
  export const VERB_APPLY = "apply";
111
117
  export const VERB_PLACES_SEARCH = "places search";
@@ -256,6 +262,19 @@ export const LIMITS = Object.freeze({
256
262
  deploymentMilliseconds: 30 * 60_000,
257
263
  previewMilliseconds: 5 * 60_000,
258
264
  pollIntervalMilliseconds: 2_000,
265
+ // How long after a staging deployment completes the edge can still serve the
266
+ // previous release (TR01236): up to a minute for Workers KV to propagate the
267
+ // route, plus the Worker's 30-second KV read cache and 30-second isolate cache
268
+ // (docs/operations/published-site-worker-runbook.md, "Route resolution
269
+ // freshness budget"). The wait is longer than the ceiling so one slow read does
270
+ // not turn a propagating release into a false warning.
271
+ stagingPropagationCeilingSeconds: 120,
272
+ stagingServedMilliseconds: 180_000,
273
+ stagingServedPollMilliseconds: 3_000,
274
+ // Two reads in a row, because each read lands on one cached copy of the route.
275
+ stagingServedConfirmations: 2,
276
+ // The review notice sits at the end of the page, so the whole document is read.
277
+ stagingServedPageBytes: 4 * 1024 * 1024,
259
278
  requestAttempts: 4,
260
279
  uploadAttempts: 4,
261
280
  diagnosticScalars: 2_000,
package/src/errors.js CHANGED
@@ -130,6 +130,8 @@ export class SiteAuthoringError extends Error {
130
130
  this.previewDiagnostic = normalizePreviewDiagnostic(options.previewDiagnostic);
131
131
  this.field = typeof options.field === "string" ? sanitizeDiagnostic(options.field, "") : undefined;
132
132
  this.status = typeof options.status === "string" ? sanitizeDiagnostic(options.status, "") : undefined;
133
+ // The site check rule (shared/site-check-rules.json) this refusal comes from, when it comes from one.
134
+ this.rule = typeof options.rule === "string" && /^[a-z0-9_.]+$/u.test(options.rule) ? options.rule : undefined;
133
135
  this.alternatives = Array.isArray(options.alternatives)
134
136
  ? options.alternatives
135
137
  .filter((value) => typeof value === "string")
@@ -191,6 +193,7 @@ export class SiteAuthoringError extends Error {
191
193
  ...(typeof problem.file === "string" ? { file: sanitizeDiagnostic(problem.file, "") } : {}),
192
194
  code: /^[a-z0-9_.-]+$/u.test(problem.code) ? problem.code : "site.failed",
193
195
  ...(typeof problem.field === "string" ? { field: sanitizeDiagnostic(problem.field, "") } : {}),
196
+ ...(typeof problem.rule === "string" && /^[a-z0-9_.]+$/u.test(problem.rule) ? { rule: problem.rule } : {}),
194
197
  message: sanitizeDiagnostic(problem.message),
195
198
  }));
196
199
  this.problemCount = list.length;
@@ -278,11 +278,13 @@ function richText(value, path, maximumLength, maximumParagraphs, knownPageResour
278
278
  if (text.length === 0) continue;
279
279
  totalLength += text.length;
280
280
  totalRuns += 1;
281
+ // A style flag that is off is left out, so a run carries only what it sets and a hand-written
282
+ // file matches what pull writes (TR01232).
281
283
  normalizedRuns.push({
282
284
  text,
283
- bold: boolean(run.bold, false, `${runPath}.bold`),
284
- italic: boolean(run.italic, false, `${runPath}.italic`),
285
- underline: boolean(run.underline, false, `${runPath}.underline`),
285
+ ...(boolean(run.bold, false, `${runPath}.bold`) ? { bold: true } : {}),
286
+ ...(boolean(run.italic, false, `${runPath}.italic`) ? { italic: true } : {}),
287
+ ...(boolean(run.underline, false, `${runPath}.underline`) ? { underline: true } : {}),
286
288
  ...(run.link === undefined || run.link === null
287
289
  ? {}
288
290
  : { link: inlineLink(run.link, `${runPath}.link`, knownPageResourceIds, tolerateStoredValues) }),
@@ -53,6 +53,28 @@ export async function readWorkspaceForms(workspaceDir, keys, validationOptions =
53
53
  return forms;
54
54
  }
55
55
 
56
+ /**
57
+ * The keys of the form files the workspace holds, read from file names alone (TR01081), or undefined
58
+ * when the folder cannot be read (a name outside the safe grammar, a link, too many files): the
59
+ * answer is only a hint for a refusal, so a workspace that cannot say changes nothing, and
60
+ * `forms validate` reports the folder's problem. A file whose name is not a form key is left out.
61
+ */
62
+ export async function workspaceFormKeys(workspaceDir) {
63
+ let files;
64
+ try {
65
+ files = await walkWorkspaceFiles(workspaceDir, FORMS_DIRECTORY, [FORM_FILE_EXTENSION]);
66
+ } catch (error) {
67
+ if (error instanceof SiteAuthoringError) return undefined;
68
+ throw error;
69
+ }
70
+ const keys = new Set();
71
+ for (const file of files) {
72
+ const key = file.slice(FORMS_DIRECTORY.length + 1, -FORM_FILE_EXTENSION.length);
73
+ if (!key.includes("/") && isFormKey(key)) keys.add(key);
74
+ }
75
+ return keys;
76
+ }
77
+
56
78
  /** A form name as the site compares it: trimmed, ignoring case (TR01216). */
57
79
  export function comparableFormName(name) {
58
80
  return String(name).trim().toLowerCase();
@@ -0,0 +1,76 @@
1
+ // Generated from shared/heading-outline.ts by scripts/sync-field-validation.mjs. Do not edit.
2
+ /**
3
+ * How a published page numbers its headings (TR00929, TR00946, TR00947).
4
+ *
5
+ * The generator renders a page with this state machine, and the page-structure
6
+ * checks in `page-structure-checks.ts` replay the same machine over a document
7
+ * to tell an author which element claims the page's `<h1>` and which headings
8
+ * the publisher renumbers. One owner keeps what the warning says in step with
9
+ * what ships. The generator's `component-renderer.ts` and the site-authoring
10
+ * package import copies of this file; `node scripts/sync-field-validation.mjs`
11
+ * writes them, so edit only this file.
12
+ *
13
+ * It is dependency-free so every consumer can carry a copy.
14
+ */
15
+ export function createHeadingOutlineState(options = {}) {
16
+ const titleOpen = options.titleOpen === true;
17
+ const parentLevel = options.parentLevel ?? 1;
18
+ return { level: parentLevel, titleOpen, titleClaimed: !titleOpen, parentLevel, open: [] };
19
+ }
20
+ // Not `Array.prototype.at`: the editor's TypeScript library predates it.
21
+ function lastOpen(state) {
22
+ return state.open[state.open.length - 1];
23
+ }
24
+ /**
25
+ * The level to render an author heading at (TR00946). It closes the open
26
+ * headings at its authored level or deeper, then sits one level below the
27
+ * heading still open, so an h3 straight after the page's h1 becomes an h2,
28
+ * and so do the h3s after it until the author's own h2. A free-form page's
29
+ * first author h1 is its title; any other h1 is demoted.
30
+ */
31
+ export function nextHeadingLevel(state, authored) {
32
+ if (!state)
33
+ return authored;
34
+ if (authored === 1 && !state.titleClaimed) {
35
+ recordFixedHeading(state, 1);
36
+ return 1;
37
+ }
38
+ while ((lastOpen(state)?.authored ?? 0) >= authored)
39
+ state.open.pop();
40
+ const level = Math.min(6, (lastOpen(state)?.level ?? state.parentLevel) + 1);
41
+ state.open.push({ authored, level });
42
+ state.level = level;
43
+ return level;
44
+ }
45
+ /** A heading whose level the page fixes, such as a hero title or CTA heading. */
46
+ export function recordFixedHeading(state, level) {
47
+ if (level === 1)
48
+ state.titleClaimed = true;
49
+ while ((lastOpen(state)?.level ?? 0) >= level)
50
+ state.open.pop();
51
+ state.open.push({ authored: level, level });
52
+ state.level = level;
53
+ }
54
+ /**
55
+ * Whether an Image Banner is the page's title: it opens a free-form page,
56
+ * shows an image, and carries overlay text. Without an image the overlay is
57
+ * not drawn, so the banner carries no heading.
58
+ */
59
+ export function bannerOpensPage(state, data) {
60
+ return state?.titleOpen === true
61
+ && Boolean(data.image?.src)
62
+ && typeof data.overlayText === "string"
63
+ && data.overlayText.trim() !== "";
64
+ }
65
+ /**
66
+ * The level of the heading a hero or CTA component fixes on the page, or
67
+ * undefined when the component carries none. A hero title is always an h1;
68
+ * a CTA heading is an h2.
69
+ */
70
+ export function componentFixedHeadingLevel(type, data) {
71
+ if (type === "hero-section" && data.title)
72
+ return 1;
73
+ if (type === "cta" && data.heading)
74
+ return 2;
75
+ return undefined;
76
+ }
package/src/index.js CHANGED
@@ -14,3 +14,4 @@ export {
14
14
  getFooterReference,
15
15
  getThemeReference,
16
16
  } from "./presentation-reference.js";
17
+ export { getSettingsReference } from "./setting-reference.js";
package/src/output.js CHANGED
@@ -3,12 +3,14 @@ import { constants as fsConstants } from "node:fs";
3
3
  import { lstat, open } from "node:fs/promises";
4
4
 
5
5
  import {
6
+ changelogCommandSince,
6
7
  CLI_BINARY_NAME,
7
8
  CLI_NAME,
8
9
  CLI_VERSION,
9
10
  GITHUB_OUTPUT_KEY_PREFIX,
10
11
  GITHUB_OUTPUT_RESULT_KEY,
11
12
  LIMITS,
13
+ REFUSAL_CLI_OUTDATED,
12
14
  REFUSAL_UNCLASSIFIED,
13
15
  RESULT_SCHEMA_VERSION,
14
16
  } from "./constants.js";
@@ -37,6 +39,7 @@ export function failureResult(error) {
37
39
  error: { code: error.code },
38
40
  };
39
41
  if (error.field) result.error.field = error.field;
42
+ if (error.rule) result.error.rule = error.rule;
40
43
  if (error.details?.length) result.error.details = error.details;
41
44
  if (error.status) result.error.status = error.status;
42
45
  if (Array.isArray(error.alternatives) && error.alternatives.length > 0) {
@@ -67,6 +70,12 @@ export function failureResult(error) {
67
70
  if (previewRecovery) result.error.preview = previewRecovery;
68
71
  const refusal = typeof error.refusalKind === "function" ? error.refusalKind() : undefined;
69
72
  if (typeof refusal === "string" && refusal !== REFUSAL_UNCLASSIFIED) result.error.refusal = refusal;
73
+ if (refusal === REFUSAL_CLI_OUTDATED) {
74
+ const latestVersion = typeof error.latestCliVersion === "function" ? error.latestCliVersion() : error.latestVersion;
75
+ if (typeof latestVersion === "string") result.error.latestVersion = latestVersion;
76
+ // What to run once the upgrade is done, so the agent need not diff the help topics.
77
+ result.error.changelogCommand = changelogCommandSince(CLI_VERSION);
78
+ }
70
79
  return result;
71
80
  }
72
81
 
@@ -1,4 +1,5 @@
1
1
  import { SiteAuthoringError } from "./errors.js";
2
+ import { DESCRIPTION_WARN_LENGTH } from "./page-structure-checks.js";
2
3
  import { boundedByBytes } from "./session.js";
3
4
  import { displayPagePath } from "./workspace.js";
4
5
 
@@ -7,7 +8,7 @@ import { displayPagePath } from "./workspace.js";
7
8
  * 160 characters search engines cut it off, so it is warned about; past 1000 it
8
9
  * is refused, here and by the API (TR01194).
9
10
  */
10
- export const DESCRIPTION_WARN_LENGTH = 160;
11
+ export { DESCRIPTION_WARN_LENGTH };
11
12
  export const DESCRIPTION_MAXIMUM_LENGTH = 1000;
12
13
  export const PAGE_DESCRIPTION_LONG = "pages.description_long";
13
14