@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/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.image_alt_missing and a11y.link_text_empty; for any page seo.title_long and seo.description_missing. "
152
- + "siteChecks.checkedInEditor names the rules only the page editor evaluates (a cover, a description field, or a description's length, which descriptionWarnings reports here); "
153
- + "siteChecks.checkedBySite names the rules that need the site's own records (images, forms), which "
154
- + "'status' reports under siteHealth. A redirect source the site would refuse names its rule in "
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
- * refuses a blank alt and `dimensionsRequired` demands positive width and
73
- * height, for a component whose images are the content (TR01322).
74
+ * requires authored alt unless inherited, and `dimensionsRequired` demands
75
+ * positive width and height for a component whose images are the content (TR01322).
74
76
  */
75
77
  const image = (options = {}) => Object.freeze({ kind: "image", ...options });
76
78
  /**
@@ -115,6 +117,7 @@ const COMPONENT_IMAGE_FIELDS = Object.freeze({
115
117
  width: num({ integer: true, minimum: 0 }),
116
118
  height: num({ integer: true, minimum: 0 }),
117
119
  alt: str(),
120
+ descriptionMode: Object.freeze({ ...enumOf(...IMAGE_DESCRIPTION_MODES), nullable: true }),
118
121
  });
119
122
 
120
123
  /**
@@ -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
- if (!spec.altRequired && !spec.dimensionsRequired) return COMPONENT_IMAGE_FIELDS;
438
+ function imageFieldsFor(spec, value) {
439
+ const altRequired = spec.altRequired && value?.descriptionMode !== "inherit";
440
+ if (!altRequired && !spec.dimensionsRequired) return COMPONENT_IMAGE_FIELDS;
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
- ...(spec.altRequired
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
- ["Do not use spacing alone to communicate document structure; keep headings and landmarks semantic."],
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: 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.",
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
- "Links open in the same tab, as card-grid and feature-grid links do.",
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. Links open in the same tab, as card-grid and feature-grid links do.",
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 carries rel=\"me\"; a Website link carries no rel.",
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(data, key)) ordered[key] = orderValue(fields[key], data[key]);
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": 14,
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 !== "image") normalized[name] = value[name] ?? definition.default;
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
  }