@taprootio/site-authoring 0.2.0 → 0.4.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 (50) hide show
  1. package/README.md +106 -13
  2. package/examples/riverbend-wellness/manifest.fixture.json +79 -0
  3. package/examples/riverbend-wellness/nav.json +26 -0
  4. package/examples/riverbend-wellness/pages/index.md +93 -0
  5. package/examples/riverbend-wellness/pages/visit.md +33 -0
  6. package/examples/riverbend-wellness/redirects.json +33 -0
  7. package/examples/riverbend-wellness/settings/brand.json +7 -0
  8. package/examples/riverbend-wellness/settings/site-header.json +21 -0
  9. package/examples/riverbend-wellness/settings/site-publishing-preferences.json +107 -0
  10. package/examples/riverbend-wellness/settings/taproot-styles.json +730 -0
  11. package/package.json +2 -1
  12. package/src/api.js +203 -3
  13. package/src/cli-release.js +152 -0
  14. package/src/cli.js +120 -11
  15. package/src/constants.js +59 -2
  16. package/src/contact-url.js +63 -0
  17. package/src/content/components.js +26 -4
  18. package/src/content/free-form-section-registry.json +3 -2
  19. package/src/content/inline-facts.js +5 -1
  20. package/src/content/tiptap-prosemirror.ts +26 -12
  21. package/src/credentials.js +39 -4
  22. package/src/errors.js +25 -0
  23. package/src/fixture-contract.js +64 -0
  24. package/src/footer-contract.js +28 -30
  25. package/src/json-path-diff.js +167 -0
  26. package/src/output.js +9 -0
  27. package/src/presentation-reference.js +5 -3
  28. package/src/redirects-contract.js +444 -0
  29. package/src/redirects-workspace.js +72 -0
  30. package/src/reference-help.js +262 -10
  31. package/src/session.js +161 -7
  32. package/src/transport.js +111 -6
  33. package/src/verbs/approve.js +7 -2
  34. package/src/verbs/deploy.js +29 -2
  35. package/src/verbs/footer-push.js +7 -2
  36. package/src/verbs/index.js +7 -1
  37. package/src/verbs/login.js +11 -1
  38. package/src/verbs/media-upload.js +7 -2
  39. package/src/verbs/nav-push.js +17 -11
  40. package/src/verbs/pages-push.js +93 -8
  41. package/src/verbs/preview-page.js +7 -2
  42. package/src/verbs/preview-revoke.js +7 -2
  43. package/src/verbs/pull.js +357 -22
  44. package/src/verbs/redirects-pull.js +90 -0
  45. package/src/verbs/redirects-push.js +100 -0
  46. package/src/verbs/status.js +90 -3
  47. package/src/verbs/theme-push.js +7 -2
  48. package/src/verbs/validate.js +93 -18
  49. package/src/verbs/whoami.js +79 -1
  50. package/src/workspace.js +153 -15
package/src/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  CLI_BINARY_NAME,
3
3
  CLI_NAME,
4
+ CLI_UPGRADE_COMMAND,
4
5
  CLI_VERSION,
5
6
  DEFAULT_LOGIN_KEY_NAME,
6
7
  DEPLOY_TARGET_PRODUCTION,
