@taprootio/site-authoring 0.2.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
5
5
  "type": "module",
6
6
  "license": "ISC",
@@ -22,6 +22,7 @@
22
22
  },
23
23
  "files": [
24
24
  "bin/",
25
+ "examples/",
25
26
  "src/",
26
27
  "LICENSE",
27
28
  "README.md"
package/src/api.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import {
2
2
  CANONICAL_TIMESTAMP,
3
+ CAPABILITY_REFUSAL_FIELD,
3
4
  LIMITS,
5
+ REFUSAL_CAPABILITY_MISSING,
4
6
  REFUSAL_CREDENTIAL_REJECTED,
5
7
  REFUSAL_PLAN_LIMIT,
6
8
  REFUSAL_PLATFORM_PAUSED,
@@ -329,6 +331,32 @@ const REFUSAL_GUIDANCE = Object.freeze({
329
331
  export function announceRefusal(error, onProgress, action = "request") {
330
332
  if (!(error instanceof ApiError)) return;
331
333
  const kind = error.refusalKind();
334
+ if (kind === REFUSAL_CAPABILITY_MISSING) {
335
+ const { permission, granted, required } = error.capability;
336
+ onProgress("");
337
+ onProgress(`CAPABILITY MISSING (refusal=${REFUSAL_CAPABILITY_MISSING}, field=${CAPABILITY_REFUSAL_FIELD})`);
338
+ onProgress(
339
+ ` Taproot refused this ${action} because the credential it runs on was not exchanged for a capability `
340
+ + `that grants '${permission}'.`,
341
+ );
342
+ onProgress(` Granted: ${granted.length > 0 ? granted.join(", ") : "(none)"}.`);
343
+ onProgress(
344
+ required.length > 0
345
+ ? ` Carried by: ${required.join(", ")}.`
346
+ : " Carried by: no site-authoring capability — this permission is not reachable by any credential.",
347
+ );
348
+ // The credential is exactly what was asked for, so re-issuing it changes
349
+ // nothing. What is wrong is the verb's declared capability set, and saying
350
+ // so is the whole point of the named refusal: the first agent to meet this
351
+ // had to read the package source to find out.
352
+ onProgress(
353
+ ` The credential is not the problem: the exchange minted precisely what the ${action} asked for. `
354
+ + "The verb's declared capability set in the CLI's verb table is short of the requests it makes; "
355
+ + "report it rather than minting a wider key.",
356
+ );
357
+ onProgress("");
358
+ return;
359
+ }
332
360
  if (kind === REFUSAL_PLAN_LIMIT) {
333
361
  onProgress("");
334
362
  onProgress("PLAN LIMIT (refusal=plan_limit, field=UpgradePrompt)");
package/src/cli.js CHANGED
@@ -75,7 +75,9 @@ const VERBS = Object.freeze([
75
75
  offline: true,
76
76
  note: "Reads manifest.fixture.json plus the fixture's page, navigation, theme, appearance, header, brand, and footer files. "
77
77
  + "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.",
78
+ + "persisted round trips, or rendering; run a real pull and authorized preview before deployment. "
79
+ + `See '${CLI_BINARY_NAME} help fixture' for the manifest contract and for the path of the complete example `
80
+ + "fixture this package ships, which needs no credential and no pulled site.",
79
81
  },
80
82
  {
81
83
  name: VERB_LOGIN,
@@ -210,7 +212,10 @@ const VERBS = Object.freeze([
210
212
  },
211
213
  {
212
214
  name: VERB_NAV_PUSH,
213
- capabilities: [CAPABILITY_DESIGN],
215
+ // Content is for the read, not the write: every PAGE nav item is checked
216
+ // against the live page list before the tree is replaced, and that list is
217
+ // gated on site.pages.edit_any (TR00691).
218
+ capabilities: [CAPABILITY_CONTENT, CAPABILITY_DESIGN],
214
219
  tokens: ["nav", "push"],
215
220
  summary: "Replace the whole navigation tree from the local workspace.",
216
221
  },
@@ -251,7 +256,18 @@ const VERBS = Object.freeze([
251
256
  },
252
257
  {
253
258
  name: VERB_DEPLOY,
254
- capabilities: [CAPABILITY_DEPLOYMENTS],
259
+ // Deployments is the only capability this verb *writes* with; the other two
260
+ // are for reads and re-reads the server makes on its behalf (TR00691).
261
+ // Content: --staging builds the candidate from the live page list
262
+ // (DeploySiteRequest.staged_page_ids names the ids explicitly), and that
263
+ // list is gated on site.pages.edit_any.
264
+ // Design: --production promotes a completed staging deployment, and the
265
+ // pipeline re-authorizes that deployment's *stored* candidate — every
266
+ // selected settings group and its navigation — against site.theme.manage,
267
+ // which only Design carries. Every candidate this CLI stages from a pulled
268
+ // workspace carries settings, navigation, or both, so a promotion without
269
+ // Design is refused.
270
+ capabilities: [CAPABILITY_CONTENT, CAPABILITY_DESIGN, CAPABILITY_DEPLOYMENTS],
255
271
  tokens: ["deploy"],
256
272
  summary: "Deploy to staging, or promote staging to production.",
257
273
  target: true,
@@ -265,6 +281,8 @@ const VERBS = Object.freeze([
265
281
  json: true,
266
282
  note: "Select the persisted draft by page path (as recorded by pull) or canonical lowercase UUID. "
267
283
  + "The homepage is recorded with an empty path, so address it as '/'. "
284
+ + "Preview before approving: approve consumes the draft, so a page that has been approved has no draft "
285
+ + "left to render and answers preview.no_draft. Review it on staging after a deploy instead. "
268
286
  + "The handoff URL in the result is single-use and expires two minutes after it is minted: opening it "
269
287
  + "consumes it, and a reused, shared, or bookmarked preview URL answers Not found. Run preview page again "
270
288
  + "for another. "
@@ -293,6 +311,23 @@ const VERBS = Object.freeze([
293
311
  },
294
312
  ]);
295
313
 
314
+ /**
315
+ * The shipped verb table's capability declarations, keyed by verb name.
316
+ *
317
+ * Exported so the tests enforce the table rather than restate it. The claim
318
+ * each entry makes — "this is the smallest set this verb's requests need" —
319
+ * went four releases without anything checking it, and it was wrong twice:
320
+ * `nav push` and `deploy` were each short of a read they make on every run, and
321
+ * `deploy` was short again of the promotion re-authorization the server runs on
322
+ * state the request only names (TR00691). A verb with no entry needs no site
323
+ * credential at all.
324
+ */
325
+ export const VERB_CAPABILITIES = Object.freeze(Object.fromEntries(
326
+ VERBS
327
+ .filter((verb) => verb.capabilities !== undefined)
328
+ .map((verb) => [verb.name, Object.freeze([...verb.capabilities])]),
329
+ ));
330
+
296
331
  const COMMON_OPTIONS = `Options:
297
332
  --config <path> Place before a config-reading verb; bypass parent discovery.
298
333
  --quiet Suppress human progress. The JSON result is unchanged.
@@ -464,6 +499,7 @@ function parseReferenceArguments(arguments_) {
464
499
  || topic === "theme"
465
500
  || topic === "appearance"
466
501
  || topic === "footer"
502
+ || topic === "fixture"
467
503
  )
468
504
  && (subject !== undefined || extra.length > 0)) {
469
505
  throw usageError("help.usage", `The '${topic}' topic does not accept a name.`);
@@ -515,6 +551,7 @@ function referenceResult(parsed) {
515
551
  case "nav":
516
552
  case "media":
517
553
  case "preview":
554
+ case "fixture":
518
555
  return { ...result, topic: "workflow", referenceKind: parsed.topic, reference: getWorkflowReference(parsed.topic) };
519
556
  case "theme":
520
557
  return { ...result, topic: "presentation", referenceKind: "theme", reference: getThemeReference() };
@@ -750,9 +787,16 @@ function parseArguments(arguments_) {
750
787
  return {
751
788
  mode: "run",
752
789
  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).
790
+ // The smallest capability set this verb's requests need — every permission
791
+ // the server resolves for them, not only the ones the verb's writes name.
792
+ // It reaches the exchange so a content push never holds deploy, and a pull
793
+ // never holds delete (TR00645). Two things count beyond the writes
794
+ // (TR00691): a read (nav push and deploy each list the site's pages first,
795
+ // and that list is gated on a Content permission), and a re-authorization
796
+ // of server-held state (deploy --production re-checks the promoted
797
+ // candidate's stored settings and navigation against a Design permission).
798
+ // `VERB_CAPABILITIES` below is what the tests pin against the routes each
799
+ // verb actually calls.
756
800
  capabilities: verb.capabilities,
757
801
  configPath,
758
802
  quiet,
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.3.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";
@@ -99,7 +99,7 @@ export const DEPLOY_TARGET_STAGING = "staging";
99
99
  export const DEPLOY_TARGET_PRODUCTION = "production";
100
100
 
101
101
  /**
102
- * The refusal vocabulary TR00602/TR00603 speak, collapsed into the four
102
+ * The refusal vocabulary TR00602/TR00603/TR00691 speak, collapsed into the five
103
103
  * distinct client behaviors plus an explicit "we do not know" value. The CLI
104
104
  * keeps these apart because an agent's correct next move differs for each:
105
105
  * see `ApiError.refusalKind()` in transport.js.
@@ -108,6 +108,7 @@ export const REFUSAL_PLATFORM_PAUSED = "platform_paused";
108
108
  export const REFUSAL_CREDENTIAL_REJECTED = "credential_rejected";
109
109
  export const REFUSAL_PLAN_LIMIT = "plan_limit";
110
110
  export const REFUSAL_THROTTLED = "throttled";
111
+ export const REFUSAL_CAPABILITY_MISSING = "capability_missing";
111
112
  export const REFUSAL_UNCLASSIFIED = "unclassified";
112
113
 
113
114
  export const REFUSAL_KINDS = Object.freeze([
@@ -115,6 +116,7 @@ export const REFUSAL_KINDS = Object.freeze([
115
116
  REFUSAL_CREDENTIAL_REJECTED,
116
117
  REFUSAL_PLAN_LIMIT,
117
118
  REFUSAL_THROTTLED,
119
+ REFUSAL_CAPABILITY_MISSING,
118
120
  REFUSAL_UNCLASSIFIED,
119
121
  ]);
120
122
 
@@ -125,6 +127,25 @@ export const ROLLOUT_REFUSAL_FIELD = "SiteAuthoringRollout";
125
127
  export const CREDENTIAL_REFUSAL_FIELD = "ExternalApiKey";
126
128
  export const PLAN_LIMIT_REFUSAL_FIELD = "UpgradePrompt";
127
129
 
130
+ /**
131
+ * The `google.rpc.ErrorInfo` reason a key-mode permission denial carries
132
+ * (TR00691, `SiteAuthoringKeyDenial` on the server). A wire identity, matched
133
+ * exactly.
134
+ */
135
+ export const CAPABILITY_REFUSAL_REASON = "SITE_AUTHORING_CAPABILITY_MISSING";
136
+
137
+ /**
138
+ * The `field` a capability refusal reports on the JSON error.
139
+ *
140
+ * The server sends this refusal as `ErrorInfo`, not as a field violation — the
141
+ * request is well formed and the credential is too narrow, so a `BadRequest`
142
+ * would misstate the status. Automation still wants one shape to branch on, so
143
+ * the CLI names the field itself, exactly as `platform_paused` carries
144
+ * `SiteAuthoringRollout`. It is the CLI's own label rather than a value read
145
+ * off the wire, which is why nothing matches an incoming field against it.
146
+ */
147
+ export const CAPABILITY_REFUSAL_FIELD = "GrantedCapabilities";
148
+
128
149
  // gRPC `ResourceExhausted`. Transcoded responses carry the numeric code in the
129
150
  // body; the HTTP mapping is 429.
130
151
  export const GRPC_RESOURCE_EXHAUSTED = 8;
@@ -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;
@@ -0,0 +1,63 @@
1
+ import { fileURLToPath } from "node:url";
2
+
3
+ /**
4
+ * The offline authoring-fixture contract (TR00647).
5
+ *
6
+ * `validate` enforces this; `help fixture` states it. Both read the constants
7
+ * below rather than restating them, so a change to what the verb requires
8
+ * cannot leave the reference describing the previous contract. The one thing
9
+ * this module deliberately does not own is the *page* entry's key set: a
10
+ * fixture page entry is a `pull` manifest entry, and `validate` reads the
11
+ * fields it needs rather than closing the shape, so a fixture written by a
12
+ * newer `pull` is not refused for carrying a field this release ignores.
13
+ */
14
+
15
+ export const FIXTURE_MANIFEST_FILE_NAME = "manifest.fixture.json";
16
+ export const FIXTURE_CONTRACT_VERSION = 1;
17
+
18
+ /** Root fields whose absence or wrong value fails validation. */
19
+ export const FIXTURE_REQUIRED_ROOT_FIELDS = Object.freeze([
20
+ "manifestVersion",
21
+ "siteId",
22
+ "pages",
23
+ "pagesTruncated",
24
+ "navigation",
25
+ "settings",
26
+ "settingsSkipped",
27
+ "fixture",
28
+ ]);
29
+
30
+ /**
31
+ * Root fields `pull` writes that a fixture may carry unchanged. They are
32
+ * accepted and read for nothing: a fixture proves local structure, and neither
33
+ * the snapshot time nor a recorded deployment can be checked without a server.
34
+ */
35
+ export const FIXTURE_OPTIONAL_ROOT_FIELDS = Object.freeze(["pulledAt", "deployments"]);
36
+
37
+ /** Every root field the manifest may declare. Anything else is refused. */
38
+ export const FIXTURE_ROOT_FIELDS = Object.freeze(
39
+ [...FIXTURE_REQUIRED_ROOT_FIELDS, ...FIXTURE_OPTIONAL_ROOT_FIELDS].sort(),
40
+ );
41
+
42
+ /** Every field the versioned `fixture` block may declare. All are required. */
43
+ export const FIXTURE_METADATA_FIELDS = Object.freeze(["contractVersion", "imageIds", "deliveryOrigins"]);
44
+
45
+ /**
46
+ * Delivery origins are restricted to the reserved example domain rather than
47
+ * merely to HTTPS. A fixture is example data that ships and is copied; a real
48
+ * delivery host in one would be a live reference in every copy of it.
49
+ */
50
+ export const FIXTURE_DELIVERY_ORIGIN_DOMAIN = "example.test";
51
+ export const FIXTURE_MAXIMUM_DELIVERY_ORIGINS = 100;
52
+
53
+ /** The complete example fixture this package ships. */
54
+ export const SHIPPED_FIXTURE_NAME = "riverbend-wellness";
55
+
56
+ /**
57
+ * The absolute path of the shipped fixture, resolved from this module rather
58
+ * than from the caller's directory, so it is correct for a global install as
59
+ * well as a checkout. `help fixture` reports it and `validate` accepts it.
60
+ */
61
+ export function shippedFixtureDirectory() {
62
+ return fileURLToPath(new URL(`../examples/${SHIPPED_FIXTURE_NAME}/`, import.meta.url)).replace(/[\\/]$/u, "");
63
+ }
@@ -1,3 +1,8 @@
1
+ import {
2
+ CONTACT_OR_WEB_URL_MAX_LENGTH,
3
+ CONTACT_OR_WEB_URL_REQUIREMENT,
4
+ isContactOrWebUrl,
5
+ } from "./contact-url.js";
1
6
  import { hasControlCharacter, isCanonicalUuid, SiteAuthoringError } from "./errors.js";
2
7
  import { FOOTER_COLOR_TOKENS, requireAppearanceColor } from "./theme-validation.js";
3
8
 
@@ -15,7 +20,7 @@ export const FOOTER_LIMITS = Object.freeze({
15
20
  linkLabelLength: 120,
16
21
  bottomTextLength: 500,
17
22
  featureImageAltLength: 300,
18
- externalUrlLength: 2_048,
23
+ externalUrlLength: CONTACT_OR_WEB_URL_MAX_LENGTH,
19
24
  backgroundRepeatHeightMinimum: 64,
20
25
  backgroundRepeatHeightMaximum: 512,
21
26
  backgroundRepeatHeightStep: 16,
@@ -198,33 +203,17 @@ function stableId(value, path, seen, label) {
198
203
  return value;
199
204
  }
200
205
 
206
+ // A `tel:` or `mailto:` target is returned exactly as authored: the number's
207
+ // punctuation and the address's plus-tag are content, and `URL.toString()`
208
+ // re-encodes both. Only the whitespace the server would strip is removed.
201
209
  function safeExternalUrl(value, path, tolerateStoredValues) {
202
210
  if (typeof value !== "string") {
203
- fail("footer.url_invalid", `${path} must be a bounded absolute http or https URL.`, path);
211
+ fail("footer.url_invalid", `${path} ${CONTACT_OR_WEB_URL_REQUIREMENT}.`, path);
204
212
  }
205
213
  const normalized = trimLikeServer(value);
206
214
  if (tolerateStoredValues) return normalized;
207
- if (
208
- normalized.length === 0
209
- || normalized.length > FOOTER_LIMITS.externalUrlLength
210
- || hasControlCharacter(normalized)
211
- || normalized.includes("\\")
212
- ) {
213
- fail("footer.url_invalid", `${path} must be a bounded absolute http or https URL.`, path);
214
- }
215
- let parsed;
216
- try {
217
- parsed = new URL(normalized);
218
- } catch {
219
- fail("footer.url_invalid", `${path} must be an absolute http or https URL.`, path);
220
- }
221
- if (
222
- (parsed.protocol !== "http:" && parsed.protocol !== "https:")
223
- || parsed.username !== ""
224
- || parsed.password !== ""
225
- || parsed.hostname === ""
226
- ) {
227
- fail("footer.url_invalid", `${path} must use http or https without embedded credentials.`, path);
215
+ if (!isContactOrWebUrl(normalized, FOOTER_LIMITS.externalUrlLength)) {
216
+ fail("footer.url_invalid", `${path} ${CONTACT_OR_WEB_URL_REQUIREMENT}.`, path);
228
217
  }
229
218
  return normalized;
230
219
  }
@@ -618,16 +607,25 @@ export function footerImageIds(value) {
618
607
  export const FOOTER_EXAMPLE = Object.freeze({
619
608
  enabled: true,
620
609
  showBrand: true,
610
+ // The aside CTA is where a local business puts the thing a visitor came for.
611
+ // A phone number is a `tel:` target, emitted verbatim so it stays dialable.
621
612
  asideCta: Object.freeze({
622
613
  id: "103e726d-3152-49c8-bf14-c947b1bd8a14",
623
- label: "Talk with us",
624
- externalUrl: "https://example.com/contact",
614
+ label: "Call the studio",
615
+ externalUrl: "tel:+15555550123",
625
616
  }),
626
- bottomLinks: Object.freeze([Object.freeze({
627
- id: "28363e08-b341-42fc-bc83-8dd73fa87904",
628
- label: "Privacy",
629
- externalUrl: "https://example.com/privacy",
630
- })]),
617
+ bottomLinks: Object.freeze([
618
+ Object.freeze({
619
+ id: "28363e08-b341-42fc-bc83-8dd73fa87904",
620
+ label: "Privacy",
621
+ externalUrl: "https://example.com/privacy",
622
+ }),
623
+ Object.freeze({
624
+ id: "9e2d1f04-4bb0-4a2d-9d1a-6d3b0d0a4c77",
625
+ label: "Email us",
626
+ externalUrl: "mailto:hello@example.com",
627
+ }),
628
+ ]),
631
629
  light: Object.freeze({
632
630
  backgroundColor: "--esp-color-layer-1",
633
631
  textColor: "--esp-color-text",
@@ -412,7 +412,7 @@ const FOOTER_FIELD_DETAILS = Object.freeze({
412
412
  "FooterLink.label": { type: "plain-text", required: true, maximumLength: FOOTER_LIMITS.linkLabelLength },
413
413
  "FooterLink.pageResourceId": { type: "tracked-page-resource-uuid", exclusiveWith: "externalUrl" },
414
414
  "FooterLink.externalUrl": {
415
- type: "absolute-http-or-https-url",
415
+ type: "contact-or-web-url",
416
416
  exclusiveWith: "pageResourceId",
417
417
  maximumLength: FOOTER_LIMITS.externalUrlLength,
418
418
  },
@@ -425,7 +425,7 @@ const FOOTER_FIELD_DETAILS = Object.freeze({
425
425
  "FooterRichTextRun.link": { type: "FooterInlineLink|null", default: null },
426
426
  "FooterInlineLink.pageResourceId": { type: "tracked-page-resource-uuid", exclusiveWith: "externalUrl" },
427
427
  "FooterInlineLink.externalUrl": {
428
- type: "absolute-http-or-https-url",
428
+ type: "contact-or-web-url",
429
429
  exclusiveWith: "pageResourceId",
430
430
  maximumLength: FOOTER_LIMITS.externalUrlLength,
431
431
  },
@@ -507,7 +507,9 @@ export function getFooterReference() {
507
507
  totalGroupMaximum: FOOTER_LIMITS.linkGroups,
508
508
  totalRichTextRunsMaximum: FOOTER_LIMITS.richTextRuns,
509
509
  targetRule:
510
- "Each link has exactly one target: a tracked pageResourceId, or an absolute credential-free http/https externalUrl.",
510
+ "Each link has exactly one target: a tracked pageResourceId, or an externalUrl that is an absolute "
511
+ + "credential-free http/https URL, or a mailto:/tel: contact URL such as tel:+15555550123. A contact URL is "
512
+ + "published exactly as authored, so write the number the way it should be dialled.",
511
513
  imageRule:
512
514
  "Image ids come from pull or media upload. Feature images require alt text; background art is decorative.",
513
515
  readOnlyProjections: FOOTER_READ_ONLY_FIELDS,