@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
package/src/cli.js
CHANGED
|
@@ -39,6 +39,9 @@ import {
|
|
|
39
39
|
VERB_MARKETING_PULL,
|
|
40
40
|
VERB_MARKETING_PUSH,
|
|
41
41
|
VERB_MEDIA_UPLOAD,
|
|
42
|
+
VERB_MEDIA_IMAGES_LIST,
|
|
43
|
+
VERB_MEDIA_IMAGES_GET,
|
|
44
|
+
VERB_MEDIA_IMAGES_DESCRIBE,
|
|
42
45
|
VERB_NAV_PUSH,
|
|
43
46
|
VERB_PAGES_DELETE,
|
|
44
47
|
VERB_PAGES_META_SET,
|
|
@@ -94,6 +97,7 @@ import { FONT_CATEGORIES, getFontCatalogReference } from "./font-catalog.js";
|
|
|
94
97
|
import { LINK_SOURCES } from "./marketing-contract.js";
|
|
95
98
|
import { assertCliCurrent } from "./session.js";
|
|
96
99
|
import { ApiError } from "./transport.js";
|
|
100
|
+
import { imageDescriptionOptions, imageListOptions, requireImageId } from "./image-description-contract.js";
|
|
97
101
|
import { formatThemeResolve } from "./theme-resolve.js";
|
|
98
102
|
import { MAXIMUM_THEME_OPEN_MAP_KEY_SCALARS } from "./theme-validation.js";
|
|
99
103
|
import { VERB_HANDLERS } from "./verbs/index.js";
|
|
@@ -148,10 +152,14 @@ const VERBS = Object.freeze([
|
|
|
148
152
|
+ "title or hero title is the page's h1, seo.h1_demoted when a # heading is published as an h2 because the "
|
|
149
153
|
+ "banner's overlay text, a hero title or an earlier # heading already is the h1 (detail names which), "
|
|
150
154
|
+ "seo.heading_renumbered and seo.heading_before_title for headings published at another level, "
|
|
151
|
-
+ "a11y.
|
|
152
|
-
+ "
|
|
153
|
-
+ "
|
|
154
|
-
+ "
|
|
155
|
+
+ "a11y.link_text_empty. Image-default completeness is deferred to the stored deployment report that status reads; "
|
|
156
|
+
+ "placement alt strings do not decide whether an image default is missing. Known author-entered metadata also receives missing, "
|
|
157
|
+
+ "short, long and repeated-title description advice (title guidance 15–70 characters; description 50–160). "
|
|
158
|
+
+ "These are writing suggestions, never requirements. Generated custom values are checked; generated "
|
|
159
|
+
+ "defaults, home-page settings and unknown fields are listed in siteChecks.deferredToDeployment. "
|
|
160
|
+
+ "siteChecks.checkedInEditor names rules only the page editor evaluates, such as a selected cover; "
|
|
161
|
+
+ "siteChecks.checkedBySite names rules whose evidence comes from site records; 'status' reads their "
|
|
162
|
+
+ "stored deployment report under siteHealth without compiling or rescanning drafts. A redirect source the site would refuse names its rule in "
|
|
155
163
|
+ "error.problems[].rule. This proves local structure and semantics only. It does not prove "
|
|
156
164
|
+ "authorization, live site ownership, concurrency, persisted round trips, or rendering: 'plan' checks a "
|
|
157
165
|
+ "workspace against the live site, and 'pages push --dry-run' does that for pages alone. "
|
|
@@ -712,6 +720,46 @@ const VERBS = Object.freeze([
|
|
|
712
720
|
note: "Positional arguments name the files or directories to upload;"
|
|
713
721
|
+ " with none, the workspace's media/ directory is walked.",
|
|
714
722
|
},
|
|
723
|
+
{
|
|
724
|
+
name: VERB_MEDIA_IMAGES_LIST,
|
|
725
|
+
surface: SURFACE_DOCS_PRESENTATION,
|
|
726
|
+
capabilities: [CAPABILITY_CONTENT],
|
|
727
|
+
surfaceCapabilities: { [SURFACE_DOCS_PRESENTATION]: [CAPABILITY_DESIGN] },
|
|
728
|
+
tokens: ["media", "images", "list"],
|
|
729
|
+
summary: "Read one page of the site's image defaults, optionally only missing descriptions.",
|
|
730
|
+
imageListOptions: true,
|
|
731
|
+
json: true,
|
|
732
|
+
note: "Returns images.items and images.nextPageToken. Pass that cursor unchanged for the next page. "
|
|
733
|
+
+ "Filtering happens on the server before pagination; no page bodies or per-image reads are scanned. "
|
|
734
|
+
+ "Description revisions are decimal strings. Preview URLs are for inspection, may expire, and are omitted from GITHUB_OUTPUT.",
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
name: VERB_MEDIA_IMAGES_GET,
|
|
738
|
+
surface: SURFACE_DOCS_PRESENTATION,
|
|
739
|
+
capabilities: [CAPABILITY_CONTENT],
|
|
740
|
+
surfaceCapabilities: { [SURFACE_DOCS_PRESENTATION]: [CAPABILITY_DESIGN] },
|
|
741
|
+
tokens: ["media", "images", "get"],
|
|
742
|
+
summary: "Read one site-owned library image's default description and revision.",
|
|
743
|
+
positionals: "imageId",
|
|
744
|
+
json: true,
|
|
745
|
+
note: "The image must belong to the selected site's ordinary library; profile, account and installation media are excluded. "
|
|
746
|
+
+ "Use the returned descriptionRevision unchanged when saving. No workspace file is written.",
|
|
747
|
+
},
|
|
748
|
+
{
|
|
749
|
+
name: VERB_MEDIA_IMAGES_DESCRIBE,
|
|
750
|
+
surface: SURFACE_DOCS_PRESENTATION,
|
|
751
|
+
capabilities: [CAPABILITY_CONTENT],
|
|
752
|
+
surfaceCapabilities: { [SURFACE_DOCS_PRESENTATION]: [CAPABILITY_DESIGN] },
|
|
753
|
+
tokens: ["media", "images", "describe"],
|
|
754
|
+
summary: "Save or clear an image-owned default with its expected revision.",
|
|
755
|
+
positionals: "imageId",
|
|
756
|
+
imageDescriptionOptions: true,
|
|
757
|
+
json: true,
|
|
758
|
+
note: "Supply exactly one of --description and --clear, plus --expected-revision from list/get. "
|
|
759
|
+
+ "Text is outer-trimmed and limited by the server to 1000 Unicode scalar values; blank text clears to null. "
|
|
760
|
+
+ "--if-missing is checked atomically on the server and cannot clear. A conflict never retries or overwrites another edit. "
|
|
761
|
+
+ "This changes the reusable image default, never a page-body override. Existing published output changes only after deployment.",
|
|
762
|
+
},
|
|
715
763
|
{
|
|
716
764
|
name: VERB_APPROVE,
|
|
717
765
|
surface: SURFACE_STANDARD,
|
|
@@ -977,6 +1025,8 @@ function verbHelp(verb) {
|
|
|
977
1025
|
? " <page-path> [--title <text>] [--description <text>] [--author <handle-or-email>]"
|
|
978
1026
|
: verb.positionals === "authorHandle"
|
|
979
1027
|
? " <handle> --name <display-name> [--email <address>]"
|
|
1028
|
+
: verb.positionals === "imageId"
|
|
1029
|
+
? " <image-id>"
|
|
980
1030
|
: verb.positionals
|
|
981
1031
|
? ` [${verb.positionals === "paths" ? "path" : "page-path"}...]`
|
|
982
1032
|
: "";
|
|
@@ -1007,6 +1057,16 @@ function verbHelp(verb) {
|
|
|
1007
1057
|
--description <text> The page's description for search results and link previews; '' clears it.
|
|
1008
1058
|
--author <ref> Credit a page that has no author: a site author's handle or a member's email address.`
|
|
1009
1059
|
: "";
|
|
1060
|
+
const imageOptions = verb.imageListOptions
|
|
1061
|
+
? `\n --missing-description Filter to images whose own default is missing.
|
|
1062
|
+
--page-size <n> Images in this page, 1–100 (default 25).
|
|
1063
|
+
--page-token <cursor> The preceding result's images.nextPageToken, unchanged.`
|
|
1064
|
+
: verb.imageDescriptionOptions
|
|
1065
|
+
? `\n --description <text> Set the shared image default; --description=<text> accepts leading hyphens.
|
|
1066
|
+
--clear Clear the default instead of supplying --description.
|
|
1067
|
+
--expected-revision <n> Required decimal revision from list/get, including 0.
|
|
1068
|
+
--if-missing Save only while the stored default is still null; cannot clear.`
|
|
1069
|
+
: "";
|
|
1010
1070
|
const authorOptions = verb.authorOptions
|
|
1011
1071
|
? `\n --name <text> The name readers see on the byline (required).
|
|
1012
1072
|
--email <address> A private address, kept to link the person to an account later (optional).`
|
|
@@ -1116,7 +1176,7 @@ function verbHelp(verb) {
|
|
|
1116
1176
|
${verb.summary}
|
|
1117
1177
|
${boundary}
|
|
1118
1178
|
|
|
1119
|
-
${options}${targetOption}${handoffOption}${reviewPathOption}${sinceOption}${deliveryTargetOption}${metaOptions}${authorOptions}${jsonOption}${resolveOptions}${dryRunOption}${archiveOption}${linkOptions}${planOption}${nameOption}${note}
|
|
1179
|
+
${options}${targetOption}${handoffOption}${reviewPathOption}${sinceOption}${deliveryTargetOption}${metaOptions}${authorOptions}${imageOptions}${jsonOption}${resolveOptions}${dryRunOption}${archiveOption}${linkOptions}${planOption}${nameOption}${note}
|
|
1120
1180
|
`;
|
|
1121
1181
|
}
|
|
1122
1182
|
|
|
@@ -1166,6 +1226,15 @@ function parseLinkOption(rest, index, argument) {
|
|
|
1166
1226
|
return { name, value, consumed: inline === undefined ? 1 : 0 };
|
|
1167
1227
|
}
|
|
1168
1228
|
|
|
1229
|
+
function parseImageOption(rest, index, argument) {
|
|
1230
|
+
const equals = argument.indexOf("=");
|
|
1231
|
+
const name = equals < 0 ? argument : argument.slice(0, equals);
|
|
1232
|
+
const value = equals < 0 ? rest[index + 1] : argument.slice(equals + 1);
|
|
1233
|
+
if (typeof value !== "string" || (equals < 0 && value.startsWith("--")))
|
|
1234
|
+
throw usageError("cli.option_value", `${name} requires a value.`, { field: name.slice(2) });
|
|
1235
|
+
return { name, value, consumed: equals < 0 ? 1 : 0 };
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1169
1238
|
function usageError(code, message, options = {}) {
|
|
1170
1239
|
return new SiteAuthoringError(code, message, { ...options, exitCode: 2 });
|
|
1171
1240
|
}
|
|
@@ -1546,6 +1615,7 @@ function parseArguments(arguments_) {
|
|
|
1546
1615
|
let planHash;
|
|
1547
1616
|
let archive = false;
|
|
1548
1617
|
const linkValues = {};
|
|
1618
|
+
const imageValues = {};
|
|
1549
1619
|
let metaTitle;
|
|
1550
1620
|
let metaDescription;
|
|
1551
1621
|
let metaAuthor;
|
|
@@ -1735,6 +1805,23 @@ function parseArguments(arguments_) {
|
|
|
1735
1805
|
index += 1;
|
|
1736
1806
|
continue;
|
|
1737
1807
|
}
|
|
1808
|
+
if ((verb.imageListOptions && (argument === "--missing-description"))
|
|
1809
|
+
|| (verb.imageDescriptionOptions && (argument === "--clear" || argument === "--if-missing"))) {
|
|
1810
|
+
const field = { "--missing-description": "missingDescription", "--clear": "clearDescription", "--if-missing": "onlyIfMissing" }[argument];
|
|
1811
|
+
if (imageValues[field]) throw usageError("cli.duplicate_option", `${argument} may be supplied only once.`);
|
|
1812
|
+
imageValues[field] = true;
|
|
1813
|
+
continue;
|
|
1814
|
+
}
|
|
1815
|
+
const imageOption = argument.split("=", 1)[0];
|
|
1816
|
+
if ((verb.imageListOptions && ["--page-size", "--page-token"].includes(imageOption))
|
|
1817
|
+
|| (verb.imageDescriptionOptions && ["--description", "--expected-revision"].includes(imageOption))) {
|
|
1818
|
+
const field = { "--page-size": "pageSize", "--page-token": "pageToken", "--description": "imageDescription", "--expected-revision": "expectedRevision" }[imageOption];
|
|
1819
|
+
if (imageValues[field] !== undefined) throw usageError("cli.duplicate_option", `${imageOption} may be supplied only once.`);
|
|
1820
|
+
const parsed = parseImageOption(rest, index, argument);
|
|
1821
|
+
imageValues[field] = parsed.value;
|
|
1822
|
+
index += parsed.consumed;
|
|
1823
|
+
continue;
|
|
1824
|
+
}
|
|
1738
1825
|
if (argument.startsWith("-")) {
|
|
1739
1826
|
throw usageError("cli.unknown_option", "The command contains an unknown option.");
|
|
1740
1827
|
}
|
|
@@ -1838,6 +1925,12 @@ function parseArguments(arguments_) {
|
|
|
1838
1925
|
{ field: "title" },
|
|
1839
1926
|
);
|
|
1840
1927
|
}
|
|
1928
|
+
if (verb.positionals === "imageId") {
|
|
1929
|
+
if (positionals.length !== 1) throw usageError("media.image_id_invalid", "Name exactly one image UUID.", { field: "imageId" });
|
|
1930
|
+
requireImageId(positionals[0]);
|
|
1931
|
+
}
|
|
1932
|
+
if (verb.imageListOptions) imageListOptions(imageValues);
|
|
1933
|
+
if (verb.imageDescriptionOptions) imageDescriptionOptions(imageValues);
|
|
1841
1934
|
if (verb.positionals === "authorHandle" && positionals.length !== 1) {
|
|
1842
1935
|
throw usageError("authors.handle_missing", "authors add needs exactly one handle, such as jane-doe.", {
|
|
1843
1936
|
field: "handle",
|
|
@@ -1908,6 +2001,7 @@ function parseArguments(arguments_) {
|
|
|
1908
2001
|
...(planHash === undefined ? {} : { planHash }),
|
|
1909
2002
|
...(archive ? { archive } : {}),
|
|
1910
2003
|
...linkValues,
|
|
2004
|
+
...imageValues,
|
|
1911
2005
|
...(metaTitle === undefined ? {} : { metaTitle }),
|
|
1912
2006
|
...(metaDescription === undefined ? {} : { metaDescription }),
|
|
1913
2007
|
...(metaAuthor === undefined ? {} : { metaAuthor }),
|
|
@@ -1919,7 +2013,7 @@ function parseArguments(arguments_) {
|
|
|
1919
2013
|
values: positionals,
|
|
1920
2014
|
scalar: verb.positionals === "pageSelector" || verb.positionals === "fixturePath" || verb.positionals === "metaPagePath"
|
|
1921
2015
|
|| verb.positionals === "deletePagePath" || verb.positionals === "siteSelector" || verb.positionals === "environmentSelector"
|
|
1922
|
-
|| verb.positionals === "authorHandle",
|
|
2016
|
+
|| verb.positionals === "authorHandle" || verb.positionals === "imageId",
|
|
1923
2017
|
}
|
|
1924
2018
|
: undefined,
|
|
1925
2019
|
};
|
|
@@ -2016,6 +2110,8 @@ export async function runCli({
|
|
|
2016
2110
|
...(parsed.metaAuthor === undefined ? {} : { metaAuthor: parsed.metaAuthor }),
|
|
2017
2111
|
...(parsed.authorName === undefined ? {} : { authorName: parsed.authorName }),
|
|
2018
2112
|
...(parsed.authorEmail === undefined ? {} : { authorEmail: parsed.authorEmail }),
|
|
2113
|
+
...Object.fromEntries(["pageSize", "pageToken", "missingDescription", "imageDescription", "clearDescription", "expectedRevision", "onlyIfMissing"]
|
|
2114
|
+
.filter((field) => parsed[field] !== undefined).map((field) => [field, parsed[field]])),
|
|
2019
2115
|
capabilities: parsed.capabilities,
|
|
2020
2116
|
surface: parsed.surface,
|
|
2021
2117
|
surfaceCapabilities: parsed.surfaceCapabilities,
|
package/src/constants.js
CHANGED
|
@@ -101,6 +101,9 @@ export const VERB_THEME_PUSH = "theme push";
|
|
|
101
101
|
export const VERB_THEME_RESOLVE = "theme resolve";
|
|
102
102
|
export const VERB_FOOTER_PUSH = "footer push";
|
|
103
103
|
export const VERB_MEDIA_UPLOAD = "media upload";
|
|
104
|
+
export const VERB_MEDIA_IMAGES_LIST = "media images list";
|
|
105
|
+
export const VERB_MEDIA_IMAGES_GET = "media images get";
|
|
106
|
+
export const VERB_MEDIA_IMAGES_DESCRIBE = "media images describe";
|
|
104
107
|
export const VERB_APPROVE = "approve";
|
|
105
108
|
export const VERB_DEPLOY = "deploy";
|
|
106
109
|
export const VERB_PREVIEW_PAGE = "preview page";
|
|
@@ -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
|
/**
|
|
@@ -320,6 +323,7 @@ const CARD_GRID = Object.freeze({
|
|
|
320
323
|
const IMAGE_BANNER = Object.freeze({
|
|
321
324
|
image: image(),
|
|
322
325
|
altText: str(),
|
|
326
|
+
descriptionMode: Object.freeze({ ...enumOf(...IMAGE_DESCRIPTION_MODES), nullable: true }),
|
|
323
327
|
overlayText: str(),
|
|
324
328
|
// One supporting sentence and at most one link under the headline: the copy
|
|
325
329
|
// stack Espalier 6.1 lays out with a bounded measure (TR00819).
|
|
@@ -429,9 +433,11 @@ const FORM = Object.freeze({
|
|
|
429
433
|
* The image field table an image spec validates against. The shared table
|
|
430
434
|
* leaves alt and dimensions optional; a spec that requires them swaps in
|
|
431
435
|
* stricter entries at the same positions so canonical order is unchanged.
|
|
436
|
+
* Inherited alt is delivery state, removed before a workspace round trip.
|
|
432
437
|
*/
|
|
433
|
-
function imageFieldsFor(spec) {
|
|
434
|
-
|
|
438
|
+
function imageFieldsFor(spec, value) {
|
|
439
|
+
const altRequired = spec.altRequired && value?.descriptionMode !== "inherit";
|
|
440
|
+
if (!altRequired && !spec.dimensionsRequired) return COMPONENT_IMAGE_FIELDS;
|
|
435
441
|
return Object.freeze({
|
|
436
442
|
...COMPONENT_IMAGE_FIELDS,
|
|
437
443
|
...(spec.dimensionsRequired
|
|
@@ -440,7 +446,7 @@ function imageFieldsFor(spec) {
|
|
|
440
446
|
height: num({ integer: true, minimum: 1, required: true }),
|
|
441
447
|
}
|
|
442
448
|
: {}),
|
|
443
|
-
...(
|
|
449
|
+
...(altRequired
|
|
444
450
|
? {
|
|
445
451
|
alt: str({
|
|
446
452
|
required: true,
|
|
@@ -702,7 +708,13 @@ const COMPONENT_DEFINITIONS = Object.freeze({
|
|
|
702
708
|
"Spacer",
|
|
703
709
|
"Intentional vertical separation with an optional decorative divider.",
|
|
704
710
|
{ height: "medium", showDivider: false, dividerStyle: "solid", dividerWidth: 1 },
|
|
705
|
-
[
|
|
711
|
+
[
|
|
712
|
+
"Do not use spacing alone to communicate document structure; keep headings and landmarks semantic.",
|
|
713
|
+
"Blocks already sit one gap apart, set by the site's blockSpacing (help settings). A spacer adds its height to "
|
|
714
|
+
+ "that gap: after text the gap is the spacer's height plus one gap, and between two components it is one gap, "
|
|
715
|
+
+ "the height, and one gap. Review spacers placed to work around tight spacing, and remove ones that are no "
|
|
716
|
+
+ "longer needed.",
|
|
717
|
+
],
|
|
706
718
|
{ height: "large", showDivider: true, dividerStyle: "solid", dividerWidth: 1 },
|
|
707
719
|
),
|
|
708
720
|
"latest-posts": component(
|
|
@@ -902,10 +914,10 @@ const COMPONENT_DEFINITIONS = Object.freeze({
|
|
|
902
914
|
"A wrapping row of pictures at one height, each with alt text and an optional link, never cropped or framed.",
|
|
903
915
|
{ height: "medium", align: "start" },
|
|
904
916
|
[
|
|
905
|
-
"Every image needs alt text
|
|
917
|
+
"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.",
|
|
906
918
|
"In Markdown the alt comes from the media manifest entry the image path names; a blank alt is refused.",
|
|
907
919
|
"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.",
|
|
908
|
-
"
|
|
920
|
+
"A link to another site opens in a new tab with nothing to set. The site's own addresses, relative paths, mailto: and tel: links open in the same tab.",
|
|
909
921
|
],
|
|
910
922
|
{
|
|
911
923
|
items: [
|
|
@@ -944,7 +956,7 @@ const COMPONENT_DEFINITIONS = Object.freeze({
|
|
|
944
956
|
[
|
|
945
957
|
"A button's visible text is its accessible name, and the product's title heads the same list item, so \"Buy now\" on every card reads with its product. Say what the button does, such as \"Buy now\" or \"Book a trial\".",
|
|
946
958
|
PRODUCT_GRID_BUTTON_TEXT_NOTE,
|
|
947
|
-
"buttonText and buttonUrl go together: text without an address and an address without text are both refused.
|
|
959
|
+
"buttonText and buttonUrl go together: text without an address and an address without text are both refused. A button to another site opens in a new tab with nothing to set. The site's own addresses, relative paths, mailto: and tel: links open in the same tab.",
|
|
948
960
|
"price is free text (\"From $30\", \"Free\"); priceNote is shown beside it and refused without a price.",
|
|
949
961
|
"An image is optional and cropped to 16:9. Its alt may be blank when the title already says what the picture shows; in Markdown the alt comes from the media manifest entry.",
|
|
950
962
|
"highlighted draws a border of at least 2px (the borderWidth when that is larger) and the badge in the site's action color to mark one card, such as the most popular plan. The grid displays products only: no cart, payments or inventory.",
|
|
@@ -989,7 +1001,7 @@ const COMPONENT_DEFINITIONS = Object.freeze({
|
|
|
989
1001
|
[
|
|
990
1002
|
"A link's text is its accessible name: the label if set, else the platform's name. With showLabels false the text is visually hidden and read by screen readers only, so the name is the same either way.",
|
|
991
1003
|
"Link only to accounts you control; each platform's brand rules apply to its mark. " + UNSUPPORTED_DOMAIN_NOTE,
|
|
992
|
-
"platform other is any website and needs a label, such as \"Book online\". A named platform's link
|
|
1004
|
+
"platform other is any website and needs a label, such as \"Book online\". A link to another site opens in a new tab with nothing to set: a named platform's link publishes rel=\"me noopener noreferrer\", a Website link publishes rel=\"noopener noreferrer\", and a link to the site's own address stays in the same tab.",
|
|
993
1005
|
],
|
|
994
1006
|
{
|
|
995
1007
|
items: [
|
|
@@ -1065,7 +1077,7 @@ function referenceSchema(spec) {
|
|
|
1065
1077
|
type: "string",
|
|
1066
1078
|
format: "https-url",
|
|
1067
1079
|
maxLength: SOCIAL_LINK_URL_MAX_LENGTH,
|
|
1068
|
-
description: `An ${HTTPS_URL_DESCRIPTION.slice(3)}. ${UNSUPPORTED_DOMAIN_NOTE}
|
|
1080
|
+
description: `An ${HTTPS_URL_DESCRIPTION.slice(3)}. ${UNSUPPORTED_DOMAIN_NOTE} A link to another site opens in a new tab with nothing to set.`,
|
|
1069
1081
|
};
|
|
1070
1082
|
}
|
|
1071
1083
|
return {
|
|
@@ -1073,7 +1085,7 @@ function referenceSchema(spec) {
|
|
|
1073
1085
|
format: spec.allowEmpty === false ? "safe-url" : "safe-url-or-empty",
|
|
1074
1086
|
description: `${
|
|
1075
1087
|
spec.allowEmpty === false ? "An" : "Empty, or an"
|
|
1076
|
-
} explicit HTTP(S), mailto, or tel URL; a root-relative path that does not begin with //; a fragment; a query; or a relative URL. Backslashes and ASCII control characters are rejected. Contact URLs require a non-empty body and refuse tel:// and mailto:// authority forms.`,
|
|
1088
|
+
} explicit HTTP(S), mailto, or tel URL; a root-relative path that does not begin with //; a fragment; a query; or a relative URL. Backslashes and ASCII control characters are rejected. Contact URLs require a non-empty body and refuse tel:// and mailto:// authority forms. A web address on another site opens in a new tab with nothing to set; the site's own addresses, relative paths, fragments, queries, mailto and tel links do not.`,
|
|
1077
1089
|
};
|
|
1078
1090
|
case "css-length":
|
|
1079
1091
|
return { type: "string", description: `Must be ${describe(spec)}.` };
|
|
@@ -1133,7 +1145,7 @@ function referenceSchema(spec) {
|
|
|
1133
1145
|
description: `In Markdown, a media path recorded by media upload (such as "media/photo.webp")${
|
|
1134
1146
|
spec.required ? "" : ", or null"
|
|
1135
1147
|
}; 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.${
|
|
1136
|
-
spec.altRequired ? " Alt text is required." : ""
|
|
1148
|
+
spec.altRequired ? " Alt text is required unless descriptionMode is inherit; inherited text must resolve before deployment." : ""
|
|
1137
1149
|
}${spec.dimensionsRequired ? " Width and height are required." : ""}`,
|
|
1138
1150
|
additionalProperties: false,
|
|
1139
1151
|
properties: referenceProperties(imageFieldsFor(spec)),
|
|
@@ -1325,7 +1337,7 @@ function validateValue(spec, value, path, errors) {
|
|
|
1325
1337
|
fail(spec.required ? "an image object" : "an image object or null");
|
|
1326
1338
|
return;
|
|
1327
1339
|
}
|
|
1328
|
-
validateFields(imageFieldsFor(spec), value, path, errors);
|
|
1340
|
+
validateFields(imageFieldsFor(spec, value), value, path, errors);
|
|
1329
1341
|
return;
|
|
1330
1342
|
}
|
|
1331
1343
|
default:
|
|
@@ -1593,9 +1605,10 @@ export function canonicalizeComponentData(componentType, data) {
|
|
|
1593
1605
|
const fields = isComponentType(componentType) ? COMPONENT_SHAPES[componentType] : undefined;
|
|
1594
1606
|
if (!fields) throw new TypeError("canonicalizeComponentData requires a known component type.");
|
|
1595
1607
|
if (!isPlainObject(data)) throw new TypeError("canonicalizeComponentData requires an object.");
|
|
1608
|
+
const authored = stripImageDescriptionHydration(data);
|
|
1596
1609
|
const ordered = {};
|
|
1597
1610
|
for (const key of Object.keys(fields)) {
|
|
1598
|
-
if (Object.hasOwn(
|
|
1611
|
+
if (Object.hasOwn(authored, key)) ordered[key] = orderValue(fields[key], authored[key]);
|
|
1599
1612
|
}
|
|
1600
1613
|
for (const key of Object.keys(data)) {
|
|
1601
1614
|
if (!Object.hasOwn(fields, key)) throw new TypeError("canonicalizeComponentData requires validated data.");
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Generated from shared/external-link.ts by scripts/sync-field-validation.mjs. Do not edit.
|
|
2
|
+
/**
|
|
3
|
+
* The one owner of which links open in a new tab (TR01327).
|
|
4
|
+
*
|
|
5
|
+
* A link opens in a new tab when its address is an absolute http or https URL
|
|
6
|
+
* whose host is not one of the site's own. Everything else (relative paths,
|
|
7
|
+
* fragments, mailto, tel, anything that does not parse) stays in the tab.
|
|
8
|
+
*
|
|
9
|
+
* It is pure and dependency-free. The generator, the editor and the
|
|
10
|
+
* site-authoring CLI cannot import this file across project roots, so
|
|
11
|
+
* `node scripts/sync-field-validation.mjs` writes their copies; edit only this
|
|
12
|
+
* file and re-run it.
|
|
13
|
+
*/
|
|
14
|
+
/** A host as the rule compares it: lowercase, with one leading `www.` dropped. */
|
|
15
|
+
export function normalizeLinkHost(host) {
|
|
16
|
+
const lowered = (host ?? "").trim().toLowerCase();
|
|
17
|
+
return lowered.startsWith("www.") ? lowered.slice(4) : lowered;
|
|
18
|
+
}
|
|
19
|
+
/** The site's own hosts, normalized. Empty and missing hosts are left out. */
|
|
20
|
+
export function siteLinkHosts(hosts) {
|
|
21
|
+
const set = new Set();
|
|
22
|
+
for (const host of hosts) {
|
|
23
|
+
const normalized = normalizeLinkHost(host);
|
|
24
|
+
if (normalized)
|
|
25
|
+
set.add(normalized);
|
|
26
|
+
}
|
|
27
|
+
return set;
|
|
28
|
+
}
|
|
29
|
+
/** True for an absolute http or https URL to a host outside the set. */
|
|
30
|
+
export function isExternalLink(href, siteHosts) {
|
|
31
|
+
if (!href)
|
|
32
|
+
return false;
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = new URL(href.trim());
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
41
|
+
return false;
|
|
42
|
+
return !siteHosts.has(normalizeLinkHost(parsed.hostname));
|
|
43
|
+
}
|
|
44
|
+
/** A rel with `noopener noreferrer` after its existing tokens, without duplicates. */
|
|
45
|
+
export function relWithNewTabTokens(rel) {
|
|
46
|
+
const tokens = new Set((rel ?? "").split(/\s+/u).filter(Boolean));
|
|
47
|
+
tokens.add("noopener");
|
|
48
|
+
tokens.add("noreferrer");
|
|
49
|
+
return [...tokens].join(" ");
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The `target` and `rel` attributes for a link that opens in a new tab, as
|
|
53
|
+
* markup with a leading space. The values are tokens, so nothing needs escaping.
|
|
54
|
+
*/
|
|
55
|
+
export function newTabAttributeMarkup(rel) {
|
|
56
|
+
return ` target="_blank" rel="${relWithNewTabTokens(rel)}"`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* What a screen reader hears after a link's name when it opens a new tab. These
|
|
60
|
+
* are the words Espalier appends to its own links, so a native anchor and an
|
|
61
|
+
* `esp-button` read alike. `generator/browser-tests/new-tab-cue.spec.ts` holds
|
|
62
|
+
* the two together.
|
|
63
|
+
*/
|
|
64
|
+
export const NEW_TAB_CUE_TEXT = " (opens in a new tab)";
|
|
65
|
+
/**
|
|
66
|
+
* The cue for a native anchor, as its last child. Pagefind indexes raw HTML, so
|
|
67
|
+
* `data-pagefind-ignore` keeps the words out of search content and excerpts.
|
|
68
|
+
* Output without `NEW_TAB_CUE_CSS` would show the words, so only published pages
|
|
69
|
+
* may carry it.
|
|
70
|
+
*/
|
|
71
|
+
export const NEW_TAB_CUE_HTML = `<span class="sr-only" data-pagefind-ignore>${NEW_TAB_CUE_TEXT}</span>`;
|
|
72
|
+
/** Hides the cue from sight only. The page shell and each shadow stylesheet with a cued anchor carry it. */
|
|
73
|
+
export const NEW_TAB_CUE_CSS = ".sr-only{position:absolute;width:0.0625rem;height:0.0625rem;padding:0;margin:-0.0625rem;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}";
|
|
74
|
+
/** Rendered HTML without its cues, for code that reads a link's text back. */
|
|
75
|
+
export function withoutNewTabCue(html) {
|
|
76
|
+
return html.split(NEW_TAB_CUE_HTML).join("");
|
|
77
|
+
}
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version":
|
|
2
|
+
"version": 16,
|
|
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,
|
|
@@ -297,6 +317,12 @@
|
|
|
297
317
|
"minimum": 10,
|
|
298
318
|
"maximum": 100
|
|
299
319
|
},
|
|
320
|
+
"blockSizePercent": {
|
|
321
|
+
"type": "integer",
|
|
322
|
+
"required": false,
|
|
323
|
+
"minimum": 10,
|
|
324
|
+
"maximum": 200
|
|
325
|
+
},
|
|
300
326
|
"opacity": {
|
|
301
327
|
"type": "number",
|
|
302
328
|
"required": false,
|
|
@@ -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,10 +466,22 @@ 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);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// TR01329: width and height sizes are exclusive, so one conflict replaces both range checks.
|
|
473
|
+
const sizeConflict = Object.hasOwn(value, "inlineSizePercent") && Object.hasOwn(value, "blockSizePercent");
|
|
474
|
+
if (sizeConflict) {
|
|
475
|
+
report(
|
|
476
|
+
`${path}/blockSizePercent`,
|
|
477
|
+
CODES.attrInvalid,
|
|
478
|
+
"Section decoration blockSizePercent cannot be combined with inlineSizePercent. Use only one of the two sizes.",
|
|
479
|
+
);
|
|
460
480
|
}
|
|
461
481
|
|
|
462
482
|
for (const [name, definition] of Object.entries(fields)) {
|
|
463
483
|
if (name === "image" || !Object.hasOwn(value, name)) continue;
|
|
484
|
+
if (sizeConflict && (name === "inlineSizePercent" || name === "blockSizePercent")) continue;
|
|
464
485
|
const fieldPath = `${path}/${name}`;
|
|
465
486
|
const entry = value[name];
|
|
466
487
|
if (definition.type === "enum") {
|
|
@@ -500,10 +521,17 @@ export function normalizeFreeFormSectionDecoration(
|
|
|
500
521
|
...(Object.hasOwn(image, "width") ? { width: image.width } : {}),
|
|
501
522
|
...(Object.hasOwn(image, "height") ? { height: image.height } : {}),
|
|
502
523
|
...(Object.hasOwn(image, "alt") ? { alt: image.alt } : {}),
|
|
524
|
+
...pickImageDescriptionFields(image),
|
|
503
525
|
};
|
|
504
526
|
const normalized = { image: Object.freeze(normalizedImage) };
|
|
505
527
|
for (const [name, definition] of Object.entries(fields)) {
|
|
506
|
-
if (name
|
|
528
|
+
if (name === "image") continue;
|
|
529
|
+
// A field with no default stays absent. The width default applies only without a height size.
|
|
530
|
+
const fallback = name === "inlineSizePercent" && Object.hasOwn(value, "blockSizePercent")
|
|
531
|
+
? undefined
|
|
532
|
+
: definition.default;
|
|
533
|
+
const resolved = value[name] ?? fallback;
|
|
534
|
+
if (resolved !== undefined) normalized[name] = resolved;
|
|
507
535
|
}
|
|
508
536
|
return finish(Object.freeze(normalized));
|
|
509
537
|
}
|