@taprootio/site-authoring 0.19.0 → 0.21.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 (43) hide show
  1. package/README.md +132 -28
  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 +71 -0
  8. package/src/cli-release.js +9 -1
  9. package/src/cli.js +128 -15
  10. package/src/constants.js +19 -0
  11. package/src/content/components.js +8 -5
  12. package/src/errors.js +3 -0
  13. package/src/footer-contract.js +5 -3
  14. package/src/form-references.js +103 -0
  15. package/src/forms-workspace.js +24 -0
  16. package/src/index.js +1 -0
  17. package/src/output.js +9 -0
  18. package/src/presentation-reference.js +41 -6
  19. package/src/problems.js +6 -1
  20. package/src/redirects-contract.js +50 -140
  21. package/src/reference-help.js +83 -36
  22. package/src/reserved-paths.js +4 -10
  23. package/src/setting-reference.js +438 -0
  24. package/src/site-check-findings.js +99 -0
  25. package/src/site-check-rules.json +22 -0
  26. package/src/site-checks.js +337 -0
  27. package/src/staging-check.js +48 -9
  28. package/src/staging-served.js +320 -0
  29. package/src/transport.js +13 -0
  30. package/src/typed-page-reference.js +128 -4
  31. package/src/typed-pages.js +1 -1
  32. package/src/verbs/changelog.js +62 -0
  33. package/src/verbs/deploy.js +38 -10
  34. package/src/verbs/forms-push.js +22 -1
  35. package/src/verbs/index.js +3 -0
  36. package/src/verbs/nav-push.js +49 -1
  37. package/src/verbs/pages-push.js +45 -1
  38. package/src/verbs/plan.js +4 -2
  39. package/src/verbs/pull.js +17 -3
  40. package/src/verbs/redirects-check.js +4 -1
  41. package/src/verbs/staging-review.js +1 -1
  42. package/src/verbs/status.js +35 -28
  43. package/src/verbs/validate.js +21 -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,12 @@ 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; siteChecks.checkedBySite names the rules that need the site's own records (images, forms), which "
129
+ + "'status' reports under siteHealth. A redirect source the site would refuse names its rule in "
130
+ + "error.problems[].rule. This proves local structure and semantics only. It does not prove "
122
131
  + "authorization, live site ownership, concurrency, persisted round trips, or rendering: 'plan' checks a "
123
132
  + "workspace against the live site, and 'pages push --dry-run' does that for pages alone. "
124
133
  + `See '${CLI_BINARY_NAME} help fixture' for the fixture manifest contract and for the path of the complete `
