@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.
- package/README.md +68 -12
- package/package.json +2 -2
- package/src/api.js +110 -3
- package/src/changelog.json +32 -0
- package/src/cli.js +102 -6
- package/src/constants.js +3 -0
- package/src/content/components.js +111 -18
- package/src/content/free-form-section-registry.json +31 -5
- package/src/content/free-form-sections.js +11 -0
- package/src/content/image-description-document.js +108 -0
- package/src/content/image-description-validation.js +70 -0
- package/src/content/tiptap-prosemirror.ts +10 -0
- package/src/content/validate-document.js +2 -0
- package/src/image-description-contract.js +84 -0
- package/src/navigation-readiness.js +47 -0
- package/src/output.js +8 -0
- package/src/page-metadata.js +10 -10
- package/src/page-structure-checks.js +50 -45
- package/src/reference-help.js +8 -4
- package/src/site-check-findings.js +18 -12
- package/src/site-check-rules.json +10 -6
- package/src/typed-pages.js +25 -9
- package/src/verbs/deploy.js +11 -0
- package/src/verbs/index.js +10 -0
- package/src/verbs/media-images.js +67 -0
- package/src/verbs/pages-push.js +2 -1
- package/src/verbs/status.js +19 -5
|
@@ -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
|
-
*
|
|
73
|
-
* height
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
|
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(
|
|
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":
|
|
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,
|