@taprootio/site-authoring 0.30.0 → 0.32.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.
@@ -0,0 +1,108 @@
1
+ // Generated from shared/image-description-document.ts by scripts/sync-field-validation.mjs. Do not edit.
2
+ import { IMAGE_DESCRIPTION_HYDRATED_KEYS, resolveImageDescription } from "./image-description-validation.js";
3
+ export function imageDescriptionImageId(image) {
4
+ return typeof image.imageId === "string"
5
+ ? image.imageId
6
+ : typeof image.id === "string" && "caption" in image
7
+ ? image.id
8
+ : undefined;
9
+ }
10
+ /** Component data may be serialized inside a structured document. Keep unchanged bytes intact. */
11
+ export function visitImageDescriptionObjects(value, transform, seen = new WeakSet()) {
12
+ if (!value || typeof value !== "object" || seen.has(value))
13
+ return;
14
+ seen.add(value);
15
+ if (Array.isArray(value)) {
16
+ for (const child of value)
17
+ visitImageDescriptionObjects(child, transform, seen);
18
+ return;
19
+ }
20
+ const record = value;
21
+ transform(record);
22
+ for (const [key, child] of Object.entries(record)) {
23
+ if (key === "componentData" && typeof child === "string") {
24
+ try {
25
+ const data = JSON.parse(child);
26
+ const before = JSON.stringify(data);
27
+ visitImageDescriptionObjects(data, transform, seen);
28
+ const after = JSON.stringify(data);
29
+ if (before !== after)
30
+ record[key] = after;
31
+ }
32
+ catch { /* Existing content validation owns malformed component data. */ }
33
+ }
34
+ else
35
+ visitImageDescriptionObjects(child, transform, seen);
36
+ }
37
+ }
38
+ export function referencedImageDescriptionIds(value) {
39
+ const ids = new Set();
40
+ visitImageDescriptionObjects(value, image => {
41
+ const id = imageDescriptionImageId(image);
42
+ if (id)
43
+ ids.add(id);
44
+ });
45
+ return ids;
46
+ }
47
+ /** API delivery state does not belong in authored documents or workspace hashes. */
48
+ export function stripImageDescriptionHydration(value) {
49
+ const copy = structuredClone(value);
50
+ visitImageDescriptionObjects(copy, image => {
51
+ for (const key of IMAGE_DESCRIPTION_HYDRATED_KEYS)
52
+ delete image[key];
53
+ if (image.descriptionMode === "inherit") {
54
+ delete image.alt;
55
+ delete image.altText;
56
+ }
57
+ const nested = image.image;
58
+ if (nested && typeof nested === "object" && !Array.isArray(nested)) {
59
+ const bannerImage = nested;
60
+ if ((image.descriptionMode ?? bannerImage.descriptionMode) === "inherit") {
61
+ delete image.altText;
62
+ delete bannerImage.alt;
63
+ delete bannerImage.altText;
64
+ }
65
+ }
66
+ });
67
+ return copy;
68
+ }
69
+ const text = (value) => typeof value === "string" ? value : undefined;
70
+ const placementMode = (mode, contextual) => mode === undefined || mode === null || mode === "" ? contextual.trim() ? "custom" : "inherit" : mode;
71
+ export function hydrateImageDescriptions(value, source) {
72
+ const copy = structuredClone(value);
73
+ const metadata = (image, delivery) => {
74
+ image.defaultDescription = delivery.defaultDescription ?? null;
75
+ image.descriptionRevision = delivery.descriptionRevision;
76
+ };
77
+ visitImageDescriptionObjects(copy, image => {
78
+ const id = imageDescriptionImageId(image);
79
+ const delivery = id ? source(id) : undefined;
80
+ if (delivery) {
81
+ const album = "caption" in image && "urls" in image;
82
+ const field = "altText" in image || album ? "altText" : "alt";
83
+ const contextual = text(image[field])
84
+ ?? (album && image.descriptionMode == null ? text(image.caption) : undefined) ?? "";
85
+ const mode = placementMode(text(image.descriptionMode), contextual);
86
+ image.descriptionMode = mode;
87
+ image[field] = resolveImageDescription(mode, contextual, delivery.defaultDescription ?? "");
88
+ metadata(image, delivery);
89
+ }
90
+ const nested = image.image;
91
+ if (!("altText" in image || "descriptionMode" in image || "overlayText" in image)
92
+ || !nested || typeof nested !== "object" || Array.isArray(nested))
93
+ return;
94
+ const bannerImage = nested;
95
+ const bannerId = imageDescriptionImageId(bannerImage);
96
+ const bannerSource = bannerId ? source(bannerId) : undefined;
97
+ if (!bannerSource)
98
+ return;
99
+ const contextual = text(image.altText) ?? "";
100
+ const mode = placementMode(text(image.descriptionMode) ?? text(bannerImage.descriptionMode), contextual);
101
+ image.descriptionMode = mode;
102
+ image.altText = resolveImageDescription(mode, contextual, bannerSource.defaultDescription ?? "");
103
+ metadata(image, bannerSource);
104
+ bannerImage.descriptionMode = mode;
105
+ bannerImage.alt = image.altText;
106
+ });
107
+ return copy;
108
+ }
@@ -0,0 +1,70 @@
1
+ // Generated from shared/image-description-validation.ts by scripts/sync-field-validation.mjs. Do not edit.
2
+ import registry from "./free-form-section-registry.json" with { type: "json" };
3
+ const definition = registry.imageDescription;
4
+ export const IMAGE_DESCRIPTION_MODES = Object.freeze([...definition.modes]);
5
+ export const IMAGE_DESCRIPTION_KEYS = Object.freeze(Object.keys(definition.fields));
6
+ const fields = Object.entries(definition.fields);
7
+ export const IMAGE_DESCRIPTION_PERSISTED_KEYS = Object.freeze(fields.filter(([, field]) => field.storage === "persisted").map(([key]) => key));
8
+ export const IMAGE_DESCRIPTION_HYDRATED_KEYS = Object.freeze(fields.filter(([, field]) => field.storage === "hydratedReadOnly").map(([key]) => key));
9
+ export function isImageDescriptionMode(value) {
10
+ return value === undefined || value === null || IMAGE_DESCRIPTION_MODES.includes(value);
11
+ }
12
+ export function isImageDefaultDescription(value) {
13
+ return value === undefined || value === null || typeof value === "string"
14
+ && !/[\uD800-\uDFFF]/u.test(value) && [...value].length <= definition.maxDefaultDescriptionScalars;
15
+ }
16
+ /** Decimal strings retain int64 precision across JSON and JavaScript. */
17
+ export function normalizeImageDescriptionRevision(value) {
18
+ if (typeof value === "number")
19
+ return Number.isSafeInteger(value) && value >= 0 ? String(value) : undefined;
20
+ if (typeof value !== "string" || !/^(?:0|[1-9][0-9]*)$/u.test(value))
21
+ return undefined;
22
+ const maximum = definition.maxRevision;
23
+ return value.length < maximum.length || value.length === maximum.length && value <= maximum ? value : undefined;
24
+ }
25
+ export function isImageDescriptionRevision(value) {
26
+ return value === undefined || value === null || normalizeImageDescriptionRevision(value) !== undefined;
27
+ }
28
+ /** Editor controls repair unknown choices; delivery validation reports them instead. */
29
+ export function imageDescriptionMode(mode, contextual) {
30
+ return typeof mode === "string" && IMAGE_DESCRIPTION_MODES.includes(mode)
31
+ ? mode
32
+ : contextual?.trim()
33
+ ? "custom"
34
+ : "inherit";
35
+ }
36
+ export function resolveImageDescription(mode, contextual, fallback) {
37
+ switch (mode) {
38
+ case "decorative":
39
+ return "";
40
+ case "inherit":
41
+ return fallback;
42
+ case "custom":
43
+ return contextual;
44
+ default:
45
+ return contextual.trim() ? contextual : fallback;
46
+ }
47
+ }
48
+ const validators = {
49
+ mode: { accepts: isImageDescriptionMode, expected: `be ${IMAGE_DESCRIPTION_MODES.join(", ")}, or null` },
50
+ text: {
51
+ accepts: isImageDefaultDescription,
52
+ expected: "be valid Unicode text within the registry's scalar limit, or null",
53
+ },
54
+ revision: {
55
+ accepts: isImageDescriptionRevision,
56
+ expected: "be a nonnegative safe integer or canonical int64 decimal string, or null",
57
+ },
58
+ };
59
+ export function imageDescriptionIssues(value) {
60
+ return fields.flatMap(([field, contract]) => {
61
+ const validator = validators[contract.kind];
62
+ return validator.accepts(value[field])
63
+ ? []
64
+ : [{ field, storage: contract.storage, expected: validator.expected }];
65
+ });
66
+ }
67
+ export function pickImageDescriptionFields(value, storage) {
68
+ return Object.fromEntries(fields.filter(([key, field]) => (!storage || field.storage === storage)
69
+ && Object.prototype.hasOwnProperty.call(value, key)).map(([key]) => [key, value[key]]));
70
+ }
@@ -8,6 +8,8 @@
8
8
  */
