@taprootio/site-authoring 0.20.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.
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"],
@@ -440,9 +464,12 @@ const VERBS = Object.freeze([
440
464
  capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
441
465
  tokens: ["redirects", "check"],
442
466
  summary: "Check the current redirect map through the authenticated staging edge.",
467
+ handoffOptions: true,
443
468
  note: "Reports each entry's path, HTTP status and Location in redirects.items, and redirects.verified, without "
444
469
  + "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.",
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.",
446
473
  },
447
474
  {
448
475
  name: VERB_REDIRECTS_PULL,
@@ -589,11 +616,19 @@ const VERBS = Object.freeze([
589
616
  tokens: ["deploy"],
590
617
  summary: "Deploy to staging, or promote staging to production.",
591
618
  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 "
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 "
595
626
  + "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').",
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.",
597
632
  },
598
633
  {
599
634
  name: VERB_PREVIEW_PAGE,
@@ -653,20 +688,23 @@ const VERBS = Object.freeze([
653
688
  capabilities: [CAPABILITY_DEPLOYMENTS],
654
689
  tokens: ["staging", "review"],
655
690
  summary: "Mint a fresh single-use review handoff for the site's staging host.",
691
+ reviewPath: true,
656
692
  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.",
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.",
659
697
  },
660
698
  {
661
699
  name: VERB_STATUS,
662
700
  surface: SURFACE_DOCS_PRESENTATION,
663
701
  // Readiness and the deployment log are Deployments; the image list is
664
- // Content, as is the broken-reference report.
702
+ // Content, as is the site-health read.
665
703
  capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
666
704
  tokens: ["status"],
667
705
  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."
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."
670
708
  + " Content permission is required; a refused or failed read fails status rather than reporting a clean site.",
671
709
  },
672
710
  ]);
@@ -733,7 +771,8 @@ credentials, and it expires 24 hours after it is issued.
733
771
 
734
772
  Verbs write one JSON result to stdout and human progress to stderr. validate,
735
773
  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
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
737
776
  human-readable by default; add --json for stable reference data. Exit codes: 0
738
777
  success, 1 failure, 2 usage fault.
739
778
 
@@ -817,6 +856,18 @@ function verbHelp(verb) {
817
856
  --production Promote the completed staging deployment to production.
818
857
  --allow-failed-preview Explicitly override the matching candidate's failed preview.`
819
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
+ : "";
820
871
  const metaOptions = verb.metaOptions
821
872
  ? `\n --title <text> The page's title.
822
873
  --description <text> The page's description for search results and link previews; '' clears it.
@@ -907,7 +958,7 @@ function verbHelp(verb) {
907
958
  ${verb.summary}
908
959
  ${boundary}
909
960
 
910
- ${options}${targetOption}${deliveryTargetOption}${metaOptions}${authorOptions}${jsonOption}${dryRunOption}${planOption}${nameOption}${note}
961
+ ${options}${targetOption}${handoffOption}${reviewPathOption}${sinceOption}${deliveryTargetOption}${metaOptions}${authorOptions}${jsonOption}${dryRunOption}${planOption}${nameOption}${note}
911
962
  `;
912
963
  }
913
964
 
@@ -968,6 +1019,7 @@ function parseReferenceArguments(arguments_) {
968
1019
  || topic === "preview"
969
1020
  || topic === "theme"
970
1021
  || topic === "appearance"
1022
+ || topic === "settings"
971
1023
  || topic === "footer"
972
1024
  || topic === "fixture"
973
1025
  || topic === "redirects"
@@ -1070,6 +1122,8 @@ function referenceResult(parsed) {
1070
1122
  return { ...result, topic: "presentation", referenceKind: "theme", reference: getThemeReference() };
1071
1123
  case "appearance":
1072
1124
  return { ...result, topic: "presentation", referenceKind: "appearance", reference: getAppearanceReference() };
1125
+ case "settings":
1126
+ return { ...result, topic: "presentation", referenceKind: "settings", reference: getSettingsReference() };
1073
1127
  case "footer":
1074
1128
  return { ...result, topic: "presentation", referenceKind: "footer", reference: getFooterReference() };
1075
1129
  default:
@@ -1249,6 +1303,9 @@ function parseArguments(arguments_) {
1249
1303
  let propagationWaitSeconds;
1250
1304
  let browser;
1251
1305
  let allowFailedPreview = false;
1306
+ let handoff;
1307
+ let reviewPath;
1308
+ let since;
1252
1309
  let dryRun = false;
1253
1310
  let json = false;
1254
1311
  let keyName;
@@ -1314,6 +1371,42 @@ function parseArguments(arguments_) {
1314
1371
  allowFailedPreview = true;
1315
1372
  continue;
1316
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
+ }
1317
1410
  if (verb.dryRun && argument === "--dry-run") {
1318
1411
  if (dryRun) throw usageError("cli.duplicate_option", "--dry-run may be supplied only once.");
1319
1412
  dryRun = true;
@@ -1394,6 +1487,16 @@ function parseArguments(arguments_) {
1394
1487
  `${verb.tokens.join(" ")} requires exactly one of --staging or --production.`,
1395
1488
  );
1396
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
+ }
1397
1500
  if (verb.positionals === "fixturePath" && (positionals.length > 1 || (init && positionals.length !== 1))) {
1398
1501
  throw usageError(
1399
1502
  "validate.fixture_path_invalid",
@@ -1507,6 +1610,9 @@ function parseArguments(arguments_) {
1507
1610
  quiet,
1508
1611
  deployTarget,
1509
1612
  allowFailedPreview,
1613
+ ...(handoff === undefined ? {} : { handoff }),
1614
+ ...(reviewPath === undefined ? {} : { reviewPath }),
1615
+ ...(since === undefined ? {} : { since }),
1510
1616
  ...(deliveryUrl === undefined ? {} : { deliveryUrl }),
1511
1617
  ...(propagationWaitSeconds === undefined ? {} : { propagationWaitSeconds }),
1512
1618
  ...(browser === undefined ? {} : { browser }),
@@ -1599,6 +1705,9 @@ export async function runCli({
1599
1705
  configPath: parsed.configPath,
1600
1706
  deployTarget: parsed.deployTarget,
1601
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 }),
1602
1711
  init: parsed.init,
1603
1712
  quiet: parsed.quiet,
1604
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,
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) }),
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
 
@@ -44,6 +44,12 @@ import {
44
44
  REQUIRED_THEME_PROPERTIES,
45
45
  validateAndLintThemePair,
46
46
  } from "./theme-validation.js";
47
+ import {
48
+ describeSettingField,
49
+ formatSettingsReference,
50
+ PAGE_BACKGROUND_IMAGE_RENDERING,
51
+ PAGE_BACKGROUND_VERSUS_CANVAS,
52
+ } from "./setting-reference.js";
47
53
  import {
48
54
  SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES,
49
55
  SETTINGS_TYPE_TAPROOT_STYLES,
@@ -273,14 +279,23 @@ const THEME_GROUPS = Object.freeze([
273
279
  }),
274
280
  Object.freeze({
275
281
  name: "surface-assets",
276
- summary: "Preserve pulled asset fields; use appearance image ids for site-owned canvas art.",
282
+ summary:
283
+ "Preserve pulled asset fields; use appearance image ids for the logos, favicon and canvas image. The page "
284
+ + "background image is a theme field behind the page content, unlike the canvas image, which paints only the "
285
+ + "side margins (help settings has both).",
277
286
  fields: Object.freeze([
278
287
  themeField("stylesheets", "Agent-authored site themes must keep this exact array empty.", { exact: [] }),
279
288
  themeField(
280
289
  "pageBackgroundImage",
281
- "CSS background-image; the complete Taproot workspace includes an empty value.",
290
+ "CSS background-image (the app's Page style > Background image). " + PAGE_BACKGROUND_IMAGE_RENDERING
291
+ + " Today it is a CSS url(...) of a delivered image, the src that .taproot-site-media.json records; it tiles "
292
+ + "at the image's natural pixel size from the top-left. The complete Taproot workspace includes an empty value.",
293
+ ),
294
+ themeField(
295
+ "pageBackgroundImageOpacity",
296
+ "How strongly the page background image shows, from 0 (invisible) to 1 (fully opaque).",
297
+ { minimum: 0, maximum: 1 },
282
298
  ),
283
- themeField("pageBackgroundImageOpacity", "Page background opacity.", { minimum: 0, maximum: 1 }),
284
299
  themeField("boxBackgroundImage", "Optional inherited box-surface background image."),
285
300
  themeField("boxBackgroundImageOpacity", "Optional box background opacity.", { minimum: 0, maximum: 1 }),
286
301
  themeField("vellumOpacity", "Optional modal-vellum opacity.", { minimum: 0, maximum: 1 }),
@@ -418,6 +433,9 @@ export function getThemeReference() {
418
433
  };
419
434
  }
420
435
 
436
+ // "linkHoverColor" reads "link hover color", so a new footer color token needs no second table.
437
+ const footerColorLabel = (name) => name.replace(/([A-Z])/gu, " $1").toLowerCase();
438
+
421
439
  function appearanceColorReference(definition) {
422
440
  return definition.type === "appearance-color"
423
441
  ? {
@@ -432,6 +450,7 @@ export function getAppearanceReference() {
432
450
  const scalarFields = APPEARANCE_SCALAR_FIELDS.map((field) => ({
433
451
  file: APPEARANCE_FILES[field.settingsType],
434
452
  path: "settings." + field.name,
453
+ ...describeSettingField(field.settingsType, field.name),
435
454
  type: field.type,
436
455
  ...(field.values ? { values: field.values } : {}),
437
456
  ...(field.minimum !== undefined ? { minimum: field.minimum, maximum: field.maximum } : {}),
@@ -447,27 +466,36 @@ export function getAppearanceReference() {
447
466
  {
448
467
  file: APPEARANCE_FILES[SETTINGS_TYPE_TAPROOT_STYLES],
449
468
  path: "settings.lightTheme",
469
+ ...describeSettingField(SETTINGS_TYPE_TAPROOT_STYLES, "lightTheme"),
450
470
  type: "complete-espalier-theme",
451
471
  },
452
472
  {
453
473
  file: APPEARANCE_FILES[SETTINGS_TYPE_TAPROOT_STYLES],
454
474
  path: "settings.darkTheme",
475
+ ...describeSettingField(SETTINGS_TYPE_TAPROOT_STYLES, "darkTheme"),
455
476
  type: "complete-espalier-theme",
456
477
  },
457
478
  ...scalarFields,
458
479
  ...APPEARANCE_FOOTER_COLOR_FIELDS.map((field) => ({
459
480
  file: field.file,
460
481
  path: field.path,
482
+ appLabel: "Footer",
483
+ description: `The footer's ${footerColorLabel(field.name)} in the ${field.scheme} scheme. footer push owns the rest of the footer.`,
484
+ renders: "The footer, once footerSettings.enabled is true (default false).",
461
485
  type: field.type,
462
486
  ...appearanceColorReference(field),
463
487
  default: field.default,
464
488
  })),
465
489
  ],
466
490
  readOnlyProjections: APPEARANCE_READ_ONLY_FIELDS,
491
+ pageBackgroundVersusCanvas: PAGE_BACKGROUND_VERSUS_CANVAS,
492
+ fieldMap: CLI_BINARY_NAME + " help settings maps every pulled field, including the read-only ones, to its app control.",
467
493
  imageReferences:
468
494
  "Use site-owned image ids retained from pull or returned by media upload. URLs are server projections, never authored inputs.",
469
495
  logoContract:
470
496
  "lightLogoId and darkLogoId are the current scheme-specific logo fields; there is no separate compact-logo setting. "
497
+ + "Each takes one image id: upload the highest-resolution file you have and assign it. An @2x or @3x suffix is only "
498
+ + "part of a filename, nothing pairs it with another file, and the platform generates the sizes. "
471
499
  + "Prefer transparent PNG or WebP artwork with genuine alpha so the header background shows through, including "
472
500
  + "inside letter counters. Avoid baked-in background rectangles unless the brand intentionally uses a badge. "
473
501
  + "Keep padding tight and verify the delivered image preserves transparency. Logo style and coloration are "
@@ -776,11 +804,17 @@ export function formatPresentationReference(reference) {
776
804
  }
777
805
  if (reference.referenceKind === "appearance") {
778
806
  return reference.title + "\nUsage: " + reference.usage + "\n\nWritable fields:\n"
779
- + reference.fields.map((field) => " " + field.file + " :: " + field.path + " — " + fieldConstraint(field)).join(
780
- "\n",
781
- )
807
+ + reference.fields.map((field) =>
808
+ " " + field.file + " :: " + field.path + " — " + fieldConstraint(field)
809
+ + "\n app: " + field.appLabel + ". " + field.description
810
+ + (field.renders === undefined ? "" : "\n renders: " + field.renders)
811
+ ).join("\n")
782
812
  + "\n\nRead-only server projections:\n"
783
813
  + reference.readOnlyProjections.map((field) => " " + field.file + " :: " + field.path).join("\n")
814
+ + "\n\nPage background image versus canvas image:\n - " + reference.pageBackgroundVersusCanvas.pageBackground
815
+ + "\n - " + reference.pageBackgroundVersusCanvas.canvas
816
+ + "\n - " + reference.pageBackgroundVersusCanvas.shared
817
+ + "\n\nField map: " + reference.fieldMap
784
818
  + "\n\nImages: " + reference.imageReferences + "\nLogos: " + reference.logoContract
785
819
  + "\nLogo contrast: " + reference.logoContrastContract
786
820
  + "\nFavicon: " + reference.faviconContract
@@ -794,6 +828,7 @@ export function formatPresentationReference(reference) {
794
828
  + "\nRetry: " + reference.retry
795
829
  + "\nRecovery: " + reference.recovery + "\n";
796
830
  }
831
+ if (reference.referenceKind === "settings") return formatSettingsReference(reference);
797
832
  if (reference.referenceKind === "footer") {
798
833
  const messages = reference.messages.map((message) =>
799
834
  message.name + " (unlisted fields rejected)\n"
package/src/problems.js CHANGED
@@ -39,7 +39,12 @@ function withContext(problem, context, error) {
39
39
  export function problemsOf(error, context = {}) {
40
40
  const own = Array.isArray(error.problems) && error.problems.length > 0
41
41
  ? error.problems
42
- : [{ code: error.code, ...(error.field === undefined ? {} : { field: error.field }), message: error.message }];
42
+ : [{
43
+ code: error.code,
44
+ ...(error.field === undefined ? {} : { field: error.field }),
45
+ ...(error.rule === undefined ? {} : { rule: error.rule }),
46
+ message: error.message,
47
+ }];
43
48
  return own.map((problem) => withContext(problem, context, error));
44
49
  }
45
50