@taprootio/site-authoring 0.6.2 → 0.7.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,4 @@
1
+ import { CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS, CAPABILITY_DESIGN } from "./capabilities.js";
1
2
  import {
2
3
  CLI_BINARY_NAME,
3
4
  CLI_NAME,
@@ -23,6 +24,7 @@ import {
23
24
  VERB_PREVIEW_PAGE,
24
25
  VERB_PREVIEW_REVOKE,
25
26
  VERB_PULL,
27
+ VERB_REDIRECTS_CHECK,
26
28
  VERB_REDIRECTS_PULL,
27
29
  VERB_REDIRECTS_PUSH,
28
30
  VERB_SITES,
@@ -54,11 +56,6 @@ import {
54
56
  REFERENCE_TOPICS,
55
57
  REFERENCE_VERSION,
56
58
  } from "./reference-help.js";
57
- import {
58
- CAPABILITY_CONTENT,
59
- CAPABILITY_DEPLOYMENTS,
60
- CAPABILITY_DESIGN,
61
- } from "./capabilities.js";
62
59
  import { assertCliCurrent } from "./session.js";
63
60
  import { VERB_HANDLERS } from "./verbs/index.js";
64
61
 
@@ -86,14 +83,16 @@ const VERBS = Object.freeze([
86
83
  {
87
84
  name: VERB_VALIDATE,
88
85
  tokens: ["validate"],
89
- summary: "Validate a complete authoring fixture without credentials or mutation.",
86
+ summary: "Validate an offline fixture, or initialize one from a pulled workspace with --init.",
90
87
  positionals: "fixturePath",
91
88
  offline: true,
92
- note: "Reads manifest.fixture.json plus the fixture's page, navigation, theme, appearance, header, brand, and footer files. "
89
+ note:
90
+ "Reads manifest.fixture.json plus the fixture's page, navigation, theme, appearance, header, brand, and footer files. "
93
91
  + "This proves local structure and semantics only. It does not prove authorization, live site ownership, concurrency, "
94
92
  + "persisted round trips, or rendering; run a real pull and authorized preview before deployment. "
95
93
  + `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.",
94
+ + "fixture this package ships, which needs no credential and no pulled site. "
95
+ + "With --init, the directory is a new destination; the current pulled workspace or --config supplies the source.",
97
96
  },
98
97
  {
99
98
  name: VERB_LOGIN,
@@ -200,7 +199,9 @@ const VERBS = Object.freeze([
200
199
  + "Markdown is deliberately one-way, so a page edited on the site since the last pull cannot be rewritten as "
201
200
  + "Markdown: pull refuses with pages.pull_conflict before changing anything under 'pages/', preserves the "
202
201
  + "site's version under '.taproot-site-state/', and leaves you to either push the local source or delete it "
203
- + "and pull again. A locally edited '.pm.json' is kept rather than overwritten for the same reason.",
202
+ + "and pull again. A locally edited '.pm.json' is kept rather than overwritten for the same reason. "
203
+ + "First revisions adopted without a completed body comparison are counted in pages.revisionsRecordedWithoutBodyComparison "
204
+ + "and announced on the human channel. New downloads and compared bodies are excluded.",
204
205
  },
205
206
  {
206
207
  name: VERB_PAGES_PUSH,
@@ -209,7 +210,8 @@ const VERBS = Object.freeze([
209
210
  summary: "Create and update pages from the local workspace.",
210
211
  positionals: "pagePaths",
211
212
  allowRawHtml: true,
212
- note: "Positional page paths narrow the push to those pages; with none, every workspace page is validated and sent. "
213
+ note:
214
+ "Positional page paths narrow the push to those pages; with none, every workspace page is validated and sent. "
213
215
  + "The homepage is recorded with an empty path, so address it as '/'. "
214
216
  + "A selected path resolves to its one authoritative source from metadata alone, and that page is then validated "
215
217
  + "exactly as a whole push would validate it — site binding, manifest integrity, live create-or-update "
@@ -236,6 +238,14 @@ const VERBS = Object.freeze([
236
238
  tokens: ["nav", "push"],
237
239
  summary: "Replace the whole navigation tree from the local workspace.",
238
240
  },
241
+ {
242
+ name: VERB_REDIRECTS_CHECK,
243
+ capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
244
+ tokens: ["redirects", "check"],
245
+ summary: "Check the current redirect map through the authenticated staging edge.",
246
+ note: "Reports path, HTTP status and Location without following redirects. Re-run after edge propagation. "
247
+ + "The result includes a fresh single-use staging handoff; keep it private.",
248
+ },
239
249
  {
240
250
  name: VERB_REDIRECTS_PULL,
241
251
  // A redirect is a content path, and both halves of the map — read and
@@ -297,9 +307,8 @@ const VERBS = Object.freeze([
297
307
  name: VERB_DEPLOY,
298
308
  // Deployments is the only capability this verb *writes* with; the other two
299
309
  // 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.
310
+ // Content: staging redirect checks read the current redirect map;
311
+ // production also re-authorizes the stored candidate's pages.
303
312
  // Design: --production promotes a completed staging deployment, and the
304
313
  // pipeline re-authorizes that deployment's *stored* candidate — every
305
314
  // selected settings group and its navigation — against site.theme.manage,
@@ -340,13 +349,13 @@ const VERBS = Object.freeze([
340
349
  {
341
350
  name: VERB_STATUS,
342
351
  // Readiness and the deployment log are Deployments; the image list is
343
- // Content.
352
+ // Content, as is the broken-reference report.
344
353
  capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
345
354
  tokens: ["status"],
346
- summary: "Report the platform authoring switch, the CLI release, deployments, readiness, and image processing.",
347
- note: "Broken references are not included: that read remains"
348
- + " session-only on the server, so the result reports it as uncovered"
349
- + " rather than pretending an empty list means a clean site.",
355
+ summary:
356
+ "Report the platform authoring switch, CLI release, deployments, readiness, image processing, and broken references.",
357
+ note: "Broken references list pages with missing image IDs or page paths in their latest editable body."
358
+ + " Content permission is required; a refused or failed read fails status rather than reporting a clean site.",
350
359
  },
351
360
  ]);
352
361
 
@@ -436,7 +445,8 @@ function verbHelp(verb) {
436
445
  : "";
437
446
  const targetOption = verb.target
438
447
  ? `\n --staging Deploy the staged site to staging.
439
- --production Promote the completed staging deployment to production.`
448
+ --production Promote the completed staging deployment to production.
449
+ --allow-failed-preview Explicitly override the matching candidate's failed preview.`
440
450
  : "";
441
451
  const rawHtmlOption = verb.allowRawHtml
442
452
  ? "\n --allow-raw-html Permit rawHtml nodes. They render verbatim and unsanitized."
@@ -466,7 +476,10 @@ function verbHelp(verb) {
466
476
  ? " It refuses only when a previous sign-in exchange recorded a newer published release than this CLI, because "
467
477
  + "Taproot accepts only the latest; with nothing recorded it runs."
468
478
  : "";
469
- const boundary = selfContained
479
+ const boundary = verb.name === VERB_VALIDATE
480
+ ? "Validation uses no credential, configuration, network or write. With --init it reads the pulled workspace "
481
+ + `or selected configuration and writes a new fixture directory, still without credentials or network.${upgradeGate}`
482
+ : selfContained
470
483
  ? "This offline verb uses no credential and reads no configuration, and performs no network request and no "
471
484
  + `write.${upgradeGate}`
472
485
  : verb.offline
@@ -478,7 +491,14 @@ function verbHelp(verb) {
478
491
  + `existing automation unaffected by login and logout.`
479
492
  : `The site-scoped credential is taken from ${PUBLISH_KEY_ENVIRONMENT_VARIABLE} when it is set, and otherwise `
480
493
  + `from the credential '${CLI_BINARY_NAME} ${VERB_LOGIN}' stores outside the repository.`;
481
- const options = selfContained
494
+ const options = verb.name === VERB_VALIDATE
495
+ ? `Options:
496
+ --init Export a pulled workspace into the new fixture directory.
497
+ --config <path> Before the verb; source configuration for --init only.
498
+ --quiet Suppress human progress. The JSON result is unchanged.
499
+ --help Show this help.
500
+ --version Show the package version.`
501
+ : selfContained
482
502
  ? `Options:
483
503
  --quiet Suppress human progress. The JSON result is unchanged.
484
504
  --help Show this help.
@@ -561,7 +581,8 @@ function parseReferenceArguments(arguments_) {
561
581
  || topic === "fixture"
562
582
  || topic === "redirects"
563
583
  )
564
- && (subject !== undefined || extra.length > 0)) {
584
+ && (subject !== undefined || extra.length > 0)
585
+ ) {
565
586
  throw usageError("help.usage", `The '${topic}' topic does not accept a name.`);
566
587
  }
567
588
  if ((topic === "page" || topic === "component") && (subject === undefined || extra.length > 0)) {
@@ -613,7 +634,12 @@ function referenceResult(parsed) {
613
634
  case "media":
614
635
  case "preview":
615
636
  case "fixture":
616
- return { ...result, topic: "workflow", referenceKind: parsed.topic, reference: getWorkflowReference(parsed.topic) };
637
+ return {
638
+ ...result,
639
+ topic: "workflow",
640
+ referenceKind: parsed.topic,
641
+ reference: getWorkflowReference(parsed.topic),
642
+ };
617
643
  case "theme":
618
644
  return { ...result, topic: "presentation", referenceKind: "theme", reference: getThemeReference() };
619
645
  case "appearance":
@@ -699,7 +725,10 @@ function parseArguments(arguments_) {
699
725
  // origin the sign-in belongs to — so they accept it. whoami is offline and
700
726
  // reads both the store and the configuration, so it accepts it too: the test
701
727
  // is "reads nothing local", not "makes no request".
702
- if (verb.offline && !verb.readsLocalState && configPath !== undefined) {
728
+ if (
729
+ verb.offline && !verb.readsLocalState && configPath !== undefined
730
+ && !(verb.name === VERB_VALIDATE && rest.includes("--init"))
731
+ ) {
703
732
  throw usageError("cli.config_option", "--config applies only to verbs that read the site configuration.", {
704
733
  field: "configPath",
705
734
  });
@@ -707,14 +736,21 @@ function parseArguments(arguments_) {
707
736
  if (rest.length === 1 && rest[0] === "--help") return { mode: "verb_help", verb };
708
737
  if (rest.length === 1 && rest[0] === "--version") return { mode: "version" };
709
738
 
739
+ let init = false;
710
740
  let quiet = false;
711
741
  let deployTarget;
742
+ let allowFailedPreview = false;
712
743
  let allowRawHtml = false;
713
744
  let json = false;
714
745
  let keyName;
715
746
  const positionals = [];
716
747
  for (let index = 0; index < rest.length; index += 1) {
717
748
  const argument = rest[index];
749
+ if (verb.name === VERB_VALIDATE && argument === "--init") {
750
+ if (init) throw usageError("cli.duplicate_option", "--init may be supplied only once.");
751
+ init = true;
752
+ continue;
753
+ }
718
754
  if (argument === "--quiet") {
719
755
  if (quiet) throw usageError("cli.duplicate_option", "--quiet may be supplied only once.");
720
756
  quiet = true;
@@ -739,6 +775,13 @@ function parseArguments(arguments_) {
739
775
  deployTarget = candidate;
740
776
  continue;
741
777
  }
778
+ if (verb.target && argument === "--allow-failed-preview") {
779
+ if (allowFailedPreview) {
780
+ throw usageError("cli.duplicate_option", "--allow-failed-preview may be supplied only once.");
781
+ }
782
+ allowFailedPreview = true;
783
+ continue;
784
+ }
742
785
  if (verb.allowRawHtml && argument === "--allow-raw-html") {
743
786
  if (allowRawHtml) {
744
787
  throw usageError("cli.duplicate_option", "--allow-raw-html may be supplied only once.");
@@ -852,8 +895,8 @@ function parseArguments(arguments_) {
852
895
  // the server resolves for them, not only the ones the verb's writes name.
853
896
  // It reaches the exchange so a content push never holds deploy, and a pull
854
897
  // 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
898
+ // (TR00691): reads (nav push lists pages; deploy checks the redirect map),
899
+ // each gated on Content, and re-authorization
857
900
  // of server-held state (deploy --production re-checks the promoted
858
901
  // candidate's stored settings and navigation against a Design permission).
859
902
  // `VERB_CAPABILITIES` below is what the tests pin against the routes each
@@ -862,6 +905,8 @@ function parseArguments(arguments_) {
862
905
  configPath,
863
906
  quiet,
864
907
  deployTarget,
908
+ allowFailedPreview,
909
+ init,
865
910
  allowRawHtml,
866
911
  keyName,
867
912
  positionals: verb.positionals
@@ -936,6 +981,8 @@ export async function runCli({
936
981
  environment,
937
982
  configPath: parsed.configPath,
938
983
  deployTarget: parsed.deployTarget,
984
+ allowFailedPreview: parsed.allowFailedPreview,
985
+ init: parsed.init,
939
986
  quiet: parsed.quiet,
940
987
  allowRawHtml: parsed.allowRawHtml,
941
988
  keyName: parsed.keyName,
package/src/constants.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const CLI_NAME = "@taprootio/site-authoring";
2
- export const CLI_VERSION = "0.6.2";
2
+ export const CLI_VERSION = "0.7.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,7 @@ 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_CHECK = "redirects check";
85
86
  export const VERB_REDIRECTS_PULL = "redirects pull";
86
87
  export const VERB_REDIRECTS_PUSH = "redirects push";
87
88
  export const VERB_THEME_PUSH = "theme push";
@@ -61,7 +61,6 @@ const list = (item) => Object.freeze({ kind: "array", item });
61
61
  const shape = (fields, options = {}) => Object.freeze({ kind: "object", fields: Object.freeze(fields), ...options });
62
62
  const image = () => Object.freeze({ kind: "image" });
63
63
  const safeUrl = (options = {}) => Object.freeze({ kind: "safe-url", ...options });
64
- const actionUrl = () => Object.freeze({ kind: "action-url" });
65
64
  const textureName = () => Object.freeze({ kind: "texture-name", maximumLength: 64 });
66
65
  const withOmission = (spec, whenOmitted) => Object.freeze({ ...spec, whenOmitted: Object.freeze(whenOmitted) });
67
66
 
@@ -149,7 +148,7 @@ const CTA = Object.freeze({
149
148
  heading: str(),
150
149
  description: str(),
151
150
  buttonText: str(),
152
- buttonUrl: actionUrl(),
151
+ buttonUrl: safeUrl(),
153
152
  variant: enumOf("primary", "danger"),
154
153
  borderWidth: num({
155
154
  integer: true,
@@ -534,15 +533,9 @@ function referenceSchema(spec) {
534
533
  return {
535
534
  type: "string",
536
535
  format: spec.allowEmpty === false ? "safe-url" : "safe-url-or-empty",
537
- description:
538
- `${spec.allowEmpty === false ? "An" : "Empty, or an"} explicit HTTP(S), mailto, or tel URL; a root-relative path that does not begin with //; a fragment; a query; or a relative URL. Backslashes and ASCII control characters are rejected.`,
539
- };
540
- case "action-url":
541
- return {
542
- type: "string",
543
- format: "action-url-or-empty",
544
- description:
545
- "Empty, an explicit HTTP(S) URL, or a root-relative path that does not begin with //. Backslashes and ASCII control characters are rejected.",
536
+ description: `${
537
+ spec.allowEmpty === false ? "An" : "Empty, or an"
538
+ } explicit HTTP(S), mailto, or tel URL; a root-relative path that does not begin with //; a fragment; a query; or a relative URL. Backslashes and ASCII control characters are rejected. Contact URLs require a non-empty body and refuse tel:// and mailto:// authority forms.`,
546
539
  };
547
540
  case "texture-name":
548
541
  return {
@@ -645,9 +638,9 @@ function describe(spec) {
645
638
  case "uuid":
646
639
  return "a canonical lowercase UUID";
647
640
  case "safe-url":
648
- return `${spec.allowEmpty === false ? "a" : "an empty string or a"} safe HTTP(S), mailto, tel, non-protocol-relative root path, fragment, query, or relative URL; backslashes and ASCII control characters are rejected`;
649
- case "action-url":
650
- return "an empty string, explicit HTTP(S) URL, or non-protocol-relative root path; backslashes and ASCII control characters are rejected";
641
+ return `${
642
+ spec.allowEmpty === false ? "a" : "an empty string or a"
643
+ } safe HTTP(S), mailto, tel, non-protocol-relative root path, fragment, query, or relative URL; backslashes and ASCII control characters are rejected`;
651
644
  case "texture-name":
652
645
  return "a lowercase hyphenated texture identifier of at most 64 characters";
653
646
  default:
@@ -682,25 +675,6 @@ function validateValue(spec, value, path, errors) {
682
675
  fail(describe(spec));
683
676
  }
684
677
  return;
685
- case "action-url": {
686
- if (typeof value !== "string") {
687
- fail(describe(spec));
688
- return;
689
- }
690
- const trimmed = value.trim();
691
- const lower = trimmed.toLowerCase();
692
- if (
693
- value !== ""
694
- && (trimmed === ""
695
- || value.includes("\\")
696
- || hasAsciiControl(value)
697
- || trimmed.startsWith("//")
698
- || (!trimmed.startsWith("/") && !lower.startsWith("http://") && !lower.startsWith("https://")))
699
- ) {
700
- fail(describe(spec));
701
- }
702
- return;
703
- }
704
678
  case "texture-name":
705
679
  if (
706
680
  typeof value !== "string"
@@ -384,7 +384,8 @@
384
384
  "required": true,
385
385
  "minScalars": 1,
386
386
  "maxScalars": 120,
387
- "nonWhitespace": true
387
+ "nonWhitespace": true,
388
+ "pattern": "^(?![\\s\\S]*<[bB][rR]\\s*/?>)[^\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f\\u2028\\u2029\\ufeff]*$"
388
389
  },
389
390
  "label": {
390
391
  "type": "plain-text",
@@ -392,7 +393,8 @@
392
393
  "default": null,
393
394
  "minScalars": 1,
394
395
  "maxScalars": 120,
395
- "nonWhitespace": true
396
+ "nonWhitespace": true,
397
+ "pattern": "^(?![\\s\\S]*<[bB][rR]\\s*/?>)[^\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f\\u2028\\u2029\\ufeff]*$"
396
398
  },
397
399
  "url": {
398
400
  "type": "safe-url",
@@ -79,6 +79,12 @@ export function normalizeInlineFactsItems(
79
79
  CODES.attrInvalid,
80
80
  `Inline fact ${name} must contain at most ${field.maxScalars} Unicode scalar values.`,
81
81
  );
82
+ } else if (!new RegExp(field.pattern, "u").test(entry)) {
83
+ report(
84
+ `${itemPath}/${name}`,
85
+ CODES.attrInvalid,
86
+ `Inline fact ${name} allows Unicode spaces, tabs, and LF/CRLF line breaks; other C0/C1 controls, U+2028/U+2029, U+FEFF, and literal <br> tags are not supported.`,
87
+ );
82
88
  }
83
89
  }
84
90
 
@@ -52,6 +52,7 @@ export interface RenderProseMirrorOptions {
52
52
  freeFormSections?: boolean;
53
53
  imageDefaults?: {
54
54
  placement?: TiptapImagePresentationPlacement | string | null;
55
+ /** The page shell emits this default once so rich-text formats inherit it. */
55
56
  maxHeightVh?: TiptapImageMaxHeightVh | number | null;
56
57
  };
57
58
  }
@@ -503,8 +504,7 @@ type NormalizedInlineFact = {
503
504
  /**
504
505
  * The row is a list, not a description list: a fact whose value names itself
505
506
  * 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
507
+ * Source order matches the visual order: value, then label. The list roles
508
508
  * are explicit because `list-style: none` makes WebKit drop them, and the
509
509
  * label is separated from the value by a space: search indexers take adjacent
510
510
  * inline elements as one word, and a whitespace-only anonymous flex item is
@@ -524,11 +524,11 @@ function renderInlineFacts(
524
524
  : escapeHtml(fact.value);
525
525
  const label = fact.label === undefined
526
526
  ? ""
527
- : `${renderElement("span", { class: "taproot-inline-fact__label" }, escapeHtml(fact.label))} `;
527
+ : ` ${renderElement("span", { class: "taproot-inline-fact__label" }, escapeHtml(fact.label))}`;
528
528
  return renderElement(
529
529
  "li",
530
530
  { class: "taproot-inline-fact", role: "listitem" },
531
- `${label}${renderElement("span", { class: "taproot-inline-fact__value" }, value)}`,
531
+ `${renderElement("span", { class: "taproot-inline-fact__value" }, value)}${label}`,
532
532
  );
533
533
  }).join("");
534
534
  return renderElement(
@@ -1013,7 +1013,7 @@ function resolveImagePresentation(
1013
1013
  attrs: Record<string, JsonValue | undefined>,
1014
1014
  defaults: RenderProseMirrorOptions["imageDefaults"],
1015
1015
  ): Record<string, string | number | undefined> {
1016
- const maxHeight = resolveMaxHeight(attrs.maxHeightVh, defaults?.maxHeightVh);
1016
+ const explicitMaxHeight = explicitImageMaxHeight(attrs.maxHeightVh);
1017
1017
  const maxHeightOverride = normalizeCssLengthOverride(attrs.maxHeight, { allowNone: true });
1018
1018
  const borderWidthOverride = normalizeCssLengthOverride(attrs.borderWidth);
1019
1019
  const placement = resolvePlacement(attrs, defaults?.placement);
@@ -1022,9 +1022,12 @@ function resolveImagePresentation(
1022
1022
  if (maxHeightOverride) styleParts.push(`--taproot-article-image-max-height: ${maxHeightOverride}`);
1023
1023
  if (borderWidthOverride) styleParts.push(`--esp-image-border: ${imageBorderValue(borderWidthOverride)}`);
1024
1024
  return {
1025
+ // The page shell owns the inherited site default so HTML and structured
1026
+ // rich text share it. Serialize only an explicit legacy choice; this
1027
+ // includes 60vh when it intentionally overrides a taller site default.
1025
1028
  // An explicit CSS max height supersedes the legacy vh enum entirely.
1026
- ...(maxHeightOverride === null && maxHeight !== DEFAULT_TIPTAP_IMAGE_MAX_HEIGHT_VH
1027
- ? { "data-image-max-height-vh": maxHeight }
1029
+ ...(maxHeightOverride === null && explicitMaxHeight !== null
1030
+ ? { "data-image-max-height-vh": explicitMaxHeight }
1028
1031
  : {}),
1029
1032
  ...(maxHeightOverride ? { "data-image-max-height": maxHeightOverride } : {}),
1030
1033
  ...(borderWidthOverride ? { "data-image-border-width": borderWidthOverride } : {}),
@@ -1034,11 +1037,9 @@ function resolveImagePresentation(
1034
1037
  };
1035
1038
  }
1036
1039
 
1037
- function resolveMaxHeight(value: unknown, defaultValue: unknown): TiptapImageMaxHeightVh {
1038
- if (value === TIPTAP_SITE_DEFAULT || value === undefined || value === null) {
1039
- return normalizeImageMaxHeight(defaultValue);
1040
- }
1041
- return normalizeImageMaxHeight(value);
1040
+ function explicitImageMaxHeight(value: unknown): TiptapImageMaxHeightVh | null {
1041
+ if (value === TIPTAP_SITE_DEFAULT || value === undefined || value === null) return null;
1042
+ return isImageMaxHeightOption(value) ? imageMaxHeightOption(value) : null;
1042
1043
  }
1043
1044
 
1044
1045
  function resolvePlacement(
@@ -1068,10 +1069,15 @@ function isAbsentPresentationValue(value: unknown): boolean {
1068
1069
  }
1069
1070
 
1070
1071
  export function normalizeImageMaxHeight(value: unknown): TiptapImageMaxHeightVh {
1071
- const parsed = typeof value === "number" ? value : Number(value);
1072
- return TIPTAP_IMAGE_MAX_HEIGHT_OPTIONS.includes(parsed as TiptapImageMaxHeightVh)
1073
- ? parsed as TiptapImageMaxHeightVh
1074
- : DEFAULT_TIPTAP_IMAGE_MAX_HEIGHT_VH;
1072
+ return isImageMaxHeightOption(value) ? imageMaxHeightOption(value) : DEFAULT_TIPTAP_IMAGE_MAX_HEIGHT_VH;
1073
+ }
1074
+
1075
+ function isImageMaxHeightOption(value: unknown): boolean {
1076
+ return TIPTAP_IMAGE_MAX_HEIGHT_OPTIONS.includes(imageMaxHeightOption(value));
1077
+ }
1078
+
1079
+ function imageMaxHeightOption(value: unknown): TiptapImageMaxHeightVh {
1080
+ return (typeof value === "number" ? value : Number(value)) as TiptapImageMaxHeightVh;
1075
1081
  }
1076
1082
 
1077
1083
  const CSS_LENGTH_UNITS = "px|em|rem|ch|ex|vw|vh|svh|lvh|dvh|svw|lvw|dvw|vmin|vmax|%";
@@ -1,3 +1,4 @@
1
+ import { isContactOrWebUrl } from "../contact-url.js";
1
2
  import { sanitizeDiagnostic } from "../errors.js";
2
3
  import freeFormSectionRegistry from "./free-form-section-registry.json" with { type: "json" };
3
4
 
@@ -17,10 +18,12 @@ import freeFormSectionRegistry from "./free-form-section-registry.json" with { t
17
18
  * page that publishes successfully and renders wrong, so this package rejects
18
19
  * them before they are sent.
19
20
  *
20
- * The rule for the reimplementations below: match the renderer exactly,
21
+ * The rule for the reimplementations below: match the renderer,
21
22
  * including the parts that look like bugs. A "safer" copy would reject
22
23
  * documents the renderer accepts, or accept documents it silently mangles,
23
- * and either way the CLI would stop being the schema.
24
+ * and either way the CLI would stop being the schema. The explicit TR00743
25
+ * compatibility exception is contact URLs: new authoring refuses malformed
26
+ * authority forms while the renderer preserves previously stored links.
24
27
  */
25
28
 
26
29
  // ---------------------------------------------------------------------------
@@ -227,6 +230,7 @@ export function isSafeUrl(url) {
227
230
 
228
231
  try {
229
232
  const parsed = new URL(cleaned);
233
+ if (["mailto:", "tel:"].includes(parsed.protocol)) return isContactOrWebUrl(cleaned);
230
234
  return ["http:", "https:", "mailto:", "tel:"].includes(parsed.protocol);
231
235
  } catch {
232
236
  return true;
package/src/errors.js CHANGED
@@ -54,6 +54,21 @@ export function normalizePreviewRecovery(preview) {
54
54
  });
55
55
  }
56
56
 
57
+ /** No renderer messages or content: only stable typed diagnostic tokens. */
58
+ export function normalizePreviewDiagnostic(value) {
59
+ if (
60
+ !value || typeof value !== "object" || !isCanonicalUuid(value.pageId)
61
+ || typeof value.errorClass !== "string" || !/^[a-z][a-zA-Z0-9_.-]{0,127}$/u.test(value.errorClass)
62
+ ) return undefined;
63
+ const result = { errorClass: value.errorClass, pageId: value.pageId };
64
+ for (const field of ["nodeType", "attribute"]) {
65
+ if (value[field] === undefined || value[field] === "") continue;
66
+ if (typeof value[field] !== "string" || !/^[a-z][a-zA-Z0-9_-]{0,127}$/u.test(value[field])) return undefined;
67
+ result[field] = value[field];
68
+ }
69
+ return result;
70
+ }
71
+
57
72
  function isUnsafeDiagnosticCodePoint(codePoint) {
58
73
  return codePoint <= 0x1f
59
74
  || (codePoint >= DELETE_CODE_POINT && codePoint <= C1_LAST_CODE_POINT)
@@ -88,6 +103,18 @@ export class SiteAuthoringError extends Error {
88
103
  this.code = typeof code === "string" && /^[a-z0-9_.-]+$/u.test(code)
89
104
  ? code
90
105
  : "site.failed";
106
+ this.details = Array.isArray(options.details)
107
+ ? options.details.slice(0, 256).filter((item) =>
108
+ item?.code === "theme.setting_missing"
109
+ && typeof item.field === "string" && typeof item.message === "string"
110
+ )
111
+ .map((item) => ({
112
+ code: item.code,
113
+ field: sanitizeDiagnostic(item.field, "").slice(0, 200),
114
+ message: "is missing (added in a later contract; run taproot-site pull)",
115
+ }))
116
+ : undefined;
117
+ this.previewDiagnostic = normalizePreviewDiagnostic(options.previewDiagnostic);
91
118
  this.field = typeof options.field === "string" ? sanitizeDiagnostic(options.field, "") : undefined;
92
119
  this.status = typeof options.status === "string" ? sanitizeDiagnostic(options.status, "") : undefined;
93
120
  this.alternatives = Array.isArray(options.alternatives)
@@ -29,11 +29,11 @@ export const FIXTURE_REQUIRED_ROOT_FIELDS = Object.freeze([
29
29
  ]);
30
30
 
31
31
  /**
32
- * Root fields `pull` writes that a fixture may carry unchanged. They are
33
- * accepted and read for nothing: a fixture proves local structure, and neither
34
- * the snapshot time nor a recorded deployment can be checked without a server.
32
+ * Optional pull metadata. Snapshot time and deployment receipts are not live
33
+ * evidence. When appearance/footer are present, validate checks their local
34
+ * image bindings and footer hashes against the fixture settings.
35
35
  */
36
- export const FIXTURE_OPTIONAL_ROOT_FIELDS = Object.freeze(["pulledAt", "deployments"]);
36
+ export const FIXTURE_OPTIONAL_ROOT_FIELDS = Object.freeze(["pulledAt", "deployments", "appearance", "footer"]);
37
37
 
38
38
  /** Every root field the manifest may declare. Anything else is refused. */
39
39
  export const FIXTURE_ROOT_FIELDS = Object.freeze(