@taprootio/site-authoring 0.14.0 → 0.15.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 (39) hide show
  1. package/README.md +62 -10
  2. package/examples/riverbend-wellness/.taproot-site-media.json +23 -0
  3. package/examples/riverbend-wellness/pages/index.md +1 -17
  4. package/package.json +2 -2
  5. package/src/api.js +59 -1
  6. package/src/cli.js +147 -22
  7. package/src/constants.js +6 -0
  8. package/src/content/components.js +64 -6
  9. package/src/content/free-form-sections.js +21 -4
  10. package/src/content/markdown.js +161 -26
  11. package/src/content/tiptap-prosemirror.ts +66 -8
  12. package/src/content/validate-document.js +30 -6
  13. package/src/content/vocabulary.js +26 -0
  14. package/src/errors.js +22 -0
  15. package/src/espalier-shared.js +11 -0
  16. package/src/font-catalog.js +87 -0
  17. package/src/footer-contract.js +1 -1
  18. package/src/output.js +19 -1
  19. package/src/presentation-reference.js +21 -7
  20. package/src/problems.js +116 -0
  21. package/src/reference-help.js +100 -34
  22. package/src/session.js +13 -0
  23. package/src/theme-proposals.js +393 -0
  24. package/src/theme-validation.js +31 -10
  25. package/src/typed-page-reference.js +11 -3
  26. package/src/typed-pages.js +31 -9
  27. package/src/verbs/deploy.js +1 -0
  28. package/src/verbs/index.js +13 -0
  29. package/src/verbs/media-upload.js +127 -30
  30. package/src/verbs/nav-push.js +7 -0
  31. package/src/verbs/pages-push.js +406 -199
  32. package/src/verbs/places.js +61 -0
  33. package/src/verbs/plan.js +585 -0
  34. package/src/verbs/theme-push.js +68 -36
  35. package/src/verbs/validate.js +264 -217
  36. package/src/video-prepare.js +48 -12
  37. package/src/workspace-check.js +199 -0
  38. package/src/workspace-ledger.js +173 -0
  39. package/src/workspace.js +47 -4