@@ -200,6 +209,21 @@ const VERBS = Object.freeze([
200
209
  + "its value. The sign-in secret is never printed; the credential is named by id and display prefix, which are "
201
210
  + "what an owner needs to revoke it.",
202
211
  },
212
+ {
213
+ name: VERB_CHANGELOG,
214
+ tokens: ["changelog"],
215
+ summary: "List what changed in each CLI release, offline.",
216
+ credentialFree: true,
217
+ configFree: true,
218
+ offline: true,
219
+ sinceOption: true,
220
+ note: "Only the latest CLI is accepted, so an upgrade can be forced at any time. After one, run "
221
+ + "'changelog --since <the version you upgraded from>' to see every release's note and the behavior it changed, "
222
+ + "and which help topics (help <topic>) to read again. With no --since it prints the installed release's entry. "
223
+ + "It reads the package's own record, needs no sign-in or site, and still runs when this CLI is outdated. "
224
+ + "The result lists entries newest first, each { version, note, behavior, help }; complete is false when "
225
+ + "--since starts before the oldest release the record covers. 'status' reports the installed release's note.",
226
+ },
203
227
  {
204
228
  name: VERB_ENV,
205
229
  tokens: ["env"],
@@ -239,7 +263,9 @@ const VERBS = Object.freeze([
239
263
  + "and announced on the human channel. New downloads and compared bodies are excluded. "
240
264
  + "A page Taproot generates (a tag, an archive, a place, a folder) is pulled as an update-only '.pm.json' "
241
265
  + "(template 'generated'): you edit its custom title, breadcrumb title, custom description and introduction, "
242
- + "and the manifest keeps the title and description the site reports.",
266
+ + "and the manifest keeps the title and description the site reports. "
267
+ + "A form a page places is written back by its key, the name 'forms/<key>.json' uses, though the site stores "
268
+ + "the form's id.",
243
269
  },
244
270
  {
245
271
  name: VERB_PAGES_META_SET,
@@ -305,6 +331,8 @@ const VERBS = Object.freeze([
305
331
  positionals: "pagePaths",
306
332
  note:
307
333
  "Positional page paths narrow the push to those pages; with none, every workspace page is validated and sent. "
334
+ + "A page names a form by key (formKey); the push looks each key up on the site, sends the form's id, and "
335
+ + "refuses a key the site does not have or has archived (pages.form_unknown, pages.form_archived) before writing. "
308
336
  + "A link in a page being sent whose address is only http(s):// plus its own text is reported in linkWarnings "
309
337
  + "(content.link_autolinked) without refusing. "
310
338
  + "A page is created without an author unless its source names one ('author:' in the front matter, or 'pages "
@@ -394,7 +422,7 @@ const VERBS = Object.freeze([
394
422
  capabilities: [CAPABILITY_CONTENT, CAPABILITY_DESIGN],
395
423
  tokens: ["plan"],
396
424
  summary: "Check the whole workspace against the live site and order what is left to send. Writes nothing.",
397
- note: "Runs every check the pushes run — each page against the site's pages, revisions and videos; media that "
425
+ note: "Runs every check the pushes run — each page against the site's pages, revisions, videos and forms; media that "
398
426
  + "pages reference and media upload has not sent; the theme, appearance and footer colors against the site's "
399
427
  + "presentation revision; the footer against its draft; navigation against the live page list — and lists "
400
428
  + "every problem with its area, file, code and field. It then orders the steps that remain: media upload, "
@@ -436,9 +464,12 @@ const VERBS = Object.freeze([
436
464
  capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
437
465
  tokens: ["redirects", "check"],
438
466
  summary: "Check the current redirect map through the authenticated staging edge.",
467
+ handoffOptions: true,
439
468
  note: "Reports each entry's path, HTTP status and Location in redirects.items, and redirects.verified, without "
440
469
  + "following redirects. A redirect is live only with the release that carries it, so this reads the staged release. "
441
- + "The result includes a fresh single-use staging handoff in stagingPreview.url; keep it private.",
470
+ + "The check authenticates with its own handoff and never reports it. The result carries no review URL unless "
471
+ + "you pass --handoff, which adds a fresh single-use one in stagingPreview.url (keep it private); "
472
+ + "'staging review' mints one at any time.",
442
473
  },
443
474
  {
444
475
  name: VERB_REDIRECTS_PULL,
@@ -585,11 +616,19 @@ const VERBS = Object.freeze([
585
616
  tokens: ["deploy"],
586
617
  summary: "Deploy to staging, or promote staging to production.",
587
618
  target: true,
588
- note: "deploy --staging returns a single-use review handoff in stagingPreview.url, on a managed Docs site too, "
589
- + "where it stages settings only: open the URL once, then switch the site's theme toggle to review both "
590
- + "schemes before deploy --production. If no handoff could be minted, stagingPreview.reason names why and "
619
+ handoffOptions: true,
620
+ note: "deploy --staging does not report a review URL, because the URL is a live single-use credential and this "
621
+ + "JSON is often saved to logs. Run 'staging review' to mint one, or pass --handoff to get a fresh "
622
+ + "single-use one in stagingPreview.url (on a managed Docs site too, where it stages settings only): open it "
623
+ + "once, then switch the site's theme toggle to review both schemes before deploy --production. Without "
624
+ + "--handoff, stagingPreview.reason is staging.handoff_not_requested and stagingPreview.recovery names the "
625
+ + "command. If a requested handoff could not be minted, stagingPreview.reason names why and "
591
626
  + "stagingPreview.recovery the next step; 'staging review' mints another. On a Standard site the result also "
592
- + "carries the automatic redirect check: routeCheck and redirects (see 'help redirects').",
627
+ + "carries the automatic redirect check: routeCheck and redirects (see 'help redirects'). The edge can keep "
628
+ + `serving the previous release for up to ${LIMITS.stagingPropagationCeilingSeconds} seconds after a deployment completes, so a Standard site's `
629
+ + "deploy --staging waits until staging serves the new release and reports stagingServed: state served, "
630
+ + "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), "
631
+ + "with the time to wait or the seconds waited.",
593
632
  },
594
633
  {
595
634
  name: VERB_PREVIEW_PAGE,
@@ -649,20 +688,23 @@ const VERBS = Object.freeze([
649
688
  capabilities: [CAPABILITY_DEPLOYMENTS],
650
689
  tokens: ["staging", "review"],
651
690
  summary: "Mint a fresh single-use review handoff for the site's staging host.",
691
+ reviewPath: true,
652
692
  note: "The handoff URL is reported only in the final JSON as stagingPreview.url, is single-use, and expires two "
653
- + "minutes after it is minted; keep it private. Open it once, then use the site's theme toggle to review both "
654
- + "schemes. This is the recovery when deploy --staging could not mint a handoff, on a managed Docs site too.",
693
+ + "minutes after it is minted; keep it private. --path <page> opens the review on that page instead of the "
694
+ + "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 "
695
+ + "schemes. deploy --staging and redirects check report no review URL unless you pass --handoff, so this is "
696
+ + "the usual way to get one, on a managed Docs site too.",
655
697
  },
656
698
  {
657
699
  name: VERB_STATUS,
658
700
  surface: SURFACE_DOCS_PRESENTATION,
659
701
  // Readiness and the deployment log are Deployments; the image list is
660
- // Content, as is the broken-reference report.
702
+ // Content, as is the site-health read.
661
703
  capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
662
704
  tokens: ["status"],
663
705
  summary:
664
- "Report the platform authoring switch, CLI release, deployments, readiness, image processing, and broken references.",
665
- note: "Broken references list pages with missing image IDs or page paths in their latest editable body."
706
+ "Report the platform authoring switch, CLI release, deployments, readiness, image processing, and site health.",
707
+ 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."
666
708
  + " Content permission is required; a refused or failed read fails status rather than reporting a clean site.",
667
709
  },
668
710
  ]);
@@ -729,7 +771,8 @@ credentials, and it expires 24 hours after it is issued.
729
771
 
730
772
  Verbs write one JSON result to stdout and human progress to stderr. validate,
731
773
  help, whoami, and env are offline and read-only. login, logout, sites, use,
732
- whoami, and env need no configuration and no site. The offline help family is
774
+ whoami, and env need no configuration and no site. So does changelog, which
775
+ also still runs when this CLI is outdated. The offline help family is
733
776
  human-readable by default; add --json for stable reference data. Exit codes: 0
734
777
  success, 1 failure, 2 usage fault.
735
778
 
@@ -813,6 +856,18 @@ function verbHelp(verb) {
813
856
  --production Promote the completed staging deployment to production.
814
857
  --allow-failed-preview Explicitly override the matching candidate's failed preview.`
815
858
  : "";
859
+ const handoffOption = verb.handoffOptions
860
+ ? `\n --handoff Also mint a fresh single-use review URL and report it in stagingPreview.url.
861
+ --no-handoff Report no review URL (the default); accepted so scripts can say so explicitly.`
862
+ : "";
863
+ const sinceOption = verb.sinceOption
864
+ ? "\n --since <version> List the releases after this one, up to the installed release."
865
+ : "";
866
+ const reviewPathOption = verb.reviewPath
867
+ ? "\n --path <page> Open the review on this page instead of the home page, such as 'about' or '/posts/one'."
868
+ : verb.handoffOptions
869
+ ? "\n --review-path <page> With --handoff, open the review URL on this page instead of the home page."
870
+ : "";
816
871
  const metaOptions = verb.metaOptions
817
872
  ? `\n --title <text> The page's title.
818
873
  --description <text> The page's description for search results and link previews; '' clears it.
@@ -903,7 +958,7 @@ function verbHelp(verb) {
903
958
  ${verb.summary}
904
959
  ${boundary}
905
960
 
906
- ${options}${targetOption}${deliveryTargetOption}${metaOptions}${authorOptions}${jsonOption}${dryRunOption}${planOption}${nameOption}${note}
961
+ ${options}${targetOption}${handoffOption}${reviewPathOption}${sinceOption}${deliveryTargetOption}${metaOptions}${authorOptions}${jsonOption}${dryRunOption}${planOption}${nameOption}${note}
907
962
  `;
908
963
  }
909
964
 
@@ -964,6 +1019,7 @@ function parseReferenceArguments(arguments_) {
964
1019
  || topic === "preview"
965
1020
  || topic === "theme"
966
1021
  || topic === "appearance"
1022
+ || topic === "settings"
967
1023
  || topic === "footer"
968
1024
  || topic === "fixture"
969
1025
  || topic === "redirects"
@@ -1066,6 +1122,8 @@ function referenceResult(parsed) {
1066
1122
  return { ...result, topic: "presentation", referenceKind: "theme", reference: getThemeReference() };
1067
1123
  case "appearance":
1068
1124
  return { ...result, topic: "presentation", referenceKind: "appearance", reference: getAppearanceReference() };
1125
+ case "settings":
1126
+ return { ...result, topic: "presentation", referenceKind: "settings", reference: getSettingsReference() };
1069
1127
  case "footer":
1070
1128
  return { ...result, topic: "presentation", referenceKind: "footer", reference: getFooterReference() };
1071
1129
  default:
@@ -1245,6 +1303,9 @@ function parseArguments(arguments_) {
1245
1303
  let propagationWaitSeconds;
1246
1304
  let browser;
1247
1305
  let allowFailedPreview = false;
1306
+ let handoff;
1307
+ let reviewPath;
1308
+ let since;
1248
1309
  let dryRun = false;
1249
1310
  let json = false;
1250
1311
  let keyName;
@@ -1310,6 +1371,42 @@ function parseArguments(arguments_) {
1310
1371
  allowFailedPreview = true;
1311
1372
  continue;
1312
1373
  }
1374
+ if (verb.handoffOptions && (argument === "--handoff" || argument === "--no-handoff")) {
1375
+ if (handoff !== undefined) {
1376
+ throw usageError(
1377
+ handoff === (argument === "--handoff") ? "cli.duplicate_option" : "cli.handoff_conflict",
1378
+ handoff === (argument === "--handoff")
1379
+ ? `${argument} may be supplied only once.`
1380
+ : "--handoff and --no-handoff cannot be used together.",
1381
+ );
1382
+ }
1383
+ handoff = argument === "--handoff";
1384
+ continue;
1385
+ }
1386
+ if ((verb.reviewPath && argument === "--path") || (verb.handoffOptions && argument === "--review-path")) {
1387
+ if (reviewPath !== undefined) {
1388
+ throw usageError("cli.duplicate_option", `${argument} may be supplied only once.`);
1389
+ }
1390
+ // An option-like value would otherwise become the page '/--json' and mint a handoff to a 404.
1391
+ const candidate = rest[index + 1];
1392
+ reviewPath = normalizeStagingReviewPath(typeof candidate === "string" && candidate.startsWith("--") ? "" : candidate);
1393
+ index += 1;
1394
+ continue;
1395
+ }
1396
+ if (verb.sinceOption && argument === "--since") {
1397
+ if (since !== undefined) throw usageError("cli.duplicate_option", "--since may be supplied only once.");
1398
+ const candidate = rest[index + 1];
1399
+ if (parseCliVersion(candidate) === undefined) {
1400
+ throw usageError(
1401
+ "changelog.since_invalid",
1402
+ "--since requires a version such as 0.20.0 (the version you upgraded from).",
1403
+ { field: "since" },
1404
+ );
1405
+ }
1406
+ since = candidate;
1407
+ index += 1;
1408
+ continue;
1409
+ }
1313
1410
  if (verb.dryRun && argument === "--dry-run") {
1314
1411
  if (dryRun) throw usageError("cli.duplicate_option", "--dry-run may be supplied only once.");
1315
1412
  dryRun = true;
@@ -1390,6 +1487,16 @@ function parseArguments(arguments_) {
1390
1487
  `${verb.tokens.join(" ")} requires exactly one of --staging or --production.`,
1391
1488
  );
1392
1489
  }
1490
+ if (verb.handoffOptions && reviewPath !== undefined && handoff !== true) {
1491
+ throw usageError(
1492
+ "cli.review_path_requires_handoff",
1493
+ "--review-path chooses the page a --handoff review URL opens on; pass --handoff with it, or use 'staging review --path'.",
1494
+ { field: "reviewPath" },
1495
+ );
1496
+ }
1497
+ if (handoff === true && verb.target && deployTarget === DEPLOY_TARGET_PRODUCTION) {
1498
+ throw usageError("cli.handoff_target", "--handoff applies only to deploy --staging.", { field: "handoff" });
1499
+ }
1393
1500
  if (verb.positionals === "fixturePath" && (positionals.length > 1 || (init && positionals.length !== 1))) {
1394
1501
  throw usageError(
1395
1502
  "validate.fixture_path_invalid",
@@ -1503,6 +1610,9 @@ function parseArguments(arguments_) {
1503
1610
  quiet,
1504
1611
  deployTarget,
1505
1612
  allowFailedPreview,
1613
+ ...(handoff === undefined ? {} : { handoff }),
1614
+ ...(reviewPath === undefined ? {} : { reviewPath }),
1615
+ ...(since === undefined ? {} : { since }),
1506
1616
  ...(deliveryUrl === undefined ? {} : { deliveryUrl }),
1507
1617
  ...(propagationWaitSeconds === undefined ? {} : { propagationWaitSeconds }),
1508
1618
  ...(browser === undefined ? {} : { browser }),
@@ -1595,6 +1705,9 @@ export async function runCli({
1595
1705
  configPath: parsed.configPath,
1596
1706
  deployTarget: parsed.deployTarget,
1597
1707
  allowFailedPreview: parsed.allowFailedPreview,
1708
+ ...(parsed.handoff === undefined ? {} : { handoff: parsed.handoff }),
1709
+ ...(parsed.reviewPath === undefined ? {} : { reviewPath: parsed.reviewPath }),
1710
+ ...(parsed.since === undefined ? {} : { since: parsed.since }),
1598
1711
  init: parsed.init,
1599
1712
  quiet: parsed.quiet,
1600
1713
  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,
@@ -352,11 +352,14 @@ const SPACER = Object.freeze({
352
352
 
353
353
  /**
354
354
  * A form defined under Site → Forms (or with `forms push`), placed by key. The
355
- * key is checked against the site's forms when the page is published, not here:
356
- * this table only knows the shape.
355
+ * workspace names a form by its key so the same files push to any site; the site
356
+ * stores the form's id, and `pages push` and `pages pull` translate between the
357
+ * two (TR01216). The key is checked against the site's forms when the page is
358
+ * pushed, not here: this table only knows the shape.
357
359
  */
358
360
  const FORM = Object.freeze({
359
- formKey: str(),
361
+ // Required: a placement with no form chosen can never publish, so it is refused here, not at deploy.
362
+ formKey: str({ required: true, nonWhitespace: true }),
360
363
  });
361
364
 
362
365
  function deepFreeze(value) {
@@ -648,9 +651,9 @@ const COMPONENT_DEFINITIONS = Object.freeze({
648
651
  FORM,
649
652
  "Form",
650
653
  "A form from Site → Forms that visitors fill in and submit.",
651
- { formKey: "" },
654
+ {},
652
655
  [
653
- "Set formKey to the key of an existing form ('forms push' creates it); an unknown or archived key fails the publish.",
656
+ "Set formKey to the key of an existing form ('forms push' creates it); 'pages push' refuses an unknown or archived key, and the site stores the form's id, not the key.",
654
657
  "The button text and what happens after a response is sent belong to the form, not the page: set submit_label and after_submit in the form's file (see 'help forms').",
655
658
  "The form's field labels are its accessible names; write each so it makes sense out of context.",
656
659
  ],
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) }),
@@ -0,0 +1,103 @@
1
+ import { listSiteForms } from "./forms-api.js";
2
+ import { contentDocuments, parseComponentData } from "./typed-pages.js";
3
+
4
+ /**
5
+ * Where the workspace and the site spell a placed form differently (TR01216).
6
+ *
7
+ * A workspace names a form by its key, as `forms/<key>.json` and the Markdown
8
+ * `form` fence do, so the same workspace can be pushed to a fresh site whose
9
+ * ids all differ. A page stored on the site names the form by its id. `pages
10
+ * push` turns keys into ids on the way out and `pages pull` turns ids back
11
+ * into keys, the way a form's `page_path` and `page_resource_id` already
12
+ * translate. The translation is block-scoped: a `formKey` anywhere but a `form`
13
+ * component block is author prose.
14
+ */
15
+
16
+ const FORM_COMPONENT = "form";
17
+
18
+ /** Each `form` component block in the document's content documents, with its parsed data. */
19
+ function formBlocks(document_) {
20
+ const found = [];
21
+ const visit = (value) => {
22
+ if (Array.isArray(value)) {
23
+ value.forEach(visit);
24
+ } else if (value !== null && typeof value === "object") {
25
+ if (value.type === "componentBlock" && value.attrs?.componentType === FORM_COMPONENT) {
26
+ const data = typeof value.attrs.componentData === "string"
27
+ ? parseComponentData(value.attrs.componentData)
28
+ : undefined;
29
+ if (data !== null && typeof data === "object" && !Array.isArray(data)) found.push({ attrs: value.attrs, data });
30
+ return;
31
+ }
32
+ Object.values(value).forEach(visit);
33
+ }
34
+ };
35
+ for (const { doc } of contentDocuments(document_)) visit(doc);
36
+ return found;
37
+ }
38
+
39
+ /** The distinct form keys a workspace document places, in document order. */
40
+ export function placedFormKeys(document_) {
41
+ const keys = [];
42
+ for (const { data } of formBlocks(document_)) {
43
+ if (typeof data.formKey === "string" && data.formKey !== "" && !keys.includes(data.formKey)) keys.push(data.formKey);
44
+ }
45
+ return keys;
46
+ }
47
+
48
+ /** Whether a document read from the site places any form by id. */
49
+ export function placesFormIds(document_) {
50
+ return formBlocks(document_).some(({ data }) => typeof data.formId === "string");
51
+ }
52
+
53
+ /**
54
+ * A copy of the document with every placed form named by id, for the wire. A key
55
+ * the index does not know is left as it is: `placedFormProblems` has already
56
+ * refused the push for it, so nothing sends it.
57
+ */
58
+ export function withFormIds(document_, idByKey) {
59
+ return translate(document_, (data) => {
60
+ const id = typeof data.formKey === "string" ? idByKey.get(data.formKey) : undefined;
61
+ if (id === undefined) return undefined;
62
+ const rest = { ...data };
63
+ delete rest.formKey;
64
+ return { ...rest, formId: id };
65
+ });
66
+ }
67
+
68
+ /**
69
+ * A copy of a document read from the site with every placed form named by key,
70
+ * for the workspace. An id the site no longer has stays an id: pull must not hide
71
+ * what the site holds, and a push of that file is refused until the author picks a form.
72
+ */
73
+ export function withFormKeys(document_, keyById) {
74
+ return translate(document_, (data) => {
75
+ const key = typeof data.formId === "string" ? keyById.get(data.formId) : undefined;
76
+ if (key === undefined) return undefined;
77
+ const rest = { ...data };
78
+ delete rest.formId;
79
+ return { ...rest, formKey: key };
80
+ });
81
+ }
82
+
83
+ function translate(document_, convert) {
84
+ const copy = structuredClone(document_);
85
+ for (const { attrs, data } of formBlocks(copy)) {
86
+ const converted = convert(data);
87
+ if (converted !== undefined) attrs.componentData = JSON.stringify(converted);
88
+ }
89
+ return copy;
90
+ }
91
+
92
+ /** The site's forms by key and by id, archived ones marked. One request serves a whole push or pull. */
93
+ export async function readFormIndex(client, siteId) {
94
+ const idByKey = new Map();
95
+ const keyById = new Map();
96
+ const archivedKeys = new Set();
97
+ for (const form of await listSiteForms(client, siteId)) {
98
+ keyById.set(form.id, form.key);
99
+ if (form.archived) archivedKeys.add(form.key);
100
+ else idByKey.set(form.key, form.id);
101
+ }
102
+ return { idByKey, keyById, archivedKeys };
103
+ }
@@ -49,9 +49,33 @@ export async function readWorkspaceForms(workspaceDir, keys, validationOptions =
49
49
  const document_ = await readWorkspaceJson(workspaceDir, file, FORM_LIMITS.fileBytes);
50
50
  forms.push({ key, file, document: validateFormDocument(document_, key, validationOptions) });
51
51
  }
52
+ requireDistinctNames(forms);
52
53
  return forms;
53
54
  }
54
55
 
56
+ /** A form name as the site compares it: trimmed, ignoring case (TR01216). */
57
+ export function comparableFormName(name) {
58
+ return String(name).trim().toLowerCase();
59
+ }
60
+
61
+ /** Names are unique per site, so two files in one push cannot share one. */
62
+ function requireDistinctNames(forms) {
63
+ const seen = new Map();
64
+ for (const form of forms) {
65
+ const name = comparableFormName(form.document.name);
66
+ const other = seen.get(name);
67
+ if (other !== undefined) {
68
+ throw new SiteAuthoringError(
69
+ "forms.name_duplicate",
70
+ `${form.file} and ${other} both name the form '${form.document.name}'. A form name is unique on a site, `
71
+ + "ignoring case; rename one of them.",
72
+ { field: `${form.file}:name` },
73
+ );
74
+ }
75
+ seen.set(name, form.file);
76
+ }
77
+ }
78
+
55
79
  /**
56
80
  * The forms baseline in the pull manifest (TR01061).
57
81
  *
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