9
9
 
10
10
  import freeFormSectionRegistry from "./free-form-section-registry.json" with { type: "json" };
11
+ import { imageDescriptionIssues } from "./image-description-validation.js";
12
+ import { isExternalLink, NEW_TAB_CUE_HTML, relWithNewTabTokens } from "./external-link.js";
11
13
  import {
12
14
  explicitImageMaxHeight,
13
15
  mediaOverrideStyleParts,
@@ -100,6 +102,12 @@ export interface RenderProseMirrorOptions {
100
102
  headingLevel?: (authored: number) => number;
101
103
  /** Claims the page's one LCP slot before rendering a section photo. */
102
104
  claimSectionBackgroundImage?: () => boolean;
105
+ /**
106
+ * The site's own hosts (TR01327). With it, external links open in a new tab
107
+ * unless the author chose otherwise; without it nothing is decided here, so
108
+ * previews and PDFs render as before.
109
+ */
110
+ siteHosts?: ReadonlySet<string>;
103
111
  }
104
112
 
105
113
  export function createEmptyProseMirrorDocument(): ProseMirrorDocument {
@@ -263,7 +271,7 @@ function renderNode(
263
271
  renderNodes(node.content ?? [], options, "nested"),
264
272
  );
265
273
  case "text":
266
- return renderTextNode(node);
274
+ return renderTextNode(node, options);
267
275
  case "hardBreak":
268
276
  return "<br>";
269
277
  case "heading":
@@ -344,15 +352,15 @@ function renderIntegrationPlacement(node: ProseMirrorNode, options: RenderProseM
344
352
  }, selected.html);
345
353
  }