@@ -258,6 +258,10 @@ const IMAGE_BANNER = Object.freeze({
258
258
  image: image(),
259
259
  altText: str(),
260
260
  overlayText: str(),
261
+ // One supporting sentence and at most one link under the headline: the copy
262
+ // stack Espalier 6.1 lays out with a bounded measure (TR00819).
263
+ supportingText: boundedStr(280),
264
+ action: Object.freeze({ ...HERO_ACTION, nullable: true }),
261
265
  fontSize: enumOf("large", "standard", "small"),
262
266
  focus: shape({
263
267
  x: num({ minimum: 0, maximum: 1 }),
@@ -268,7 +272,20 @@ const IMAGE_BANNER = Object.freeze({
268
272
  // `ratio` keeps the wide/compact shape, capped at the viewport space below
269
273
  // the site header; `viewport` fills exactly that space (TR00413).
270
274
  heightMode: enumOf("ratio", "viewport"),
271
- contentPosition: enumOf("bottom-start", "bottom", "bottom-end", "center", "top-start", "top", "top-end"),
275
+ contentPosition: enumOf(
276
+ "bottom-start",
277
+ "bottom",
278
+ "bottom-end",
279
+ "center",
280
+ "top-start",
281
+ "top",
282
+ "top-end",
283
+ "middle-start",
284
+ "middle-end",
285
+ ),
286
+ // Below the compact width the copy stays over the image, or moves into a
287
+ // band under it when the narrow crop has no quiet region.
288
+ compactPlacement: enumOf("overlay", "below"),
272
289
  contentGutter: enumOf("frame", "page"),
273
290
  followingSpacing: enumOf("standard", "compact"),
274
291
  scrim: enumOf("auto", "none", "flat", "top", "bottom", "left", "right", "radial"),
@@ -574,12 +591,15 @@ const COMPONENT_DEFINITIONS = Object.freeze({
574
591
  image: null,
575
592
  altText: "",
576
593
  overlayText: "",
594
+ supportingText: "",
595
+ action: null,
577
596
  fontSize: "large",
578
597
  focus: { x: 0.5, y: 0.5 },
579
598
  ratio: "3/1",
580
599
  compactRatio: "3/2",
581
600
  heightMode: "ratio",
582
601
  contentPosition: "center",
602
+ compactPlacement: "overlay",
583
603
  contentGutter: "frame",
584
604
  followingSpacing: "standard",
585
605
  scrim: "auto",
@@ -597,8 +617,10 @@ const COMPONENT_DEFINITIONS = Object.freeze({
597
617
  "For a full-bleed photograph with text aligned to the surrounding page well, set contentGutter to page. followingSpacing: compact reduces the top padding of an immediately following section so a bottom banner heading reads with its supporting paragraph. Use a banner-only contentPadding: none section followed by a standard text section; keep their theme contexts consistent. Defaults preserve frame-edge text and standard section spacing.",
598
618
  "scrimEdge: flush finishes a directional scrim at full strength where it meets the page, so a full-bleed banner dissolves into the band below instead of ending at a visible line. It requires the arrangement above: a banner-only contentPadding: none section immediately followed by a section in the same theme context. It is inert when scrim is none, flat, or radial, and it stops being seamless when bannerScheme pins a polarity the page scheme does not share, because the scrim ink is then a derived color rather than the adjoining band's own. Default none; add it deliberately, per banner.",
599
619
  "Compose around the actual photograph: identify the meaningful subject and a quiet area for copy. Do not cover faces, product details, or high-detail edges. A focal point controls cropping, not text placement; inspect wide and compact crops separately. Keep the subject's face, ears, and other identifying details inside the compact frame, including at both ends of parallax travel. Adjust focus toward the subject rather than reusing a generic center; reduce motion or choose a roomier crop if preserving the subject requires it.",
600
- "Use contentPosition to place copy in that quiet area, not automatically in the center. Keep one dominant message and nearby supporting copy/actions; if the photo has no safe text area, use a split hero or a separate text section instead of forcing an overlay.",
601
- "Use the rule of thirds as a composition guide: consider the subject near one third and a compact text group in quiet space on the opposite side. Golden-ratio proportions are optional alternatives, not a proven engagement formula. Neither guide overrides legibility, the actual subject, or the compact crop. Current contentPosition values are edge/center anchors, not exact third or golden-ratio coordinates; do not invent unsupported positioning properties.",
620
+ "Use contentPosition to place copy in that quiet area, not automatically in the center: top, middle and bottom rows, each at start, center or end (middle-start and middle-end sit at mid-height against an edge; start and end mirror for right-to-left text). Keep one dominant message; if the photo has no safe text area, use a split hero or a separate text section instead of forcing an overlay.",
621
+ "The copy is a stack: overlayText is the headline, supportingText one optional supporting sentence (280 characters at most) set in the lead type, and action at most one link ({ label, url } with a safe URL, or null). The stack keeps its own spacing and a bounded line length, so long copy does not run the width of a wide banner, and the link is a real keyboard-reachable link with a visible focus ring. A headline alone lays out as before.",
622
+ "compactPlacement: below moves the copy into a band under the image at compact widths, on the banner scheme's own surface, for a crop that keeps its subject but loses the quiet region the copy sat on. Default overlay keeps the copy over the image. The banner grows instead of clipping when enlarged text or long labels need the room, past the height left below the site header if it must, and returns to its shape once the copy fits.",
623
+ "Use the rule of thirds as a composition guide: consider the subject near one third and a compact text group in quiet space on the opposite side. Golden-ratio proportions are optional alternatives, not a proven engagement formula. Neither guide overrides legibility, the actual subject, or the compact crop. contentPosition values are edge, middle and center anchors, not exact third or golden-ratio coordinates; do not invent unsupported positioning properties.",
602
624
  "Check contrast behind the actual letters at both motion endpoints and both themes: WCAG AA requires 4.5:1 for ordinary text and 3:1 for qualifying large text. A text shadow alone is not a contrast guarantee. Keep text and actions stationary; recheck at mobile widths and enlarged text.",
603
625
  "Prefer parallax-subtle or parallax when motion is useful: motion follows the reader's scrolling and stops when they stop. Infinite ambient loops are an explicit art-direction choice, not the default recommendation.",
604
626
  "imageMotion is optional and defaults to none. slow-zoom is a cinematic 18% zoom over 6 seconds, alternating; drift-start and drift-end travel horizontally over 8 seconds with a 14% safety crop.",
@@ -610,9 +632,12 @@ const COMPONENT_DEFINITIONS = Object.freeze({
610
632
  image: null,
611
633
  altText: "",
612
634
  overlayText: "Train together",
635
+ supportingText: "Small classes, every morning, for every level.",
636
+ action: { label: "See the schedule", url: "/classes" },
613
637
  focus: { x: 0.5, y: 0.35 },
614
638
  heightMode: "ratio",
615
- contentPosition: "bottom-start",
639
+ contentPosition: "middle-start",
640
+ compactPlacement: "below",
616
641
  scrim: "bottom",
617
642
  scrimStrength: "medium",
618
643
  texture: "paper",
@@ -681,6 +706,35 @@ export const COMPONENT_SHAPES = Object.freeze(Object.fromEntries(
681
706
  Object.entries(COMPONENT_DEFINITIONS).map(([type, definition]) => [type, definition.fields]),
682
707
  ));
683
708
 
709
+ /**
710
+ * Replaces each image field in parsed component data with
711
+ * `mapImage(value, path)`, following the component's field table. Markdown
712
+ * authoring turns media paths into stored image records this way, and help
713
+ * builds its Markdown examples the other way (TR01187). A value of the wrong
714
+ * shape is passed through for validation to report.
715
+ */
716
+ export function mapComponentImages(componentType, data, mapImage) {
717
+ const fields = COMPONENT_SHAPES[componentType];
718
+ return fields !== undefined && isPlainObject(data) ? mapImageFields(fields, data, "", mapImage) : data;
719
+ }
720
+
721
+ function mapImageFields(fields, value, path, mapImage) {
722
+ const mapped = { ...value };
723
+ for (const [name, spec] of Object.entries(fields)) {
724
+ if (Object.hasOwn(value, name)) mapped[name] = mapImageValue(spec, value[name], `${path}/${name}`, mapImage);
725
+ }
726
+ return mapped;
727
+ }
728
+
729
+ function mapImageValue(spec, value, path, mapImage) {
730
+ if (spec.kind === "image") return mapImage(value, path);
731
+ if (spec.kind === "object" && isPlainObject(value)) return mapImageFields(spec.fields, value, path, mapImage);
732
+ if (spec.kind === "array" && Array.isArray(value)) {
733
+ return value.map((item, index) => mapImageValue(spec.item, item, `${path}/${index}`, mapImage));
734
+ }
735
+ return value;
736
+ }
737
+
684
738
  export function getComponentDefinition(componentType) {
685
739
  return typeof componentType === "string" && Object.hasOwn(COMPONENT_DEFINITIONS, componentType)
686
740
  ? COMPONENT_DEFINITIONS[componentType]
@@ -749,8 +803,12 @@ function referenceSchema(spec) {
749
803
  case "image":
750
804
  return {
751
805
  type: ["object", "null"],
806
+ // What a Markdown component fence takes instead (TR01187).
807
+ markdownType: ["string", "null"],
752
808
  description:
753
- "null, or a media-upload result. Keep imageId, src, and urls keys so the server can rewrite delivery URLs.",
809
+ "In Markdown, a media path recorded by media upload (such as \"media/photo.webp\"), or null; pages push "
810
+ + "stores the image record. A .pm.json document carries that record: keep imageId, src, and urls keys so "
811
+ + "the server can rewrite delivery URLs.",
754
812
  additionalProperties: false,
755
813
  properties: referenceProperties(COMPONENT_IMAGE_FIELDS),
756
814
  };
@@ -805,7 +863,7 @@ function describe(spec) {
805
863
  case "object":
806
864
  return "an object";
807
865
  case "image":
808
- return "an image object or null";
866
+ return "a media path in Markdown, an image object in .pm.json, or null";
809
867
  case "uuid":
810
868
  return "a canonical lowercase UUID";
811
869
  case "safe-url":
@@ -18,6 +18,16 @@ function deepFreeze(value) {
18
18
 
19
19
  export const FREE_FORM_SECTION_REGISTRY = deepFreeze(registry);
20
20
 
21
+ /**
22
+ * An image converted from a Markdown media path carries `"src": ""` and
23
+ * `"urls": []`, like body and component images: the API fills both from the
24
+ * image id at read time (TR01187). Only that exact pair is unfilled; any other
25
+ * empty value is still refused.
26
+ */
27
+ function isUnfilledDelivery(image) {
28
+ return image.src === "" && Array.isArray(image.urls) && image.urls.length === 0;
29
+ }
30
+
21
31
  function decorationDeliveryUrlIsSafe(value) {
22
32
  if (typeof value !== "string" || value === "" || /[\u0000-\u0020\u007f]/u.test(value) || value.includes("\\")) {
23
33
  return false;
@@ -70,10 +80,11 @@ function normalizeProcessedSectionImage(value, definition, path, label, report)
70
80
  if (Object.hasOwn(value, "imageId") && !isUuid(value.imageId)) {
71
81
  reject(`${path}/imageId`, CODES.attrInvalid, `${label} imageId must be a canonical lowercase UUID.`);
72
82
  }
83
+ const unfilled = isUnfilledDelivery(value);
73
84
  if (Object.hasOwn(value, "src")) {
74
85
  if (typeof value.src !== "string") {
75
86
  reject(`${path}/src`, CODES.attrInvalid, `${label} src must be a string.`);
76
- } else if (!decorationDeliveryUrlIsSafe(value.src)) {
87
+ } else if (!unfilled && !decorationDeliveryUrlIsSafe(value.src)) {
77
88
  reject(
78
89
  `${path}/src`,
79
90
  value.src === "" ? CODES.imageKeys : CODES.attrInvalid,
@@ -88,7 +99,7 @@ function normalizeProcessedSectionImage(value, definition, path, label, report)
88
99
  if (!Array.isArray(value.urls)) {
89
100
  reject(`${path}/urls`, CODES.attrInvalid, `${label} urls must be an array.`);
90
101
  } else {
91
- if (value.urls.length < definition.urls.minItems || value.urls.length > definition.urls.maxItems) {
102
+ if (!unfilled && (value.urls.length < definition.urls.minItems || value.urls.length > definition.urls.maxItems)) {
92
103
  reject(
93
104
  `${path}/urls`,
94
105
  CODES.attrInvalid,
@@ -138,6 +149,7 @@ function normalizeProcessedSectionImage(value, definition, path, label, report)
138
149
  }
139
150
  if (
140
151
  definition.srcMustMatchUrls
152
+ && !unfilled
141
153
  && typeof value.src === "string"
142
154
  && !value.urls.some((option) => isPlainObject(option) && option.url === value.src)
143
155
  ) {
@@ -354,10 +366,11 @@ export function normalizeFreeFormSectionDecoration(
354
366
  report(`${imagePath}/imageId`, CODES.attrInvalid, "Section decoration imageId must be a canonical lowercase UUID.");
355
367
  }
356
368
 
369
+ const unfilled = isUnfilledDelivery(image);
357
370
  if (Object.hasOwn(image, "src")) {
358
371
  if (typeof image.src !== "string") {
359
372
  report(`${imagePath}/src`, CODES.attrInvalid, "Section decoration image src must be a string.");
360
- } else if (!decorationDeliveryUrlIsSafe(image.src)) {
373
+ } else if (!unfilled && !decorationDeliveryUrlIsSafe(image.src)) {
361
374
  report(
362
375
  `${imagePath}/src`,
363
376
  image.src === "" ? CODES.imageKeys : CODES.attrInvalid,
@@ -372,7 +385,10 @@ export function normalizeFreeFormSectionDecoration(
372
385
  if (!Array.isArray(image.urls)) {
373
386
  report(`${imagePath}/urls`, CODES.attrInvalid, "Section decoration image urls must be an array.");
374
387
  } else {
375
- if (image.urls.length < imageDefinition.urls.minItems || image.urls.length > imageDefinition.urls.maxItems) {
388
+ if (
389
+ !unfilled
390
+ && (image.urls.length < imageDefinition.urls.minItems || image.urls.length > imageDefinition.urls.maxItems)
391
+ ) {
376
392
  report(
377
393
  `${imagePath}/urls`,
378
394
  CODES.attrInvalid,
@@ -421,6 +437,7 @@ export function normalizeFreeFormSectionDecoration(
421
437
  }
422
438
  if (
423
439
  imageDefinition.srcMustMatchUrls
440
+ && !unfilled
424
441
  && typeof image.src === "string"
425
442
  && !image.urls.some((option) => isPlainObject(option) && option.url === image.src)
426
443
  ) {
@@ -3,6 +3,7 @@ import {
3
3
  canonicalizeComponentData,
4
4
  expandComponentAuthoringShorthand,
5
5
  isComponentType,
6
+ mapComponentImages,
6
7
  validateComponentBlock,
7
8
  } from "./components.js";
8
9
  import {
@@ -20,6 +21,8 @@ import {
20
21
  isPlainObject,
21
22
  isSafeUrl,
22
23
  isUuid,
24
+ LINK_REL_TOKENS,
25
+ LINK_TARGETS,
23
26
  MARK_TYPES,
24
27
  } from "./vocabulary.js";
25
28
 
@@ -595,7 +598,7 @@ function consumeTable(lines, prelude, context) {
595
598
  // Top-level section containers
596
599
  // ---------------------------------------------------------------------------
597
600
 
598
- function sectionHeaderAttrs(text, line) {
601
+ async function sectionHeaderAttrs(text, line, markdownContext) {
599
602
  const match = SECTION_HEADER_RE.exec(text);
600
603
  if (!match) {
601
604
  throw fail(
@@ -674,6 +677,21 @@ function sectionHeaderAttrs(text, line) {
674
677
  }
675
678
  }
676
679
 
680
+ for (const [owner, field] of [["decoration", "image"], ["background", "image"], ["background", "portraitImage"]]) {
681
+ if (isPlainObject(parsed[owner]) && Object.hasOwn(parsed[owner], field)) {
682
+ parsed[owner] = {
683
+ ...parsed[owner],
684
+ [field]: await authoredImageField(
685
+ parsed[owner][field],
686
+ `/${owner}/${field}`,
687
+ markdownContext,
688
+ line,
689
+ "section header",
690
+ ),
691
+ };
692
+ }
693
+ }
694
+
677
695
  const normalizedDecoration = normalizeFreeFormSectionDecoration(
678
696
  parsed.decoration,
679
697
  "/attrs/decoration",
@@ -709,7 +727,7 @@ function fenceClosing(delimiter) {
709
727
 
710
728
  async function consumeSection(lines, start, headerText, context, depth) {
711
729
  const line = lines[start];
712
- const attrs = sectionHeaderAttrs(headerText, line);
730
+ const attrs = await sectionHeaderAttrs(headerText, line, context);
713
731
  const body = [];
714
732
  let index = start + 1;
715
733
  let activeFence;
@@ -773,7 +791,7 @@ function stripClosingHashes(title) {
773
791
  // Fenced blocks: code, components, and inline facts
774
792
  // ---------------------------------------------------------------------------
775
793
 
776
- function consumeFence(lines, start, fence, context) {
794
+ async function consumeFence(lines, start, fence, context) {
777
795
  const [, indent, delimiter, rawInfo] = fence;
778
796
  const info = rawInfo.trim();
779
797
  const line = lines[start];
@@ -801,7 +819,10 @@ function consumeFence(lines, start, fence, context) {
801
819
  }
802
820
 
803
821
  if (info.startsWith("component:")) {
804
- return { node: componentNode(info.slice("component:".length).trim(), body.join("\n"), line), next: index };
822
+ return {
823
+ node: await componentNode(info.slice("component:".length).trim(), body.join("\n"), line, context),
824
+ next: index,
825
+ };
805
826
  }
806
827
  if (info === INLINE_FACTS_CONTRACT.markdown.fence) {
807
828
  if (context.blockPlacement !== "root" && context.blockPlacement !== "section") {
@@ -865,7 +886,7 @@ function stripIndent(text, columns) {
865
886
  return text.slice(index);
866
887
  }
867
888
 
868
- function componentNode(componentType, body, line) {
889
+ async function componentNode(componentType, body, line, context) {
869
890
  if (!isComponentType(componentType)) {
870
891
  throw fail(
871
892
  CODES.componentUnknown,
@@ -879,7 +900,22 @@ function componentNode(componentType, body, line) {
879
900
  if (expanded.error !== undefined) {
880
901
  throw fail(CODES.componentData, "component block", expanded.error, line.number);
881
902
  }
882
- const source = expanded.source;
903
+ let source = expanded.source;
904
+ const parsed = parseJsonOrUndefined(source);
905
+ if (parsed !== undefined) {
906
+ const imageFields = [];
907
+ mapComponentImages(componentType, parsed, (value, path) => {
908
+ imageFields.push([path, value]);
909
+ return value;
910
+ });
911
+ if (imageFields.length > 0) {
912
+ const resolved = new Map();
913
+ for (const [path, value] of imageFields) {
914
+ resolved.set(path, await authoredImageField(value, path, context, line, "component block"));
915
+ }
916
+ source = JSON.stringify(mapComponentImages(componentType, parsed, (_value, path) => resolved.get(path)));
917
+ }
918
+ }
883
919
  const errors = validateComponentBlock(componentType, source, "");
884
920
  if (errors.length > 0) {
885
921
  throw fail(errors[0].code, "component block", errors[0].message, line.number);
@@ -1476,12 +1512,71 @@ function parseLink(text, index, scan, lineNumber, marks) {
1476
1512
  );
1477
1513
  }
1478
1514
 
1515
+ const options = parseLinkAttributes(text, destinationEnd + 1, lineNumber);
1516
+ const attrs = { href: destination, ...options?.attrs };
1479
1517
  const inner = text.slice(index + 1, textEnd);
1480
- const nodes = parseInline(inner, scan, lineNumber, [...marks, { type: "link", attrs: { href: destination } }]);
1518
+ const nodes = parseInline(inner, scan, lineNumber, [...marks, { type: "link", attrs }]);
1481
1519
  if (nodes.length === 0) {
1482
1520
  throw fail(CODES.markdownLink, "link", "A link must have text.", lineNumber);
1483
1521
  }
1484
- return { nodes, end: destinationEnd + 1 };
1522
+ return { nodes, end: options ? options.end : destinationEnd + 1 };
1523
+ }
1524
+
1525
+ /**
1526
+ * The optional `{rel="sponsored nofollow" target="_blank"}` right after a
1527
+ * link's destination (TR01190), either attribute alone or both in any order.
1528
+ * Braces that start `{rel=` or `{target=` are attributes and must be well
1529
+ * formed; any other brace after a link stays literal text.
1530
+ */
1531
+ function parseLinkAttributes(text, from, lineNumber) {
1532
+ const rest = text.slice(from);
1533
+ if (!/^\{\s*(?:rel|target)\s*=/u.test(rest)) return undefined;
1534
+ const match = /^\{((?:\s*(?:rel|target)="[^"\\{}]*")+)\s*\}/u.exec(rest);
1535
+ const pairs = match ? [...match[1].matchAll(/(rel|target)="([^"]*)"/gu)] : [];
1536
+ const names = pairs.map((pair) => pair[1]);
1537
+ if (!match || new Set(names).size !== names.length) {
1538
+ throw fail(
1539
+ CODES.markdownLink,
1540
+ "link attributes",
1541
+ 'Write a link\'s attributes right after it as {rel="nofollow" target="_blank"}: each at most once, '
1542
+ + "double-quoted. Escape a literal brace as \\{.",
1543
+ lineNumber,
1544
+ );
1545
+ }
1546
+ const attrs = {};
1547
+ for (const [, name, value] of pairs) {
1548
+ if (name === "target") {
1549
+ const target = value.trim().toLowerCase();
1550
+ if (!LINK_TARGETS.includes(target)) {
1551
+ throw fail(
1552
+ CODES.linkTarget,
1553
+ "link target",
1554
+ `A link's target is "_blank" (open in a new tab) or "_self"; "${value}" is not one.`,
1555
+ lineNumber,
1556
+ );
1557
+ }
1558
+ attrs.target = target;
1559
+ continue;
1560
+ }
1561
+ const tokens = value.trim().toLowerCase().split(/\s+/u).filter(Boolean);
1562
+ const unknown = tokens.filter((token) => !LINK_REL_TOKENS.includes(token));
1563
+ if (tokens.length === 0 || unknown.length > 0) {
1564
+ throw fail(
1565
+ CODES.linkRel,
1566
+ "link rel",
1567
+ `A link's rel takes space-separated link types from ${LINK_REL_TOKENS.join(", ")}; ${
1568
+ unknown.length
1569
+ ? `${unknown.map((token) => `"${token}"`).join(", ")} ${unknown.length === 1 ? "is" : "are"} not one`
1570
+ : "this one is empty"
1571
+ }.`,
1572
+ lineNumber,
1573
+ );
1574
+ }
1575
+ // Vocabulary order, so a written rel can never read as the editor's old
1576
+ // default ("noopener noreferrer nofollow"), which means no choice.
1577
+ attrs.rel = LINK_REL_TOKENS.filter((token) => tokens.includes(token)).join(" ");
1578
+ }
1579
+ return { attrs, end: from + match[0].length };
1485
1580
  }
1486
1581
 
1487
1582
  // The same unbounded scan as `findLinkTextEnd`, and quadratic for the same
@@ -1515,19 +1610,34 @@ function unescapePunctuation(text) {
1515
1610
  }
1516
1611
 
1517
1612
  /**
1518
- * Builds a `taprootImage` node from `![alt](reference)`.
1519
- *
1520
- * The node **always** carries `"src": ""` and `"urls": []`, whatever the
1521
- * resolver reported for delivery URLs.
1522
- * `PageImageDeliveryRewriter.ReplaceUrlFields` rewrites `src`/`url`/`urls`
1523
- * only where the key already exists, filling them from the site's signed
1524
- * delivery URLs at read time — so the key must be there and its value must not
1525
- * be a URL this CLI invented. An image node missing the keys is rewritten into
1526
- * nothing and renders blank; one carrying a hand-built URL renders a link the
1527
- * site cannot sign.
1613
+ * Builds a `taprootImage` node from `![alt](reference)`. Like every Markdown
1614
+ * image it carries `"src": ""` and `"urls": []`; see `resolveAuthoredImage`.
1528
1615
  */
1529
1616
  async function imageNode(alt, reference, context, line) {
1530
- const source = unescapePunctuation(reference);
1617
+ const resolved = await resolveAuthoredImage(unescapePunctuation(reference), context, line);
1618
+ const attrs = {
1619
+ imageId: resolved.imageId,
1620
+ src: "",
1621
+ urls: [],
1622
+ alt: unescapePunctuation(alt) || resolved.alt,
1623
+ };
1624
+ if (resolved.width !== undefined) attrs.width = resolved.width;
1625
+ if (resolved.height !== undefined) attrs.height = resolved.height;
1626
+ return { type: "taprootImage", attrs };
1627
+ }
1628
+
1629
+ /**
1630
+ * The one way Markdown turns a media path into an image: body images,
1631
+ * component image fields and section images all resolve here (TR01187).
1632
+ *
1633
+ * The record always carries `"src": ""` and `"urls": []`, whatever the
1634
+ * resolver reported for delivery URLs. `PageImageDeliveryRewriter` fills them
1635
+ * from the image id at read time, but only where the keys already exist, so
1636
+ * they must be present and must not hold a URL this CLI invented: an image
1637
+ * missing the keys renders blank, and a hand-built URL is one the site cannot
1638
+ * sign. Width and height are left out when the media record does not know them.
1639
+ */
1640
+ async function resolveAuthoredImage(source, context, line, field) {
1531
1641
  if (source.length > CONTENT_LIMITS.imageReferenceLength) {
1532
1642
  throw fail(CODES.markdownImage, "image", "The image reference is too long.", line.number);
1533
1643
  }
@@ -1541,7 +1651,9 @@ async function imageNode(alt, reference, context, line) {
1541
1651
  throw fail(
1542
1652
  CODES.markdownImage,
1543
1653
  "image",
1544
- `The image '${identifier(source)}' could not be resolved. Upload it with 'media upload' first.`,
1654
+ `The image '${identifier(source)}'${field ? ` at ${field}` : ""} could not be resolved. `
1655
+ + "Record it in .taproot-site-media.json: 'media upload' does that in a workspace, and a fixture lists it "
1656
+ + "in its own copy.",
1545
1657
  line.number,
1546
1658
  error,
1547
1659
  );
@@ -1554,16 +1666,39 @@ async function imageNode(alt, reference, context, line) {
1554
1666
  line.number,
1555
1667
  );
1556
1668
  }
1557
-
1558
- const attrs = {
1669
+ const dimension = (value) => (Number.isSafeInteger(value) && value > 0 ? value : undefined);
1670
+ const width = dimension(resolved.width);
1671
+ const height = dimension(resolved.height);
1672
+ return {
1559
1673
  imageId: resolved.imageId,
1560
1674
  src: "",
1561
1675
  urls: [],
1562
- alt: unescapePunctuation(alt) || (typeof resolved.alt === "string" ? resolved.alt : ""),
1676
+ ...(width === undefined ? {} : { width }),
1677
+ ...(height === undefined ? {} : { height }),
1678
+ alt: typeof resolved.alt === "string" ? resolved.alt : "",
1563
1679
  };
1564
- if (Number.isInteger(resolved.width) && resolved.width > 0) attrs.width = resolved.width;
1565
- if (Number.isInteger(resolved.height) && resolved.height > 0) attrs.height = resolved.height;
1566
- return { type: "taprootImage", attrs };
1680
+ }
1681
+
1682
+ /** A component or section image field in Markdown: a media path, or null. */
1683
+ async function authoredImageField(value, path, context, line, label) {
1684
+ if (value === null) return null;
1685
+ if (typeof value !== "string" || value === "") {
1686
+ throw fail(
1687
+ CODES.markdownImage,
1688
+ label,
1689
+ `${path} takes a media path such as "media/photo.webp", or null. Markdown does not accept the upload record.`,
1690
+ line.number,
1691
+ );
1692
+ }
1693
+ return await resolveAuthoredImage(value, context, line, path);
1694
+ }
1695
+
1696
+ function parseJsonOrUndefined(source) {
1697
+ try {
1698
+ return JSON.parse(source);
1699
+ } catch {
1700
+ return undefined;
1701
+ }
1567
1702
  }
1568
1703
 
1569
1704
  // ---------------------------------------------------------------------------
@@ -367,14 +367,7 @@ function wrapMark(html: string, mark: ProseMirrorMark): string {
367
367
  case "link": {
368
368
  const href = stringAttr(mark.attrs?.href);
369
369
  if (!href || !isSafeUrl(href)) return html;
370
- return renderElement(
371
- "a",
372
- {
373
- href,
374
- rel: "noopener noreferrer nofollow",
375
- },
376
- html,
377
- );
370
+ return renderElement("a", { href, ...linkRelAttributes(mark.attrs) }, html);
378
371
  }
379
372
  default:
380
373
  return html;
@@ -1377,6 +1370,71 @@ function serializeAttributes(attrs: Record<string, string | number | boolean | n
1377
1370
  return serialized.length ? ` ${serialized.join(" ")}` : "";
1378
1371
  }
1379
1372
 
1373
+ /**
1374
+ * The rel tokens a link may carry (TR01190): every HTML link type valid on
1375
+ * `<a>` except `opener`, which hands a new tab a handle on the page.
1376
+ */
1377
+ export const LINK_REL_TOKENS = [
1378
+ "nofollow",
1379
+ "sponsored",
1380
+ "ugc",
1381
+ "me",
1382
+ "noopener",
1383
+ "noreferrer",
1384
+ "external",
1385
+ "author",
1386
+ "license",
1387
+ "tag",
1388
+ "bookmark",
1389
+ "help",
1390
+ "prev",
1391
+ "next",
1392
+ "privacy-policy",
1393
+ "terms-of-service",
1394
+ ] as const;
1395
+ /** The targets a link may carry; only `_blank` changes what renders. */
1396
+ export const LINK_TARGETS = ["_blank", "_self"] as const;
1397
+ /**
1398
+ * The editor stored this rel, with `target="_blank"`, on every link before
1399
+ * authors could choose, so a mark carrying it records no choice of either.
1400
+ */
1401
+ export const LEGACY_EDITOR_LINK_REL = "noopener noreferrer nofollow";
1402
+
1403
+ /**
1404
+ * An author's chosen tokens as a stored rel. Those three tokens in the old
1405
+ * default's order would read as no choice, so they are stored in vocabulary
1406
+ * order instead.
1407
+ */
1408
+ export function spellLinkRel(tokens: readonly string[]): string {
1409
+ const rel = tokens.join(" ");
1410
+ return rel === LEGACY_EDITOR_LINK_REL
1411
+ ? LINK_REL_TOKENS.filter((token) => tokens.includes(token)).join(" ")
1412
+ : rel;
1413
+ }
1414
+
1415
+ /** A stored rel as tokens, or undefined when it is the editor's old default (no choice). */
1416
+ export function chosenLinkRelTokens(rel: unknown): string[] | undefined {
1417
+ const tokens = stringAttr(rel)?.trim().toLowerCase().split(/\s+/u).filter(Boolean) ?? [];
1418
+ return tokens.join(" ") === LEGACY_EDITOR_LINK_REL ? undefined : tokens;
1419
+ }
1420
+
1421
+ /**
1422
+ * The `rel` and `target` a link publishes with: the author's allowed tokens in
1423
+ * their order, and noopener and noreferrer whenever it opens a new tab.
1424
+ * Anything else stored is ignored.
1425
+ */
1426
+ export function linkRelAttributes(attrs: Record<string, unknown> | undefined): { rel?: string; target?: string } {
1427
+ const stored = chosenLinkRelTokens(attrs?.rel);
1428
+ if (!stored) return {};
1429
+ const newTab = stringAttr(attrs?.target)?.trim().toLowerCase() === "_blank";
1430
+ const allowed: readonly string[] = LINK_REL_TOKENS;
1431
+ const rel = new Set([
1432
+ ...stored.filter((token) => allowed.includes(token)),
1433
+ ...(newTab ? ["noopener", "noreferrer"] : []),
1434
+ ]);
1435
+ return { ...(rel.size ? { rel: [...rel].join(" ") } : {}), ...(newTab ? { target: "_blank" } : {}) };
1436
+ }
1437
+
1380
1438
  function isSafeUrl(url: string): boolean {
1381
1439
  if (/[\x00-\x1f\x7f\\]/u.test(url)) return false;
1382
1440
  const cleaned = url.trim();
@@ -18,6 +18,8 @@ import {
18
18
  isPlainObject,
19
19
  isSafeUrl,
20
20
  isUuid,
21
+ LINK_REL_TOKENS,
22
+ LINK_TARGETS,
21
23
  MARK_TYPE_SET,
22
24
  NODE_TYPE_SET,
23
25
  normalizeCssLengthOverride,
@@ -485,12 +487,34 @@ const MARK_RULES = Object.freeze({
485
487
  { required: true, code: CODES.linkHref, missingCode: CODES.linkHref },
486
488
  ),
487
489
  // `@tiptap/extension-link` declares these four alongside `href`, and
488
- // `Mark.toJSON` emits every declared attribute — so every link the editor
489
- // has ever written carries all five, and refusing any of them would refuse
490
- // an ordinary pull/push round trip. The renderer ignores them and writes
491
- // its own `rel`.
492
- target: optionalString(),
493
- rel: optionalString(),
490
+ // `Mark.toJSON` emits every declared attribute, so every link the editor
491
+ // has ever written carries all five. The renderer publishes `rel` and
492
+ // `target="_blank"` from closed lists (TR01190), so those two are held to
493
+ // them; it ignores `class` and `title`. The editor's old default rel,
494
+ // "noopener noreferrer nofollow", is within the list and renders as no
495
+ // choice.
496
+ target: spec(
497
+ (value) => {
498
+ if (isAbsent(value) || (typeof value === "string" && value.trim() === "")) return null;
499
+ return typeof value === "string" && LINK_TARGETS.includes(value.trim().toLowerCase())
500
+ ? null
501
+ : `"_blank" (open in a new tab), "_self", or absent`;
502
+ },
503
+ { code: CODES.linkTarget },
504
+ ),
505
+ rel: spec(
506
+ (value) => {
507
+ if (isAbsent(value)) return null;
508
+ if (typeof value !== "string") return "a string of space-separated link types";
509
+ const unknown = value.trim().toLowerCase().split(/\s+/u)
510
+ .filter((token) => token && !LINK_REL_TOKENS.includes(token));
511
+ if (unknown.length === 0) return null;
512
+ return `space-separated link types from ${LINK_REL_TOKENS.join(", ")}; ${
513
+ unknown.map((token) => `"${token}"`).join(", ")
514
+ } ${unknown.length === 1 ? "is" : "are"} not one`;
515
+ },
516
+ { code: CODES.linkRel },
517
+ ),
494
518
  class: optionalString(),
495
519
  title: optionalString(),
496
520
  }),