@@ -22,6 +23,8 @@ import {
22
23
  VERB_PREVIEW_PAGE,
23
24
  VERB_PREVIEW_REVOKE,
24
25
  VERB_PULL,
26
+ VERB_REDIRECTS_PULL,
27
+ VERB_REDIRECTS_PUSH,
25
28
  VERB_SITES,
26
29
  VERB_STATUS,
27
30
  VERB_THEME_PUSH,
@@ -56,8 +59,21 @@ import {
56
59
  CAPABILITY_DEPLOYMENTS,
57
60
  CAPABILITY_DESIGN,
58
61
  } from "./capabilities.js";
62
+ import { assertCliCurrent } from "./session.js";
59
63
  import { VERB_HANDLERS } from "./verbs/index.js";
60
64
 
65
+ /**
66
+ * The verbs the local version gate applies to (TR00703).
67
+ *
68
+ * Exactly the three that make no request. Every other verb reaches Taproot,
69
+ * whose own refusal is authoritative and better informed than this recording,
70
+ * so gating them here would only duplicate an answer — and gating `login`,
71
+ * `logout`, or `env` would take away the commands an operator needs while they
72
+ * are outdated. `--help` and `--version` are outside it for the same reason:
73
+ * they are how someone finds out what they have.
74
+ */
75
+ const VERSION_GATED_OFFLINE_VERBS = Object.freeze([VERB_HELP, VERB_VALIDATE, VERB_WHOAMI]);
76
+
61
77
  // One entry per verb, in help order. `tokens` is the exact leading positional
62
78
  // sequence; matching prefers the longest sequence, so a two-token family can
63
79
  // never be shadowed by a one-token verb.
@@ -75,7 +91,9 @@ const VERBS = Object.freeze([
75
91
  offline: true,
76
92
  note: "Reads manifest.fixture.json plus the fixture's page, navigation, theme, appearance, header, brand, and footer files. "
77
93
  + "This proves local structure and semantics only. It does not prove authorization, live site ownership, concurrency, "
78
- + "persisted round trips, or rendering; run a real pull and authorized preview before deployment.",
94
+ + "persisted round trips, or rendering; run a real pull and authorized preview before deployment. "
95
+ + `See '${CLI_BINARY_NAME} help fixture' for the manifest contract and for the path of the complete example `
96
+ + "fixture this package ships, which needs no credential and no pulled site.",
79
97
  },
80
98
  {
81
99
  name: VERB_LOGIN,
@@ -195,8 +213,9 @@ const VERBS = Object.freeze([
195
213
  + "The homepage is recorded with an empty path, so address it as '/'. "
196
214
  + "A selected path resolves to its one authoritative source from metadata alone, and that page is then validated "
197
215
  + "exactly as a whole push would validate it — site binding, manifest integrity, live create-or-update "
198
- + "resolution, system-page rules, path uniqueness against the live site, media ownership, and two workspace "
199
- + "files claiming one path all still fail closed. What a selection does not do is convert or validate the "
216
+ + "resolution, system-page rules, path uniqueness against the live site, media ownership, a live page whose "
217
+ + "stored-state revision moved since this workspace last reconciled with it (pages.push_conflict), and two "
218
+ + "workspace files claiming one path all still fail closed. What a selection does not do is convert or validate the "
200
219
  + "documents of pages it is not sending: an unrelated page left on an obsolete contract is reported by the "
201
220
  + "whole-workspace push, not used to block this one. The result states the selection and how many sources were "
202
221
  + "discovered and validated. "
@@ -210,10 +229,35 @@ const VERBS = Object.freeze([
210
229
  },
211
230
  {
212
231
  name: VERB_NAV_PUSH,
213
- capabilities: [CAPABILITY_DESIGN],
232
+ // Content is for the read, not the write: every PAGE nav item is checked
233
+ // against the live page list before the tree is replaced, and that list is
234
+ // gated on site.pages.edit_any (TR00691).
235
+ capabilities: [CAPABILITY_CONTENT, CAPABILITY_DESIGN],
214
236
  tokens: ["nav", "push"],
215
237
  summary: "Replace the whole navigation tree from the local workspace.",
216
238
  },
239
+ {
240
+ name: VERB_REDIRECTS_PULL,
241
+ // A redirect is a content path, and both halves of the map — read and
242
+ // replace — are gated on site.pages.edit_any, which only Content carries.
243
+ capabilities: [CAPABILITY_CONTENT],
244
+ tokens: ["redirects", "pull"],
245
+ summary: "Snapshot the site's whole redirect map into redirects.json.",
246
+ note: "Also part of 'pull'. Run it on its own to re-read the map after a page rename, or after a push was "
247
+ + "refused as a conflict, without re-pulling every page. It records the map's revision in the pull "
248
+ + "manifest; 'redirects push' sends that revision so a stale replace is refused rather than deleting an "
249
+ + "entry a rename recorded. See 'taproot-site help redirects'.",
250
+ },
251
+ {
252
+ name: VERB_REDIRECTS_PUSH,
253
+ capabilities: [CAPABILITY_CONTENT],
254
+ tokens: ["redirects", "push"],
255
+ summary: "Validate redirects.json and replace the whole redirect map.",
256
+ note: "Run 'redirects pull' first. The whole file is validated locally by entry index — path normalization, "
257
+ + "targets, statuses, duplicates, chains, and loops — before anything is sent, and the site refuses a "
258
+ + "source a live page occupies. Entries take effect on the next deploy. "
259
+ + "See 'taproot-site help redirects'.",
260
+ },
217
261
  {
218
262
  name: VERB_THEME_PUSH,
219
263
  capabilities: [CAPABILITY_DESIGN],
@@ -251,7 +295,18 @@ const VERBS = Object.freeze([
251
295
  },
252
296
  {
253
297
  name: VERB_DEPLOY,
254
- capabilities: [CAPABILITY_DEPLOYMENTS],
298
+ // Deployments is the only capability this verb *writes* with; the other two
299
+ // are for reads and re-reads the server makes on its behalf (TR00691).
300
+ // Content: --staging builds the candidate from the live page list
301
+ // (DeploySiteRequest.staged_page_ids names the ids explicitly), and that
302
+ // list is gated on site.pages.edit_any.
303
+ // Design: --production promotes a completed staging deployment, and the
304
+ // pipeline re-authorizes that deployment's *stored* candidate — every
305
+ // selected settings group and its navigation — against site.theme.manage,
306
+ // which only Design carries. Every candidate this CLI stages from a pulled
307
+ // workspace carries settings, navigation, or both, so a promotion without
308
+ // Design is refused.
309
+ capabilities: [CAPABILITY_CONTENT, CAPABILITY_DESIGN, CAPABILITY_DEPLOYMENTS],
255
310
  tokens: ["deploy"],
256
311
  summary: "Deploy to staging, or promote staging to production.",
257
312
  target: true,
@@ -265,6 +320,8 @@ const VERBS = Object.freeze([
265
320
  json: true,
266
321
  note: "Select the persisted draft by page path (as recorded by pull) or canonical lowercase UUID. "
267
322
  + "The homepage is recorded with an empty path, so address it as '/'. "
323
+ + "Preview before approving: approve consumes the draft, so a page that has been approved has no draft "
324
+ + "left to render and answers preview.no_draft. Review it on staging after a deploy instead. "
268
325
  + "The handoff URL in the result is single-use and expires two minutes after it is minted: opening it "
269
326
  + "consumes it, and a reused, shared, or bookmarked preview URL answers Not found. Run preview page again "
270
327
  + "for another. "
@@ -286,13 +343,30 @@ const VERBS = Object.freeze([
286
343
  // Content.
287
344
  capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
288
345
  tokens: ["status"],
289
- summary: "Report deployments, readiness, and image processing.",
346
+ summary: "Report the platform authoring switch, the CLI release, deployments, readiness, and image processing.",
290
347
  note: "Broken references are not included: that read remains"
291
348
  + " session-only on the server, so the result reports it as uncovered"
292
349
  + " rather than pretending an empty list means a clean site.",
293
350
  },
294
351
  ]);
295
352
 
353
+ /**
354
+ * The shipped verb table's capability declarations, keyed by verb name.
355
+ *
356
+ * Exported so the tests enforce the table rather than restate it. The claim
357
+ * each entry makes — "this is the smallest set this verb's requests need" —
358
+ * went four releases without anything checking it, and it was wrong twice:
359
+ * `nav push` and `deploy` were each short of a read they make on every run, and
360
+ * `deploy` was short again of the promotion re-authorization the server runs on
361
+ * state the request only names (TR00691). A verb with no entry needs no site
362
+ * credential at all.
363
+ */
364
+ export const VERB_CAPABILITIES = Object.freeze(Object.fromEntries(
365
+ VERBS
366
+ .filter((verb) => verb.capabilities !== undefined)
367
+ .map((verb) => [verb.name, Object.freeze([...verb.capabilities])]),
368
+ ));
369
+
296
370
  const COMMON_OPTIONS = `Options:
297
371
  --config <path> Place before a config-reading verb; bypass parent discovery.
298
372
  --quiet Suppress human progress. The JSON result is unchanged.
@@ -318,6 +392,15 @@ whoami, and env need no configuration and no site. The offline help family is
318
392
  human-readable by default; add --json for stable reference data. Exit codes: 0
319
393
  success, 1 failure, 2 usage fault.
320
394
 
395
+ Troubleshooting: a command that fails with field=CliUpgradeRequired (or
396
+ refusal=cli_outdated) means this CLI is behind the latest published release,
397
+ which is the only release Taproot accepts. Nothing is wrong with the
398
+ credential, the request, or the site, and no retry of this version succeeds.
399
+ Run '${CLI_UPGRADE_COMMAND}' and try again; if the
400
+ release is minutes old, npm may not serve it yet, so wait and retry the
401
+ upgrade. Once a run has recorded a newer release, help, validate, and whoami
402
+ refuse the same way offline. --version and --help always answer.
403
+
321
404
  Verbs:
322
405
  ${VERBS.map((verb) => ` ${verb.tokens.join(" ").padEnd(14)} ${verb.summary}`).join("\n")}
323
406
 
@@ -373,11 +456,22 @@ function verbHelp(verb) {
373
456
  // would have had to gain.
374
457
  const selfContained = verb.offline && !verb.readsLocalState;
375
458
  const prefix = selfContained ? CLI_BINARY_NAME : `${CLI_BINARY_NAME} [--config <path>]`;
459
+ // The offline boundary states the version gate too (TR00703). It is the one
460
+ // thing these verbs read that is not their own input, and a boundary sentence
461
+ // that omitted it would be describing a verb that no longer exists.
462
+ // `env` is offline too and deliberately outside the gate — switching Taproots
463
+ // is one of the things an outdated CLI must still be able to do — so the
464
+ // sentence is attached per verb rather than to offline-ness.
465
+ const upgradeGate = VERSION_GATED_OFFLINE_VERBS.includes(verb.name)
466
+ ? " It refuses only when a previous sign-in exchange recorded a newer published release than this CLI, because "
467
+ + "Taproot accepts only the latest; with nothing recorded it runs."
468
+ : "";
376
469
  const boundary = selfContained
377
- ? "This offline verb reads no configuration or credential and performs no network request or write."
470
+ ? "This offline verb uses no credential and reads no configuration, and performs no network request and no "
471
+ + `write.${upgradeGate}`
378
472
  : verb.offline
379
473
  ? "This verb answers entirely from local state — the stored sign-in and the configuration — and makes no "
380
- + "network request and no write."
474
+ + `network request and no write.${upgradeGate}`
381
475
  : verb.credentialFree
382
476
  ? `This verb reads the configuration but requires no existing credential. `
383
477
  + `${PUBLISH_KEY_ENVIRONMENT_VARIABLE} always takes precedence over the stored credential, so setting it leaves `
@@ -464,6 +558,8 @@ function parseReferenceArguments(arguments_) {
464
558
  || topic === "theme"
465
559
  || topic === "appearance"
466
560
  || topic === "footer"
561
+ || topic === "fixture"
562
+ || topic === "redirects"
467
563
  )
468
564
  && (subject !== undefined || extra.length > 0)) {
469
565
  throw usageError("help.usage", `The '${topic}' topic does not accept a name.`);
@@ -513,8 +609,10 @@ function referenceResult(parsed) {
513
609
  return { ...result, topic: "component", component };
514
610
  }
515
611
  case "nav":
612
+ case "redirects":
516
613
  case "media":
517
614
  case "preview":
615
+ case "fixture":
518
616
  return { ...result, topic: "workflow", referenceKind: parsed.topic, reference: getWorkflowReference(parsed.topic) };
519
617
  case "theme":
520
618
  return { ...result, topic: "presentation", referenceKind: "theme", reference: getThemeReference() };
@@ -750,9 +848,16 @@ function parseArguments(arguments_) {
750
848
  return {
751
849
  mode: "run",
752
850
  verb: verb.name,
753
- // The smallest capability set this verb's requests need. It reaches the
754
- // exchange so a content push never holds deploy, and a pull never holds
755
- // delete (TR00645).
851
+ // The smallest capability set this verb's requests need — every permission
852
+ // the server resolves for them, not only the ones the verb's writes name.
853
+ // It reaches the exchange so a content push never holds deploy, and a pull
854
+ // never holds delete (TR00645). Two things count beyond the writes
855
+ // (TR00691): a read (nav push and deploy each list the site's pages first,
856
+ // and that list is gated on a Content permission), and a re-authorization
857
+ // of server-held state (deploy --production re-checks the promoted
858
+ // candidate's stored settings and navigation against a Design permission).
859
+ // `VERB_CAPABILITIES` below is what the tests pin against the routes each
860
+ // verb actually calls.
756
861
  capabilities: verb.capabilities,
757
862
  configPath,
758
863
  quiet,
@@ -811,10 +916,14 @@ export async function runCli({
811
916
  return 0;
812
917
  }
813
918
  if (parsed.mode === "reference") {
919
+ await assertCliCurrent(environment);
814
920
  const result = referenceResult(parsed);
815
921
  stdout.write(parsed.json ? `${serializeResult(result)}\n` : formatReferenceResult(result));
816
922
  return 0;
817
923
  }
924
+ if (VERSION_GATED_OFFLINE_VERBS.includes(parsed.verb)) {
925
+ await assertCliCurrent(environment);
926
+ }
818
927
  // Own properties only: a verb name must never resolve through the
819
928
  // prototype chain to something like `constructor`.
820
929
  const handler = Object.hasOwn(handlers, parsed.verb) ? handlers[parsed.verb] : undefined;
package/src/constants.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const CLI_NAME = "@taprootio/site-authoring";
2
- export const CLI_VERSION = "0.2.0";
2
+ export const CLI_VERSION = "0.4.0";
3
3
  export const CLI_BINARY_NAME = "taproot-site";
4
4
  export const RESULT_SCHEMA_VERSION = 1;
5
5
  export const CONFIG_FILE_NAME = "taproot-site.json";
@@ -82,6 +82,8 @@ export const VERB_LOGIN = "login";
82
82
  export const VERB_LOGOUT = "logout";
83
83
  export const VERB_PAGES_PUSH = "pages push";
84
84
  export const VERB_NAV_PUSH = "nav push";
85
+ export const VERB_REDIRECTS_PULL = "redirects pull";
86
+ export const VERB_REDIRECTS_PUSH = "redirects push";
85
87
  export const VERB_THEME_PUSH = "theme push";
86
88
  export const VERB_FOOTER_PUSH = "footer push";
87
89
  export const VERB_MEDIA_UPLOAD = "media upload";
@@ -99,7 +101,7 @@ export const DEPLOY_TARGET_STAGING = "staging";
99
101
  export const DEPLOY_TARGET_PRODUCTION = "production";
100
102
 
101
103
  /**
102
- * The refusal vocabulary TR00602/TR00603 speak, collapsed into the four
104
+ * The refusal vocabulary TR00602/TR00603/TR00691 speak, collapsed into the five
103
105
  * distinct client behaviors plus an explicit "we do not know" value. The CLI
104
106
  * keeps these apart because an agent's correct next move differs for each:
105
107
  * see `ApiError.refusalKind()` in transport.js.
@@ -108,6 +110,8 @@ export const REFUSAL_PLATFORM_PAUSED = "platform_paused";
108
110
  export const REFUSAL_CREDENTIAL_REJECTED = "credential_rejected";
109
111
  export const REFUSAL_PLAN_LIMIT = "plan_limit";
110
112
  export const REFUSAL_THROTTLED = "throttled";
113
+ export const REFUSAL_CAPABILITY_MISSING = "capability_missing";
114
+ export const REFUSAL_CLI_OUTDATED = "cli_outdated";
111
115
  export const REFUSAL_UNCLASSIFIED = "unclassified";
112
116
 
113
117
  export const REFUSAL_KINDS = Object.freeze([
@@ -115,6 +119,8 @@ export const REFUSAL_KINDS = Object.freeze([
115
119
  REFUSAL_CREDENTIAL_REJECTED,
116
120
  REFUSAL_PLAN_LIMIT,
117
121
  REFUSAL_THROTTLED,
122
+ REFUSAL_CAPABILITY_MISSING,
123
+ REFUSAL_CLI_OUTDATED,
118
124
  REFUSAL_UNCLASSIFIED,
119
125
  ]);
120
126
 
@@ -125,6 +131,53 @@ export const ROLLOUT_REFUSAL_FIELD = "SiteAuthoringRollout";
125
131
  export const CREDENTIAL_REFUSAL_FIELD = "ExternalApiKey";
126
132
  export const PLAN_LIMIT_REFUSAL_FIELD = "UpgradePrompt";
127
133
 
134
+ /**
135
+ * The field Taproot refuses an outdated CLI with (TR00703).
136
+ *
137
+ * Named so the name alone is the instruction, because for every CLI published
138
+ * before this check that is the whole message: an unknown refusal field prints
139
+ * as "Taproot rejected the request field '<name>'." and nothing else.
140
+ */
141
+ export const CLI_UPGRADE_REFUSAL_FIELD = "CliUpgradeRequired";
142
+
143
+ /**
144
+ * How an operator gets current. Composed from {@link CLI_NAME} so the command
145
+ * in a refusal cannot drift from the package that refusal is about.
146
+ */
147
+ export const CLI_UPGRADE_COMMAND = `npm install -g ${CLI_NAME}@latest`;
148
+
149
+ /**
150
+ * The platform switch behind every `platform_paused` refusal, and where the one
151
+ * person who can undo it goes to look (TR00692).
152
+ *
153
+ * Held here rather than written out at each site because four surfaces say it —
154
+ * the pre-write warning, `status`, `whoami`, and the refusal guidance — and a
155
+ * setting key spelled four ways is a key an operator cannot search for. The key
156
+ * is a wire identity and is never reworded; the location is display text.
157
+ */
158
+ export const EXTERNAL_WRITES_SETTING_KEY = "site_authoring.external_writes_enabled";
159
+ export const EXTERNAL_WRITES_SETTING_LOCATION =
160
+ "Admin → Platform settings → Site Authoring (\"External site authoring writes enabled\")";
161
+
162
+ /**
163
+ * The `google.rpc.ErrorInfo` reason a key-mode permission denial carries
164
+ * (TR00691, `SiteAuthoringKeyDenial` on the server). A wire identity, matched
165
+ * exactly.
166
+ */
167
+ export const CAPABILITY_REFUSAL_REASON = "SITE_AUTHORING_CAPABILITY_MISSING";
168
+
169
+ /**
170
+ * The `field` a capability refusal reports on the JSON error.
171
+ *
172
+ * The server sends this refusal as `ErrorInfo`, not as a field violation — the
173
+ * request is well formed and the credential is too narrow, so a `BadRequest`
174
+ * would misstate the status. Automation still wants one shape to branch on, so
175
+ * the CLI names the field itself, exactly as `platform_paused` carries
176
+ * `SiteAuthoringRollout`. It is the CLI's own label rather than a value read
177
+ * off the wire, which is why nothing matches an incoming field against it.
178
+ */
179
+ export const CAPABILITY_REFUSAL_FIELD = "GrantedCapabilities";
180
+
128
181
  // gRPC `ResourceExhausted`. Transcoded responses carry the numeric code in the
129
182
  // body; the HTTP mapping is 429.
130
183
  export const GRPC_RESOURCE_EXHAUSTED = 8;
@@ -144,6 +197,10 @@ export const LIMITS = Object.freeze({
144
197
  configDiscoveryParents: 32,
145
198
  workspacePathBytes: 4 * 1024,
146
199
  apiResponseBytes: 1024 * 1024,
200
+ // The redirect map is the one read whose valid size exceeds the ordinary
201
+ // response bound: two thousand entries with long absolute targets. Sized
202
+ // with the workspace file that holds it (WORKSPACE_LIMITS.redirectsBytes).
203
+ redirectMapResponseBytes: 8 * 1024 * 1024,
147
204
  requestMilliseconds: 60_000,
148
205
  uploadMilliseconds: 5 * 60_000,
149
206
  deploymentMilliseconds: 30 * 60_000,
@@ -0,0 +1,63 @@
1
+ import { hasControlCharacter } from "./errors.js";
2
+
3
+ /**
4
+ * The one rule for an outbound target on a footer link or a navigation item.
5
+ *
6
+ * A local business's phone number and address are the two things a visitor
7
+ * looks for in a header and a footer, and page links have accepted `tel:` and
8
+ * `mailto:` since D12. This is the same permissive scheme set the published
9
+ * renderer already applies to page content (`generator/src/url-safety.ts`,
10
+ * `shared/tiptap-prosemirror.ts`, `ux/client/src/common/sanitize-html.ts`),
11
+ * narrowed to what a *stored* target may be: absolute, so there is no base to
12
+ * resolve against at publish time, and credential-free, so a footer cannot
13
+ * carry someone's password into published HTML.
14
+ *
15
+ * `tel:` and `mailto:` are opaque URLs whose body is content — a number with
16
+ * its punctuation, an address with its plus-tag. Callers emit the value
17
+ * verbatim after this check rather than round-tripping it through
18
+ * `URL.toString()`, which re-encodes both.
19
+ */
20
+
21
+ /** Longest external target a footer link or a navigation item may carry. */
22
+ export const CONTACT_OR_WEB_URL_MAX_LENGTH = 2_048;
23
+
24
+ /**
25
+ * The requirement clause every layer says when it refuses a target, so the
26
+ * CLI, the editors, and the server all name the same four schemes.
27
+ */
28
+ export const CONTACT_OR_WEB_URL_REQUIREMENT =
29
+ "must be an absolute http or https URL without credentials, or a mailto or tel contact URL";
30
+
31
+ const WEB_PROTOCOLS = new Set(["http:", "https:"]);
32
+ const CONTACT_PROTOCOLS = new Set(["mailto:", "tel:"]);
33
+
34
+ /**
35
+ * Whether `value` is a target the footer and navigation contracts accept.
36
+ *
37
+ * ASCII controls, C1 controls, and backslashes are refused before the scheme
38
+ * is looked at: browsers normalize them inconsistently, and a line break in a
39
+ * `mailto:` is the header-injection vector.
40
+ */
41
+ export function isContactOrWebUrl(value, maximumLength = CONTACT_OR_WEB_URL_MAX_LENGTH) {
42
+ if (typeof value !== "string" || value.length === 0 || value.length > maximumLength) return false;
43
+ if (hasControlCharacter(value) || value.includes("\\")) return false;
44
+
45
+ let parsed;
46
+ try {
47
+ parsed = new URL(value);
48
+ } catch {
49
+ return false;
50
+ }
51
+
52
+ if (WEB_PROTOCOLS.has(parsed.protocol)) {
53
+ return parsed.username === "" && parsed.password === "" && parsed.hostname !== "";
54
+ }
55
+ // The body is everything after `tel:` or `mailto:`; an empty one is a link
56
+ // to nothing, which publishes as a dead anchor rather than a refusal. A
57
+ // body that opens with `//` is an authority, which a contact URL has no use
58
+ // for, and the four layers agree on refusing it rather than on how each
59
+ // parser validates one.
60
+ return CONTACT_PROTOCOLS.has(parsed.protocol)
61
+ && parsed.pathname !== ""
62
+ && !value.slice(value.indexOf(":") + 1).startsWith("//");
63
+ }
@@ -149,7 +149,11 @@ const CTA = Object.freeze({
149
149
  buttonText: str(),
150
150
  buttonUrl: actionUrl(),
151
151
  variant: enumOf("primary", "danger"),
152
- borderWidth: num({ integer: true, minimum: 0 }),
152
+ borderWidth: num({
153
+ integer: true,
154
+ minimum: 0,
155
+ description: "Pixel border around the call-to-action panel; 0 leaves it unbordered.",
156
+ }),
153
157
  imagePosition: enumOf("top", "left", "right"),
154
158
  mediaImage: image(),
155
159
  });
@@ -167,7 +171,11 @@ const FEATURE_GRID = Object.freeze({
167
171
  ),
168
172
  columns: num({ values: Object.freeze([2, 3, 4]) }),
169
173
  iconSize: enumOf("small", "medium", "large", "xlarge"),
170
- borderWidth: num({ integer: true, minimum: 0 }),
174
+ borderWidth: num({
175
+ integer: true,
176
+ minimum: 0,
177
+ description: "Pixel border around each item, as on a card grid; the grid itself is never framed. 0 boxes nothing.",
178
+ }),
171
179
  });
172
180
 
173
181
  /** `TestimonialData` and its `TestimonialItem`. */
@@ -184,7 +192,11 @@ const TESTIMONIAL = Object.freeze({
184
192
  columns: num({ values: Object.freeze([1, 2, 3]) }),
185
193
  carousel: bool(),
186
194
  interval: num({ integer: true, minimum: 2000 }),
187
- borderWidth: num({ integer: true, minimum: 0 }),
195
+ borderWidth: num({
196
+ integer: true,
197
+ minimum: 0,
198
+ description: "Pixel border around the whole set of quotations — the grid or the carousel; 0 leaves it unbordered.",
199
+ }),
188
200
  });
189
201
 
190
202
  /** `LatestPostsData` — resolved at generation time. */
@@ -212,7 +224,11 @@ const CARD_GRID = Object.freeze({
212
224
  { kind: "value", value: Object.freeze([]) },
213
225
  ),
214
226
  columns: num({ values: Object.freeze([2, 3]) }),
215
- borderWidth: num({ integer: true, minimum: 0 }),
227
+ borderWidth: num({
228
+ integer: true,
229
+ minimum: 0,
230
+ description: "Pixel border around each card; the grid itself is never framed. 0 boxes nothing.",
231
+ }),
216
232
  });
217
233
 
218
234
  /** `ImageBannerData`. */
@@ -372,6 +388,7 @@ const COMPONENT_DEFINITIONS = Object.freeze({
372
388
  [
373
389
  "Each linked item needs a title that remains meaningful as link text.",
374
390
  "Icon images need descriptive alt text when they convey information; otherwise use an empty alt value.",
391
+ "Item title and description are plain text: an item's url makes the whole item the link, and no link can be placed inside the text. Put linked prose in a paragraph beside the grid.",
375
392
  ],
376
393
  {
377
394
  items: [
@@ -428,6 +445,7 @@ const COMPONENT_DEFINITIONS = Object.freeze({
428
445
  [
429
446
  "Every linked card needs a title that describes its destination.",
430
447
  "Card images need descriptive alt text unless they are decorative.",
448
+ "Card title and description are plain text: a card's linkUrl makes the whole card the link, and no link can be placed inside the text. Put linked prose in a paragraph beside the grid.",
431
449
  ],
432
450
  {
433
451
  cards: [
@@ -544,6 +562,10 @@ function referenceSchema(spec) {
544
562
  if (spec.values) schema.enum = [...spec.values];
545
563
  if (spec.minimum !== undefined) schema.minimum = spec.minimum;
546
564
  if (spec.maximum !== undefined) schema.maximum = spec.maximum;
565
+ // A numeric field whose units say nothing about what the number does —
566
+ // `borderWidth` is the case — carries its meaning here so `help
567
+ // component` prints it beside the constraints.
568
+ if (spec.description) schema.description = spec.description;
547
569
  return schema;
548
570
  }
549
571
  case "array":
@@ -370,7 +370,7 @@
370
370
  "inlineFacts": {
371
371
  "nodeType": "inlineFacts",
372
372
  "label": "Inline facts",
373
- "help": "A compact ordered row of labeled facts whose values may be native links.",
373
+ "help": "A compact ordered row of facts whose values may be native links. A fact that names itself needs no label.",
374
374
  "attrs": {
375
375
  "items": {
376
376
  "type": "array",
@@ -388,7 +388,8 @@
388
388
  },
389
389
  "label": {
390
390
  "type": "plain-text",
391
- "required": true,
391
+ "required": false,
392
+ "default": null,
392
393
  "minScalars": 1,
393
394
  "maxScalars": 120,
394
395
  "nonWhitespace": true
@@ -64,9 +64,13 @@ export function normalizeInlineFactsItems(
64
64
  }
65
65
  }
66
66
 
67
+ // The value is the fact; the label names it when the value does not name
68
+ // itself. An omitted or null label is the standalone fact, while an empty
69
+ // string stays refused so a half-authored item is still caught.
67
70
  for (const name of ["value", "label"]) {
68
71
  const field = definition.fields[name];
69
72
  const entry = item[name];
73
+ if (!field.required && (entry === undefined || entry === null)) continue;
70
74
  if (typeof entry !== "string" || (field.nonWhitespace && entry.trim() === "")) {
71
75
  report(`${itemPath}/${name}`, CODES.attrInvalid, `Inline fact ${name} must contain non-whitespace text.`);
72
76
  } else if (scalarLength(entry, field.maxScalars) > field.maxScalars) {
@@ -88,7 +92,7 @@ export function normalizeInlineFactsItems(
88
92
 
89
93
  items.push(Object.freeze({
90
94
  value: item.value,
91
- label: item.label,
95
+ ...(typeof item.label === "string" ? { label: item.label } : {}),
92
96
  ...(typeof item.url === "string" ? { url: item.url.trim() } : {}),
93
97
  }));
94
98
  });
@@ -496,10 +496,20 @@ function countUnicodeScalars(value: string): number {
496
496
 
497
497
  type NormalizedInlineFact = {
498
498
  value: string;
499
- label: string;
499
+ label?: string;
500
500
  url?: string;
501
501
  };
502
502
 
503
+ /**
504
+ * The row is a list, not a description list: a fact whose value names itself
505
+ * carries no label, and `dl` has no valid shape for a `dd` without a `dt`.
506
+ * The label keeps its leading source position so the CSS `order` rules that
507
+ * lift the value above it stay unchanged for a labelled fact. The list roles
508
+ * are explicit because `list-style: none` makes WebKit drop them, and the
509
+ * label is separated from the value by a space: search indexers take adjacent
510
+ * inline elements as one word, and a whitespace-only anonymous flex item is
511
+ * not rendered, so the row looks the same.
512
+ */
503
513
  function renderInlineFacts(
504
514
  node: ProseMirrorNode,
505
515
  options: RenderProseMirrorOptions,
@@ -512,17 +522,18 @@ function renderInlineFacts(
512
522
  const value = fact.url
513
523
  ? renderElement("a", { href: fact.url }, escapeHtml(fact.value))
514
524
  : escapeHtml(fact.value);
525
+ const label = fact.label === undefined
526
+ ? ""
527
+ : `${renderElement("span", { class: "taproot-inline-fact__label" }, escapeHtml(fact.label))} `;
515
528
  return renderElement(
516
- "div",
517
- { class: "taproot-inline-fact" },
518
- `${renderElement("dt", { class: "taproot-inline-fact__label" }, escapeHtml(fact.label))}${
519
- renderElement("dd", { class: "taproot-inline-fact__value" }, value)
520
- }`,
529
+ "li",
530
+ { class: "taproot-inline-fact", role: "listitem" },
531
+ `${label}${renderElement("span", { class: "taproot-inline-fact__value" }, value)}`,
521
532
  );
522
533
  }).join("");
523
534
  return renderElement(
524
- "dl",
525
- withProseMeasure({ class: "taproot-inline-facts" }, node.type, options),
535
+ "ul",
536
+ withProseMeasure({ class: "taproot-inline-facts", role: "list" }, node.type, options),
526
537
  items,
527
538
  );
528
539
  }
@@ -544,14 +555,17 @@ function normalizeInlineFacts(node: ProseMirrorNode): NormalizedInlineFact[] | u
544
555
  for (const value of node.attrs.items) {
545
556
  if (!isRecord(value) || !hasOnlyKeys(value, fieldKeys)) return undefined;
546
557
  const factValue = stringAttr(value.value);
547
- const label = stringAttr(value.label);
548
558
  if (
549
559
  !factValue
550
- || !label
551
560
  || factValue.trim() === ""
552
- || label.trim() === ""
553
561
  || countUnicodeScalars(factValue) > definition.fields.value.maxScalars
554
- || countUnicodeScalars(label) > definition.fields.label.maxScalars
562
+ ) return undefined;
563
+ // An omitted or null label is the standalone fact; an empty string is a
564
+ // half-authored one and still fails the row closed.
565
+ const label = value.label === undefined || value.label === null ? undefined : stringAttr(value.label);
566
+ if (
567
+ value.label !== undefined && value.label !== null
568
+ && (!label || label.trim() === "" || countUnicodeScalars(label) > definition.fields.label.maxScalars)
555
569
  ) return undefined;
556
570
  const url = value.url === undefined || value.url === null ? undefined : stringAttr(value.url);
557
571
  if (value.url !== undefined && value.url !== null && (!url || !isSafeUrl(url))) return undefined;