346
354
 
347
- function renderTextNode(node: ProseMirrorNode): string {
355
+ function renderTextNode(node: ProseMirrorNode, options: RenderProseMirrorOptions): string {
348
356
  let rendered = escapeHtml(node.text ?? "");
349
357
  for (const mark of node.marks ?? []) {
350
- rendered = wrapMark(rendered, mark);
358
+ rendered = wrapMark(rendered, mark, options);
351
359
  }
352
360
  return rendered;
353
361
  }
354
362
 
355
- function wrapMark(html: string, mark: ProseMirrorMark): string {
363
+ function wrapMark(html: string, mark: ProseMirrorMark, options: RenderProseMirrorOptions): string {
356
364
  switch (mark.type) {
357
365
  case "bold":
358
366
  return `<strong>${html}</strong>`;
@@ -367,7 +375,7 @@ function wrapMark(html: string, mark: ProseMirrorMark): string {
367
375
  case "link": {
368
376
  const href = stringAttr(mark.attrs?.href);
369
377
  if (!href || !isSafeUrl(href)) return html;
370
- return renderElement("a", { href, ...linkRelAttributes(mark.attrs) }, html);
378
+ return renderLink(href, mark.attrs, html, options);
371
379
  }
372
380
  default:
373
381
  return html;
@@ -409,7 +417,7 @@ type NormalizedTable = {
409
417
  * omit one malformed table rather than reinterpret arbitrary blocks as cells.
410
418
  */
411
419
  function renderTable(node: ProseMirrorNode, options: RenderProseMirrorOptions): string {
412
- const table = normalizeTable(node);
420
+ const table = normalizeTable(node, options);
413
421
  if (!table) return "";
414
422
 
415
423
  const header = renderElement(
@@ -455,7 +463,7 @@ function renderTable(node: ProseMirrorNode, options: RenderProseMirrorOptions):
455
463
  );
456
464
  }
457
465
 
458
- function normalizeTable(node: ProseMirrorNode): NormalizedTable | undefined {
466
+ function normalizeTable(node: ProseMirrorNode, options: RenderProseMirrorOptions): NormalizedTable | undefined {
459
467
  const definition = freeFormSectionRegistry.table;
460
468
  if (!hasOnlySerializedKeys(node, ["type", "attrs", "content"])) return undefined;
461
469
  const attrs = node.attrs;
@@ -495,7 +503,7 @@ function normalizeTable(node: ProseMirrorNode): NormalizedTable | undefined {
495
503
  return undefined;
496
504
  }
497
505
 
498
- const headerCells = normalizeTableRow(headerRow, definition.nodeTypes.header, true);
506
+ const headerCells = normalizeTableRow(headerRow, definition.nodeTypes.header, true, options);
499
507
  if (!headerCells) return undefined;
500
508
  const normalizedDataRows: string[][] = [];
501
509
  for (const row of dataRows) {
@@ -507,7 +515,7 @@ function normalizeTable(node: ProseMirrorNode): NormalizedTable | undefined {
507
515
  || !Array.isArray(row.content)
508
516
  || row.content.length !== columnCount
509
517
  ) return undefined;
510
- const normalized = normalizeTableRow(row, definition.nodeTypes.cell, false);
518
+ const normalized = normalizeTableRow(row, definition.nodeTypes.cell, false, options);
511
519
  if (!normalized) return undefined;
512
520
  normalizedDataRows.push(normalized);
513
521
  }
@@ -523,6 +531,7 @@ function normalizeTableRow(
523
531
  row: ProseMirrorNode,
524
532
  expectedCellType: string,
525
533
  header: boolean,
534
+ options: RenderProseMirrorOptions,
526
535
  ): string[] | undefined {
527
536
  if (!Array.isArray(row.content)) return undefined;
528
537
  const cells: string[] = [];
@@ -533,7 +542,7 @@ function normalizeTableRow(
533
542
  || cell.type !== expectedCellType
534
543
  || !hasNoAttrs(cell)
535
544
  ) return undefined;
536
- const normalized = normalizeTableCell(cell);
545
+ const normalized = normalizeTableCell(cell, options);
537
546
  if (!normalized) return undefined;
538
547
  if (header && freeFormSectionRegistry.table.cells.headerNonWhitespace && normalized.text.trim() === "") {
539
548
  return undefined;
@@ -543,7 +552,10 @@ function normalizeTableRow(
543
552
  return cells;
544
553
  }
545
554
 
546
- function normalizeTableCell(cell: ProseMirrorNode): { html: string; text: string } | undefined {
555
+ function normalizeTableCell(
556
+ cell: ProseMirrorNode,
557
+ options: RenderProseMirrorOptions,
558
+ ): { html: string; text: string } | undefined {
547
559
  const paragraphs = cell.content;
548
560
  const paragraph = paragraphs?.[0];
549
561
  if (
@@ -573,7 +585,7 @@ function normalizeTableCell(cell: ProseMirrorNode): { html: string; text: string
573
585
  || inlineNode.text === ""
574
586
  ) return undefined;
575
587
  text += inlineNode.text;
576
- html.push(renderTextNode(inlineNode));
588
+ html.push(renderTextNode(inlineNode, options));
577
589
  } else if (inlineNode.type === "hardBreak") {
578
590
  if (
579
591
  !hasOnlySerializedKeys(inlineNode, ["type", "attrs", "marks"])
@@ -658,7 +670,7 @@ function renderInlineFacts(
658
670
  if (!facts) return "";
659
671
  const items = facts.map((fact) => {
660
672
  const value = fact.url
661
- ? renderElement("a", { href: fact.url }, escapeHtml(fact.value))
673
+ ? renderLink(fact.url, {}, escapeHtml(fact.value), options)
662
674
  : escapeHtml(fact.value);
663
675
  const label = fact.label === undefined
664
676
  ? ""
@@ -820,11 +832,15 @@ function renderSection(
820
832
  }
821
833
 
822
834
  type NormalizedSectionImage = {
835
+ imageId: string;
823
836
  src: string;
824
837
  urls: Array<{ minWidth: number; url: string }>;
825
838
  width?: number;
826
839
  height?: number;
827
840
  alt?: string;
841
+ descriptionMode?: string | null;
842
+ defaultDescription?: string | null;
843
+ descriptionRevision?: number | string | null;
828
844
  };
829
845
 
830
846
  type NormalizedSectionBackground = {
@@ -912,12 +928,17 @@ function normalizeSectionImage(
912
928
  || (hasOwn(value, "height") && !isPositiveInteger(value.height))
913
929
  || (hasOwn(value, "alt") && typeof value.alt !== "string")
914
930
  ) return undefined;
931
+ if (imageDescriptionIssues(value).length > 0) return undefined;
915
932
  return {
933
+ imageId: value.imageId as string,
916
934
  src,
917
935
  urls: candidates,
918
936
  width: isPositiveInteger(value.width) ? value.width : undefined,
919
937
  height: isPositiveInteger(value.height) ? value.height : undefined,
920
938
  alt: typeof value.alt === "string" ? value.alt : undefined,
939
+ descriptionMode: value.descriptionMode as string | null | undefined,
940
+ defaultDescription: value.defaultDescription as string | null | undefined,
941
+ descriptionRevision: value.descriptionRevision as number | string | null | undefined,
921
942
  };
922
943
  }
923
944
 
@@ -1020,7 +1041,7 @@ type NormalizedSectionDecoration = {
1020
1041
  anchor: string;
1021
1042
  inlineOffsetPercent: number;
1022
1043
  blockOffsetPercent: number;
1023
- inlineSizePercent: number;
1044
+ size: string;
1024
1045
  opacity: number;
1025
1046
  tintToken: string;
1026
1047
  };
@@ -1032,7 +1053,7 @@ function normalizedSectionDecorationStyle(value: unknown): string[] | undefined
1032
1053
  `--esp-section-decoration-image: url("${escapeCssString(decoration.imageUrl)}")`,
1033
1054
  `--esp-section-decoration-color: var(${decoration.tintToken})`,
1034
1055
  `--esp-section-decoration-position: ${sectionDecorationPosition(decoration)}`,
1035
- `--esp-section-decoration-size: ${decoration.inlineSizePercent}% auto`,
1056
+ `--esp-section-decoration-size: ${decoration.size}`,
1036
1057
  `--esp-section-decoration-opacity: ${decoration.opacity}`,
1037
1058
  ];
1038
1059
  }
@@ -1054,6 +1075,10 @@ function normalizeSectionDecoration(value: unknown): NormalizedSectionDecoration
1054
1075
  const anchor = value.anchor ?? fields.anchor.default;
1055
1076
  const inlineOffsetPercent = value.inlineOffsetPercent ?? fields.inlineOffsetPercent.default;
1056
1077
  const blockOffsetPercent = value.blockOffsetPercent ?? fields.blockOffsetPercent.default;
1078
+ // TR01329: width and height sizes are exclusive. Read the raw keys, because
1079
+ // the width default is filled in below.
1080
+ if (value.inlineSizePercent !== undefined && value.blockSizePercent !== undefined) return undefined;
1081
+ const blockSizePercent = value.blockSizePercent;
1057
1082
  const inlineSizePercent = value.inlineSizePercent ?? fields.inlineSizePercent.default;
1058
1083
  const opacity = value.opacity ?? fields.opacity.default;
1059
1084
  const tint = value.tint ?? fields.tint.default;
@@ -1064,7 +1089,12 @@ function normalizeSectionDecoration(value: unknown): NormalizedSectionDecoration
1064
1089
  if (!isBoundedNumber(blockOffsetPercent, fields.blockOffsetPercent.minimum, fields.blockOffsetPercent.maximum)) {
1065
1090
  return undefined;
1066
1091
  }
1067
- if (
1092
+ if (blockSizePercent !== undefined) {
1093
+ if (
1094
+ !isBoundedNumber(blockSizePercent, fields.blockSizePercent.minimum, fields.blockSizePercent.maximum)
1095
+ || !Number.isInteger(blockSizePercent)
1096
+ ) return undefined;
1097
+ } else if (
1068
1098
  !isBoundedNumber(inlineSizePercent, fields.inlineSizePercent.minimum, fields.inlineSizePercent.maximum)
1069
1099
  || !Number.isInteger(inlineSizePercent)
1070
1100
  ) return undefined;
@@ -1078,7 +1108,7 @@ function normalizeSectionDecoration(value: unknown): NormalizedSectionDecoration
1078
1108
  anchor,
1079
1109
  inlineOffsetPercent,
1080
1110
  blockOffsetPercent,
1081
- inlineSizePercent,
1111
+ size: blockSizePercent !== undefined ? `auto ${blockSizePercent}%` : `${inlineSizePercent}% auto`,
1082
1112
  opacity,
1083
1113
  tintToken,
1084
1114
  };
@@ -1406,7 +1436,11 @@ export const LINK_REL_TOKENS = [
1406
1436
  "privacy-policy",
1407
1437
  "terms-of-service",
1408
1438
  ] as const;
1409
- /** The targets a link may carry; only `_blank` changes what renders. */
1439
+ /**
1440
+ * The targets a link may carry. `_blank` opens a new tab and `_self` keeps the
1441
+ * tab; with a site host set, any other value leaves the choice to the external
1442
+ * link rule (TR01327).
1443
+ */
1410
1444
  export const LINK_TARGETS = ["_blank", "_self"] as const;
1411
1445
  /**
1412
1446
  * The editor stored this rel, with `target="_blank"`, on every link before
@@ -1435,18 +1469,42 @@ export function chosenLinkRelTokens(rel: unknown): string[] | undefined {
1435
1469
  /**
1436
1470
  * The `rel` and `target` a link publishes with: the author's allowed tokens in
1437
1471
  * their order, and noopener and noreferrer whenever it opens a new tab.
1438
- * Anything else stored is ignored.
1472
+ * `_blank` opens a new tab and `_self` keeps the tab. Any other target, or a
1473
+ * mark with the old editor rel (whose target was never a choice), follows the
1474
+ * external link rule when a host set is supplied, and otherwise stays in the
1475
+ * tab. Anything else stored is ignored.
1439
1476
  */
1440
- export function linkRelAttributes(attrs: Record<string, unknown> | undefined): { rel?: string; target?: string } {
1477
+ export function linkRelAttributes(
1478
+ attrs: Record<string, unknown> | undefined,
1479
+ href?: string,
1480
+ siteHosts?: ReadonlySet<string>,
1481
+ ): { rel?: string; target?: string } {
1441
1482
  const stored = chosenLinkRelTokens(attrs?.rel);
1442
- if (!stored) return {};
1443
- const newTab = stringAttr(attrs?.target)?.trim().toLowerCase() === "_blank";
1483
+ const target = stringAttr(attrs?.target)?.trim().toLowerCase();
1484
+ const byRule = () => siteHosts !== undefined && isExternalLink(href, siteHosts);
1485
+ const newTab = stored && (target === "_blank" || target === "_self")
1486
+ ? target === "_blank"
1487
+ : byRule();
1444
1488
  const allowed: readonly string[] = LINK_REL_TOKENS;
1445
- const rel = new Set([
1446
- ...stored.filter((token) => allowed.includes(token)),
1447
- ...(newTab ? ["noopener", "noreferrer"] : []),
1448
- ]);
1449
- return { ...(rel.size ? { rel: [...rel].join(" ") } : {}), ...(newTab ? { target: "_blank" } : {}) };
1489
+ const kept = (stored ?? []).filter((token) => allowed.includes(token));
1490
+ const rel = newTab ? relWithNewTabTokens(kept.join(" ")) : [...new Set(kept)].join(" ");
1491
+ return { ...(rel ? { rel } : {}), ...(newTab ? { target: "_blank" } : {}) };
1492
+ }
1493
+
1494
+ /**
1495
+ * An anchor with the attributes `linkRelAttributes` chooses. Only a publish
1496
+ * passes a host set, and only then does a new-tab link carry its hidden cue,
1497
+ * because the editor preview and legal PDFs have no rule to hide it (TR01327).
1498
+ */
1499
+ function renderLink(
1500
+ href: string,
1501
+ attrs: Record<string, unknown> | undefined,
1502
+ html: string,
1503
+ options: RenderProseMirrorOptions,
1504
+ ): string {
1505
+ const linkAttributes = linkRelAttributes(attrs, href, options.siteHosts);
1506
+ const cue = options.siteHosts !== undefined && linkAttributes.target === "_blank" ? NEW_TAB_CUE_HTML : "";
1507
+ return renderElement("a", { href, ...linkAttributes }, `${html}${cue}`);
1450
1508
  }
1451
1509
 
1452
1510
  function isSafeUrl(url: string): boolean {
@@ -1,3 +1,4 @@
1
+ import { IMAGE_DESCRIPTION_MODES } from "./image-description-validation.js";
1
2
  import { validateComponentBlock } from "./components.js";
2
3
  import {
3
4
  FREE_FORM_SECTION_REGISTRY,
@@ -180,6 +181,7 @@ const IMAGE_ATTRS = Object.freeze({
180
181
  ),
181
182
  urls: URLS_SPEC,
182
183
  alt: optionalString(),
184
+ descriptionMode: optionalEnum(IMAGE_DESCRIPTION_MODES),
183
185
  width: optionalPositiveInteger(),
184
186
  height: optionalPositiveInteger(),
185
187
  crop: CROP_SPEC,
@@ -0,0 +1,84 @@
1
+ import { normalizeImageDescriptionRevision } from "./content/image-description-validation.js";
2
+ import { LIMITS } from "./constants.js";
3
+ import { hasControlCharacter, SiteAuthoringError } from "./errors.js";
4
+
5
+ export const IMAGE_LIST_DEFAULT_PAGE_SIZE = 25;
6
+ export const IMAGE_LIST_MAXIMUM_PAGE_SIZE = 100;
7
+ const IMAGE_ID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/u;
8
+
9
+ export function isImageId(value) {
10
+ return typeof value === "string" && IMAGE_ID.test(value) && value !== "00000000-0000-0000-0000-000000000000";
11
+ }
12
+
13
+ export function requireImageId(value) {
14
+ if (!isImageId(value)) {
15
+ throw new SiteAuthoringError("media.image_id_invalid", "Name exactly one canonical lowercase image UUID.", {
16
+ field: "imageId", exitCode: 2,
17
+ });
18
+ }
19
+ return value;
20
+ }
21
+
22
+ export { normalizeImageDescriptionRevision as imageDescriptionRevision } from "./content/image-description-validation.js";
23
+
24
+ export function imageListOptions(invocation = {}) {
25
+ const pageSize = invocation.pageSize === undefined ? IMAGE_LIST_DEFAULT_PAGE_SIZE : Number(invocation.pageSize);
26
+ if ((invocation.pageSize !== undefined && !["number", "string"].includes(typeof invocation.pageSize))
27
+ || !Number.isInteger(pageSize) || pageSize < 1 || pageSize > IMAGE_LIST_MAXIMUM_PAGE_SIZE
28
+ || (typeof invocation.pageSize === "string" && !/^[1-9][0-9]*$/u.test(invocation.pageSize))) {
29
+ throw new SiteAuthoringError("media.image_page_size_invalid", "--page-size must be a whole number from 1 to 100.", {
30
+ field: "pageSize", exitCode: 2,
31
+ });
32
+ }
33
+ const pageToken = invocation.pageToken ?? "";
34
+ if (typeof pageToken !== "string" || !pageToken.isWellFormed()
35
+ || Buffer.byteLength(pageToken, "utf8") > LIMITS.configPathBytes) {
36
+ throw new SiteAuthoringError("media.image_page_token_invalid", "--page-token must be the cursor from the previous result.", {
37
+ field: "pageToken", exitCode: 2,
38
+ });
39
+ }
40
+ return { pageSize, pageToken, missingDescription: invocation.missingDescription === true };
41
+ }
42
+
43
+ export function imageDescriptionOptions(invocation = {}) {
44
+ const hasDescription = invocation.imageDescription !== undefined;
45
+ const clear = invocation.clearDescription === true;
46
+ if (hasDescription === clear) {
47
+ throw new SiteAuthoringError("media.image_description_choice", "Supply exactly one of --description or --clear.", {
48
+ field: "description", exitCode: 2,
49
+ });
50
+ }
51
+ const description = clear ? "" : invocation.imageDescription;
52
+ if (typeof description !== "string" || !description.isWellFormed()
53
+ || Buffer.byteLength(description, "utf8") > LIMITS.configBytes) {
54
+ throw new SiteAuthoringError("media.image_description_invalid", "--description needs bounded, valid Unicode text.", {
55
+ field: "description", exitCode: 2,
56
+ });
57
+ }
58
+ const expectedRevision = normalizeImageDescriptionRevision(invocation.expectedRevision);
59
+ if (expectedRevision === undefined) {
60
+ throw new SiteAuthoringError("media.image_revision_required",
61
+ "Supply --expected-revision from the image's list/get result, as a nonnegative int64 decimal integer.", {
62
+ field: "expectedRevision", exitCode: 2,
63
+ });
64
+ }
65
+ const onlyIfMissing = invocation.onlyIfMissing === true;
66
+ if (onlyIfMissing && description.trim() === "") {
67
+ throw new SiteAuthoringError("media.image_condition_clear", "--if-missing cannot clear a description.", {
68
+ field: "onlyIfMissing", exitCode: 2,
69
+ });
70
+ }
71
+ return { defaultDescription: description, expectedRevision, onlyIfMissing };
72
+ }
73
+
74
+ export function safeImagePreviewUrl(value) {
75
+ if (typeof value !== "string" || !value.isWellFormed() || value.length > LIMITS.configPathBytes
76
+ || hasControlCharacter(value) || /[\s\\]/u.test(value)) return "";
77
+ try {
78
+ const url = new URL(value);
79
+ return url.protocol === "https:" && url.username === "" && url.password === ""
80
+ && url.search === "" && url.hash === "" ? url.href : "";
81
+ } catch {
82
+ return "";
83
+ }
84
+ }
@@ -0,0 +1,47 @@
1
+ const READY = "NAVIGATION_PUBLISHING_READINESS_STATE_READY";
2
+ const BLOCKED = "NAVIGATION_PUBLISHING_READINESS_STATE_BLOCKED";
3
+ const UNKNOWN = "NAVIGATION_PUBLISHING_READINESS_STATE_UNKNOWN";
4
+ const REASON_PREFIX = "NAVIGATION_PUBLISHING_READINESS_BLOCKER_REASON_";
5
+ const MESSAGES = {
6
+ PAGE_NOT_INCLUDED: "Include this approved page in the release.",
7
+ PAGE_NOT_APPROVED: "Approve this page and include it in the release.",
8
+ FOLDER_NEEDS_RELEASED_CONTENT: "Publish a qualifying page under this path, or approve and include one in this release.",
9
+ TARGET_UNAVAILABLE: "Remove or replace this unavailable navigation target.",
10
+ };
11
+
12
+ export function normalizeNavigationReadiness(value) {
13
+ return {
14
+ state: [READY, BLOCKED].includes(value?.state) ? value.state : UNKNOWN,
15
+ blockers: (Array.isArray(value?.blockers) ? value.blockers : []).map((blocker) => ({
16
+ resourceId: typeof blocker?.resourceId === "string" ? blocker.resourceId : "",
17
+ pageId: typeof blocker?.pageId === "string" ? blocker.pageId : "",
18
+ title: typeof blocker?.title === "string" ? blocker.title : "",
19
+ path: typeof blocker?.path === "string" ? blocker.path : "",
20
+ reason: typeof blocker?.reason === "string" ? blocker.reason : "",
21
+ })),
22
+ };
23
+ }
24
+
25
+ export function isNavigationReady(navigation) {
26
+ return navigation.state === READY;
27
+ }
28
+
29
+ export function describeNavigationReadiness(navigation) {
30
+ if (isNavigationReady(navigation)) return "Navigation is ready.";
31
+ if (navigation.state !== BLOCKED) return "Navigation could not be checked. Try the readiness check again.";
32
+ const details = navigation.blockers.slice(0, 20).map((blocker) => {
33
+ const target = blocker.title || "Unavailable page";
34
+ const path = blocker.pageId ? ` (/${blocker.path.replace(/^\/+/, "")})` : "";
35
+ const message = MESSAGES[blocker.reason.replace(REASON_PREFIX, "")] || MESSAGES.TARGET_UNAVAILABLE;
36
+ return `${target}${path}: ${message}`;
37
+ });
38
+ return details.length ? `Navigation is blocked. ${details.join(" ")}` : "Navigation cannot be published with this set.";
39
+ }
40
+
41
+ export function reportNavigationReadiness(navigation) {
42
+ return {
43
+ state: navigation.state,
44
+ blockers: navigation.blockers.slice(0, 20),
45
+ ...(navigation.blockers.length > 20 ? { blockersTruncated: true } : {}),
46
+ };
47
+ }
package/src/output.js CHANGED
@@ -159,6 +159,14 @@ export async function writeGithubActionsOutput(outputPath, result) {
159
159
  const safeResult = structuredClone(result);
160
160
  if (safeResult.verb === "preview page") delete safeResult.url;
161
161
  if (safeResult.stagingPreview) delete safeResult.stagingPreview.url;
162
+ if (["media images list", "media images get", "media images describe"].includes(safeResult.verb)) {
163
+ const images = safeResult.image ? [safeResult.image] : safeResult.images?.items ?? [];
164
+ for (const image of images) {
165
+ delete image.previewUrl;
166
+ delete image.responsivePreviewUrls;
167
+ }
168
+ }
169
+ for (const image of safeResult.siteHealth?.imageFindings ?? []) delete image.previewUrl;
162
170
  const json = requireBoundedJson(safeResult);
163
171
  const delimiter = githubDelimiter(json);
164
172
  const lines = [