@taprootio/site-authoring 0.29.0 → 0.31.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.
@@ -8,6 +8,8 @@ import registry from "./free-form-section-registry.json" with { type: "json" };
8
8
  import { hasAsciiControl } from "../errors.js";
9
9
  import { isSocialLinkUrl, SOCIAL_LINK_URL_MAX_LENGTH, trimSocialLinkInput } from "../social-link-url.js";
10
10
  import socialPlatforms from "../social-platforms.json" with { type: "json" };
11
+ import { IMAGE_DESCRIPTION_MODES } from "./image-description-validation.js";
12
+ import { stripImageDescriptionHydration } from "./image-description-document.js";
11
13
  import {
12
14
  CONTENT_ERROR_CODES as CODES,
13
15
  CONTENT_LIMITS,
@@ -69,8 +71,8 @@ const list = (item, options = {}) => Object.freeze({ kind: "array", item, ...opt
69
71
  const shape = (fields, options = {}) => Object.freeze({ kind: "object", fields: Object.freeze(fields), ...options });
70
72
  /**
71
73
  * A processed-image record or null. `required` refuses null, `altRequired`
72
- * refuses a blank alt and `dimensionsRequired` demands positive width and
73
- * height, for a component whose images are the content (TR01322).
74
+ * requires authored alt unless inherited, and `dimensionsRequired` demands
75
+ * positive width and height for a component whose images are the content (TR01322).
74
76
  */
75
77
  const image = (options = {}) => Object.freeze({ kind: "image", ...options });
76
78
  /**
@@ -115,6 +117,7 @@ const COMPONENT_IMAGE_FIELDS = Object.freeze({
115
117
  width: num({ integer: true, minimum: 0 }),
116
118
  height: num({ integer: true, minimum: 0 }),
117
119
  alt: str(),
120
+ descriptionMode: Object.freeze({ ...enumOf(...IMAGE_DESCRIPTION_MODES), nullable: true }),
118
121
  });
119
122
 
120
123
  /**
@@ -247,6 +250,22 @@ const LATEST_POSTS = Object.freeze({
247
250
  bannerScheme: enumOf("auto", "light", "dark"),
248
251
  });
249
252
 
253
+ /** The registry owns the row heights; the help text and the renderer derive from the same map. */
254
+ const IMAGE_ROW_HEIGHTS = registry.components["image-row"].heights;
255
+ const IMAGE_ROW_HEIGHT_SIZES = Object.entries(IMAGE_ROW_HEIGHTS)
256
+ .map(([name, rem]) => `${name} ${rem} (${Number.parseFloat(rem) * 16}px)`)
257
+ .join(", ");
258
+ const IMAGE_ROW_HEIGHT_HELP =
259
+ `Each image's height: ${IMAGE_ROW_HEIGHT_SIZES} at a 16px root; width follows each image's own shape, never cropped or framed.`;
260
+
261
+ /** The registry owns the card image spacings too; each side takes the same four names (TR01326). */
262
+ const CARD_IMAGE_SPACINGS = registry.components["card-grid"].imageSpacings;
263
+ const CARD_IMAGE_SPACING_NAMES = Object.keys(CARD_IMAGE_SPACINGS);
264
+ const CARD_IMAGE_SPACING_VALUES =
265
+ `none (0), small (${CARD_IMAGE_SPACINGS.small}), medium (${CARD_IMAGE_SPACINGS.medium}) or text (the same padding as around the card's text)`;
266
+ const CARD_IMAGE_SPACING_SIDE_HELP = (side, defaults) =>
267
+ `Space on the ${side} of each card image: ${CARD_IMAGE_SPACING_VALUES}. Left out, it is ${defaults}.`;
268
+
250
269
  /** `CardGridData`. `cropState` is the one genuinely optional field in types.ts. */
251
270
  const CARD_GRID = Object.freeze({
252
271
  cards: withOmission(
@@ -262,6 +281,36 @@ const CARD_GRID = Object.freeze({
262
281
  columns: num({ values: Object.freeze([2, 3]) }),
263
282
  presentation: enumOf("cards", "editorial", "featured", "poster"),
264
283
  imageAspect: enumOf("auto", "landscape", "square", "portrait"),
284
+ imageFit: withOmission(
285
+ described(
286
+ enumOf("cover", "contain"),
287
+ "cover fills the card and crops each image to imageAspect; contain shows the whole image at imageHeight, never cropped or framed, and needs each image's width and height.",
288
+ ),
289
+ { kind: "value", value: "cover" },
290
+ ),
291
+ imageHeight: described(
292
+ enumOf(...Object.keys(IMAGE_ROW_HEIGHTS)),
293
+ `${IMAGE_ROW_HEIGHT_HELP} Only with imageFit contain, where it is medium when left out; refused otherwise.`,
294
+ ),
295
+ imageAlign: described(
296
+ enumOf("start", "center", "end"),
297
+ "Where each whole image sits across its card, not the text: start is the left edge and end the right edge in a left-to-right page. Only with imageFit contain, where it is start when left out; refused otherwise.",
298
+ ),
299
+ imageSpacing: described(
300
+ shape({
301
+ top: described(enumOf(...CARD_IMAGE_SPACING_NAMES), CARD_IMAGE_SPACING_SIDE_HELP("top", "none with cover and small with contain")),
302
+ bottom: described(enumOf(...CARD_IMAGE_SPACING_NAMES), CARD_IMAGE_SPACING_SIDE_HELP("bottom", "none")),
303
+ start: described(
304
+ enumOf(...CARD_IMAGE_SPACING_NAMES),
305
+ CARD_IMAGE_SPACING_SIDE_HELP("left edge (start)", "none with cover and text with contain"),
306
+ ),
307
+ end: described(
308
+ enumOf(...CARD_IMAGE_SPACING_NAMES),
309
+ CARD_IMAGE_SPACING_SIDE_HELP("right edge (end)", "none with cover and text with contain"),
310
+ ),
311
+ }),
312
+ "Space around each card image; name only the sides to change, and a side left out keeps its default for the fit. With imageFit contain, start moves an image aligned to start and end moves one aligned to end; otherwise they only limit how wide the image can be.",
313
+ ),
265
314
  interaction: enumOf("none", "lift", "zoom", "caption-reveal"),
266
315
  borderWidth: num({
267
316
  integer: true,
@@ -274,6 +323,7 @@ const CARD_GRID = Object.freeze({
274
323
  const IMAGE_BANNER = Object.freeze({
275
324
  image: image(),
276
325
  altText: str(),
326
+ descriptionMode: Object.freeze({ ...enumOf(...IMAGE_DESCRIPTION_MODES), nullable: true }),
277
327
  overlayText: str(),
278
328
  // One supporting sentence and at most one link under the headline: the copy
279
329
  // stack Espalier 6.1 lays out with a bounded measure (TR00819).
@@ -383,9 +433,11 @@ const FORM = Object.freeze({
383
433
  * The image field table an image spec validates against. The shared table
384
434
  * leaves alt and dimensions optional; a spec that requires them swaps in
385
435
  * stricter entries at the same positions so canonical order is unchanged.
436
+ * Inherited alt is delivery state, removed before a workspace round trip.
386
437
  */
387
- function imageFieldsFor(spec) {
388
- if (!spec.altRequired && !spec.dimensionsRequired) return COMPONENT_IMAGE_FIELDS;
438
+ function imageFieldsFor(spec, value) {
439
+ const altRequired = spec.altRequired && value?.descriptionMode !== "inherit";
440
+ if (!altRequired && !spec.dimensionsRequired) return COMPONENT_IMAGE_FIELDS;
389
441
  return Object.freeze({
390
442
  ...COMPONENT_IMAGE_FIELDS,
391
443
  ...(spec.dimensionsRequired
@@ -394,7 +446,7 @@ function imageFieldsFor(spec) {
394
446
  height: num({ integer: true, minimum: 1, required: true }),
395
447
  }
396
448
  : {}),
397
- ...(spec.altRequired
449
+ ...(altRequired
398
450
  ? {
399
451
  alt: str({
400
452
  required: true,
@@ -408,14 +460,6 @@ function imageFieldsFor(spec) {
408
460
  });
409
461
  }
410
462
 
411
- /** The registry owns the row heights; the help text and the renderer derive from the same map. */
412
- const IMAGE_ROW_HEIGHTS = registry.components["image-row"].heights;
413
- const IMAGE_ROW_HEIGHT_HELP = `Each image's height: ${
414
- Object.entries(IMAGE_ROW_HEIGHTS)
415
- .map(([name, rem]) => `${name} ${rem} (${Number.parseFloat(rem) * 16}px)`)
416
- .join(", ")
417
- } at a 16px root; width follows each image's own shape, never cropped or framed.`;
418
-
419
463
  /**
420
464
  * `ImageRowData` (TR01322). A row of pictures carries no other text, so every
421
465
  * image needs alt text and the dimensions the row sizes it by.
@@ -648,7 +692,7 @@ const COMPONENT_DEFINITIONS = Object.freeze({
648
692
  "Icon images need descriptive alt text when they convey information; otherwise use an empty alt value.",
649
693
  "Item title and description are plain text: an item's url makes the whole item the link, and no link can be placed inside the text. Put linked prose in a paragraph beside the grid.",
650
694
  "iconSize sets the icon box: small 3rem (48px), medium 6rem (96px), large 10rem (160px), xlarge 16rem (256px) at a 16px root.",
651
- "Icons are cropped to a square and framed by the site's image border. For badges, logos or any picture that must keep its shape, use the image-row component instead.",
695
+ "Icons are cropped to a square and framed by the site's image border. For badges, logos or any picture that must keep its shape, use the image-row component instead, or a card-grid with imageFit contain when each needs a card.",
652
696
  ],
653
697
  {
654
698
  items: [
@@ -713,6 +757,14 @@ const COMPONENT_DEFINITIONS = Object.freeze({
713
757
  "Every linked card needs a title that describes its destination.",
714
758
  "Card images need descriptive alt text unless they are decorative.",
715
759
  "Card title and description are plain text: a card's linkUrl makes the whole card the link, and no link can be placed inside the text. Put linked prose in a paragraph beside the grid.",
760
+ "Images are cropped when imageFit is cover, the default: each is cut to the fixed imageAspect, while imageAspect auto follows the picture's own shape up to the page's height cap. imageFit contain never crops; it shows the whole image.",
761
+ "For a logo or mark use imageFit contain with a tightly cropped file that has no built-in padding, and set the size with imageHeight and the space around it with imageSpacing, not by padding the file.",
762
+ `imageHeight sets the height of a whole image: ${IMAGE_ROW_HEIGHT_SIZES} at a 16px root. Width follows the image's shape, and an image wider than its card shrinks to fit.`,
763
+ "imageHeight and imageAlign are refused with cover, and when imageFit is left out, which means cover. With contain, imageHeight is medium and imageAlign is start when left out. imageAlign moves the image only, never the text.",
764
+ `imageSpacing is an object with any of top, bottom, start and end, each ${CARD_IMAGE_SPACING_VALUES}. It works with either fit.`,
765
+ "A side left out of imageSpacing keeps its default, so name only the sides to change. Defaults for cover: none on every side. Defaults for contain: top small, bottom none, start and end text.",
766
+ "imageFit contain needs each card image's width and height, which media upload records; a card image with a missing or zero size is refused.",
767
+ "A card with a saved crop shows that crop under contain; replace the image with the whole file to show all of it. The Zoom interaction has no effect on a whole image.",
716
768
  ],
717
769
  {
718
770
  cards: [
@@ -856,7 +908,7 @@ const COMPONENT_DEFINITIONS = Object.freeze({
856
908
  "A wrapping row of pictures at one height, each with alt text and an optional link, never cropped or framed.",
857
909
  { height: "medium", align: "start" },
858
910
  [
859
- "Every image needs alt text: a row of pictures carries no other text. For a linked image the alt is the link's name, so name the destination or action, such as \"Download on the App Store\"; for an unlinked image, describe the picture.",
911
+ "Every image needs alt text unless its descriptionMode is inherit, in which case the inherited description must resolve to non-blank text before deployment. A row of pictures carries no other text. For a linked image the alt is the link's name, so name the destination or action, such as \"Download on the App Store\"; for an unlinked image, describe the picture.",
860
912
  "In Markdown the alt comes from the media manifest entry the image path names; a blank alt is refused.",
861
913
  "Each image keeps its own shape at the chosen height and is never cropped or framed; an image wider than the row shrinks to fit. Width and height are required so the row can size the image; media upload records them.",
862
914
  "Links open in the same tab, as card-grid and feature-grid links do.",
@@ -1077,6 +1129,7 @@ function referenceSchema(spec) {
1077
1129
  type: spec.nullable ? ["object", "null"] : "object",
1078
1130
  additionalProperties: false,
1079
1131
  properties: referenceProperties(spec.fields),
1132
+ ...(spec.description ? { description: spec.description } : {}),
1080
1133
  };
1081
1134
  case "image":
1082
1135
  return {
@@ -1086,7 +1139,7 @@ function referenceSchema(spec) {
1086
1139
  description: `In Markdown, a media path recorded by media upload (such as "media/photo.webp")${
1087
1140
  spec.required ? "" : ", or null"
1088
1141
  }; pages push stores the image record. A .pm.json document carries that record: keep imageId, src, and urls keys so the server can rewrite delivery URLs.${
1089
- spec.altRequired ? " Alt text is required." : ""
1142
+ spec.altRequired ? " Alt text is required unless descriptionMode is inherit; inherited text must resolve before deployment." : ""
1090
1143
  }${spec.dimensionsRequired ? " Width and height are required." : ""}`,
1091
1144
  additionalProperties: false,
1092
1145
  properties: referenceProperties(imageFieldsFor(spec)),
@@ -1278,7 +1331,7 @@ function validateValue(spec, value, path, errors) {
1278
1331
  fail(spec.required ? "an image object" : "an image object or null");
1279
1332
  return;
1280
1333
  }
1281
- validateFields(imageFieldsFor(spec), value, path, errors);
1334
+ validateFields(imageFieldsFor(spec, value), value, path, errors);
1282
1335
  return;
1283
1336
  }
1284
1337
  default:
@@ -1341,6 +1394,44 @@ export function expandComponentAuthoringShorthand(componentType, source) {
1341
1394
  return { source: JSON.stringify({ provider: target.provider, videoId: target.videoId, ...rest }) };
1342
1395
  }
1343
1396
 
1397
+ /**
1398
+ * The card grid's image rules that need two fields (TR01326): imageHeight and
1399
+ * imageAlign only mean something with `contain`, and a contained image is
1400
+ * sized from its stored width and height. The published renderer's readiness
1401
+ * check applies the same rules at the same paths.
1402
+ */
1403
+ function validateCardImageFit(parsed, dataPath, errors) {
1404
+ const fit = parsed.imageFit;
1405
+ // An invalid imageFit was already reported by `validateFields`.
1406
+ if (fit !== undefined && fit !== "cover" && fit !== "contain") return;
1407
+ if (fit !== "contain") {
1408
+ for (const field of ["imageHeight", "imageAlign"]) {
1409
+ if (parsed[field] === undefined) continue;
1410
+ errors.push(contentError(
1411
+ `${dataPath}/${field}`,
1412
+ CODES.componentData,
1413
+ `${field} applies only when imageFit is "contain"; remove ${field}, or set imageFit to "contain".`,
1414
+ ));
1415
+ }
1416
+ return;
1417
+ }
1418
+ if (!Array.isArray(parsed.cards)) return;
1419
+ for (const [index, card] of parsed.cards.entries()) {
1420
+ if (!isPlainObject(card) || !isPlainObject(card.image)) continue;
1421
+ for (const dimension of ["width", "height"]) {
1422
+ const value = card.image[dimension];
1423
+ // A wrong-type dimension was already reported by `validateFields`.
1424
+ if (value !== undefined && typeof value !== "number") continue;
1425
+ if (value !== undefined && value !== 0) continue;
1426
+ errors.push(contentError(
1427
+ `${dataPath}/cards/${index}/image/${dimension}`,
1428
+ CODES.componentData,
1429
+ `With imageFit "contain" a card image needs its ${dimension} in pixels; upload the image again with media upload, or set imageFit to "cover".`,
1430
+ ));
1431
+ }
1432
+ }
1433
+ }
1434
+
1344
1435
  /**
1345
1436
  * Validates a `componentBlock`'s `componentType` and `componentData` pair.
1346
1437
  *
@@ -1392,6 +1483,7 @@ export function validateComponentBlock(componentType, componentData, basePath) {
1392
1483
  const fields = isComponentType(componentType) ? COMPONENT_SHAPES[componentType] : undefined;
1393
1484
  if (!fields) return errors;
1394
1485
  validateFields(fields, parsed, dataPath, errors);
1486
+ if (componentType === "card-grid") validateCardImageFit(parsed, dataPath, errors);
1395
1487
  if (componentType === "card-grid" && Array.isArray(parsed.cards)) {
1396
1488
  for (const [index, card] of parsed.cards.entries()) {
1397
1489
  if (
@@ -1507,9 +1599,10 @@ export function canonicalizeComponentData(componentType, data) {
1507
1599
  const fields = isComponentType(componentType) ? COMPONENT_SHAPES[componentType] : undefined;
1508
1600
  if (!fields) throw new TypeError("canonicalizeComponentData requires a known component type.");
1509
1601
  if (!isPlainObject(data)) throw new TypeError("canonicalizeComponentData requires an object.");
1602
+ const authored = stripImageDescriptionHydration(data);
1510
1603
  const ordered = {};
1511
1604
  for (const key of Object.keys(fields)) {
1512
- if (Object.hasOwn(data, key)) ordered[key] = orderValue(fields[key], data[key]);
1605
+ if (Object.hasOwn(authored, key)) ordered[key] = orderValue(fields[key], authored[key]);
1513
1606
  }
1514
1607
  for (const key of Object.keys(data)) {
1515
1608
  if (!Object.hasOwn(fields, key)) throw new TypeError("canonicalizeComponentData requires validated data.");
@@ -1,5 +1,16 @@
1
1
  {
2
- "version": 13,
2
+ "version": 15,
3
+
4
+ "imageDescription": {
5
+ "fields": {
6
+ "descriptionMode": { "kind": "mode", "storage": "persisted" },
7
+ "defaultDescription": { "kind": "text", "storage": "hydratedReadOnly" },
8
+ "descriptionRevision": { "kind": "revision", "storage": "hydratedReadOnly" }
9
+ },
10
+ "modes": ["inherit", "custom", "decorative"],
11
+ "maxDefaultDescriptionScalars": 1000,
12
+ "maxRevision": "9223372036854775807"
13
+ },
3
14
  "section": {
4
15
  "nodeType": "section",
5
16
  "label": "Section",
@@ -84,7 +95,10 @@
84
95
  "optionalKeys": [
85
96
  "width",
86
97
  "height",
87
- "alt"
98
+ "alt",
99
+ "descriptionMode",
100
+ "defaultDescription",
101
+ "descriptionRevision"
88
102
  ],
89
103
  "urls": {
90
104
  "minItems": 1,
@@ -124,7 +138,10 @@
124
138
  "optionalKeys": [
125
139
  "width",
126
140
  "height",
127
- "alt"
141
+ "alt",
142
+ "descriptionMode",
143
+ "defaultDescription",
144
+ "descriptionRevision"
128
145
  ],
129
146
  "urls": {
130
147
  "minItems": 1,
@@ -237,7 +254,10 @@
237
254
  "optionalKeys": [
238
255
  "width",
239
256
  "height",
240
- "alt"
257
+ "alt",
258
+ "descriptionMode",
259
+ "defaultDescription",
260
+ "descriptionRevision"
241
261
  ],
242
262
  "urls": {
243
263
  "minItems": 1,
@@ -548,7 +568,13 @@
548
568
  "card-grid": {
549
569
  "label": "Card Grid",
550
570
  "measure": "wide",
551
- "rootPlacement": "flow"
571
+ "rootPlacement": "flow",
572
+ "imageSpacings": {
573
+ "none": "0rem",
574
+ "small": "1em",
575
+ "medium": "2em",
576
+ "text": "var(--esp-size-small-to-normal, 1rem)"
577
+ }
552
578
  },
553
579
  "image-banner": {
554
580
  "label": "Image Banner",
@@ -1,4 +1,5 @@
1
1
  import registry from "./free-form-section-registry.json" with { type: "json" };
2
+ import { imageDescriptionIssues, pickImageDescriptionFields } from "./image-description-validation.js";
2
3
 
3
4
  import {
4
5
  CONTENT_ERROR_CODES as CODES,
@@ -45,6 +46,12 @@ function decorationDeliveryUrlIsSafe(value) {
45
46
  }
46
47
  }
47
48
 
49
+ function validateImageDescription(image, path, label, report) {
50
+ for (const issue of imageDescriptionIssues(image)) {
51
+ report(`${path}/${issue.field}`, CODES.attrInvalid, `${label} ${issue.field} must ${issue.expected}.`);
52
+ }
53
+ }
54
+
48
55
  function normalizeProcessedSectionImage(value, definition, path, label, report) {
49
56
  let valid = true;
50
57
  const reject = (errorPath, code, message) => {
@@ -170,6 +177,7 @@ function normalizeProcessedSectionImage(value, definition, path, label, report)
170
177
  reject(`${path}/alt`, CODES.attrInvalid, `${label} alt must be a string.`);
171
178
  }
172
179
 
180
+ validateImageDescription(value, path, label, reject);
173
181
  if (!valid) return undefined;
174
182
  return Object.freeze({
175
183
  imageId: value.imageId,
@@ -182,6 +190,7 @@ function normalizeProcessedSectionImage(value, definition, path, label, report)
182
190
  ...(Object.hasOwn(value, "width") ? { width: value.width } : {}),
183
191
  ...(Object.hasOwn(value, "height") ? { height: value.height } : {}),
184
192
  ...(Object.hasOwn(value, "alt") ? { alt: value.alt } : {}),
193
+ ...pickImageDescriptionFields(value),
185
194
  });
186
195
  }
187
196
 
@@ -457,6 +466,7 @@ export function normalizeFreeFormSectionDecoration(
457
466
  if (Object.hasOwn(image, "alt") && typeof image.alt !== "string") {
458
467
  report(`${imagePath}/alt`, CODES.attrInvalid, "Section decoration image alt must be a string.");
459
468
  }
469
+ validateImageDescription(image, imagePath, "Section decoration image", report);
460
470
  }
461
471
 
462
472
  for (const [name, definition] of Object.entries(fields)) {
@@ -500,6 +510,7 @@ export function normalizeFreeFormSectionDecoration(
500
510
  ...(Object.hasOwn(image, "width") ? { width: image.width } : {}),
501
511
  ...(Object.hasOwn(image, "height") ? { height: image.height } : {}),
502
512
  ...(Object.hasOwn(image, "alt") ? { alt: image.alt } : {}),
513
+ ...pickImageDescriptionFields(image),
503
514
  };
504
515
  const normalized = { image: Object.freeze(normalizedImage) };
505
516
  for (const [name, definition] of Object.entries(fields)) {
@@ -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,7 @@
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";
11
12
  import {
12
13
  explicitImageMaxHeight,
13
14
  mediaOverrideStyleParts,
@@ -820,11 +821,15 @@ function renderSection(
820
821
  }
821
822
 
822
823
  type NormalizedSectionImage = {
824
+ imageId: string;
823
825
  src: string;
824
826
  urls: Array<{ minWidth: number; url: string }>;
825
827
  width?: number;
826
828
  height?: number;
827
829
  alt?: string;
830
+ descriptionMode?: string | null;
831
+ defaultDescription?: string | null;
832
+ descriptionRevision?: number | string | null;
828
833
  };
829
834
 
830
835
  type NormalizedSectionBackground = {
@@ -912,12 +917,17 @@ function normalizeSectionImage(
912
917
  || (hasOwn(value, "height") && !isPositiveInteger(value.height))
913
918
  || (hasOwn(value, "alt") && typeof value.alt !== "string")
914
919
  ) return undefined;
920
+ if (imageDescriptionIssues(value).length > 0) return undefined;
915
921
  return {
922
+ imageId: value.imageId as string,
916
923
  src,
917
924
  urls: candidates,
918
925
  width: isPositiveInteger(value.width) ? value.width : undefined,
919
926
  height: isPositiveInteger(value.height) ? value.height : undefined,
920
927
  alt: typeof value.alt === "string" ? value.alt : undefined,
928
+ descriptionMode: value.descriptionMode as string | null | undefined,
929
+ defaultDescription: value.defaultDescription as string | null | undefined,
930
+ descriptionRevision: value.descriptionRevision as number | string | null | undefined,
921
931
  };
922
932
  }
923
933
 
@@ -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,