@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.
- package/README.md +87 -12
- package/examples/riverbend-wellness/settings/taproot-styles.json +1 -0
- package/package.json +2 -2
- package/src/api.js +110 -3
- package/src/appearance-contract.js +11 -0
- package/src/block-flow.js +117 -0
- package/src/changelog.json +45 -0
- package/src/cli.js +102 -6
- package/src/constants.js +3 -0
- package/src/content/components.js +28 -15
- package/src/content/external-link.js +77 -0
- package/src/content/free-form-section-registry.json +30 -4
- package/src/content/free-form-sections.js +29 -1
- package/src/content/image-description-document.js +108 -0
- package/src/content/image-description-validation.js +70 -0
- package/src/content/tiptap-prosemirror.ts +85 -27
- 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/presentation-reference.js +9 -0
- package/src/reference-help.js +23 -10
- package/src/setting-reference.js +9 -0
- package/src/settings-catalog.js +1 -0
- 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
|
@@ -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
|
|
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(
|
|
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
|
-
?
|
|
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
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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(
|
|
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
|
-
|
|
1443
|
-
const
|
|
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
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
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 = [
|