@taprootio/site-authoring 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -157,12 +157,20 @@ banner title or hero title is the page's `<h1>`; `seo.h1_demoted` when a `#` hea
157
157
  publishes as an `<h2>` because the banner's overlay text, a hero title or an earlier
158
158
  `#` heading already is the `<h1>` (`detail` says which); `seo.h1_multiple` for a hero
159
159
  title after the `<h1>`; `seo.heading_renumbered` and `seo.heading_before_title` for
160
- headings published at another level; and `a11y.image_alt_missing` and
161
- `a11y.link_text_empty`. Any page gets `seo.title_long` and `seo.description_missing`.
162
- `siteChecks.checkedInEditor` names the rules only the page editor evaluates (a cover, a description field, or a
163
- description's length, which `descriptionWarnings` reports here). `siteChecks.checkedBySite` names the rules that need the site's own
164
- records, such as placed images and forms: `status` reports those under
165
- `siteHealth`; `siteChecks.checkedAtDeploy` names the ones only a deploy applies. A redirect source the site would refuse also names its rule in
160
+ headings published at another level; and `a11y.link_text_empty`. Image-default
161
+ completeness (`a11y.image_default_missing`) comes from the stored deployment
162
+ report, using frozen image defaults. Placement alt strings and decorative choices
163
+ do not fill a missing reusable default. Known author-entered metadata receives missing, short and
164
+ long title/description advice, plus `seo.description_matches_title` when the description
165
+ repeats its title. The writing guidance is 15–70 characters for titles and 50–160 for
166
+ descriptions, counted as Unicode characters after outer trimming. These suggestions
167
+ never require padding or truncation and never refuse a save, push or deploy.
168
+ Generated custom values are checked locally. Generated defaults, home-page settings
169
+ and unknown fields defer to deployment in `siteChecks.deferredToDeployment`.
170
+ `siteChecks.checkedInEditor` names rules only the page editor evaluates, such as a selected cover. `siteChecks.checkedBySite` names the rules that need the site's own
171
+ records, such as referenced images and forms: `status` reads the stored deployment
172
+ report under `siteHealth` without scanning current drafts or compiling a new report;
173
+ `siteChecks.checkedAtDeploy` names the ones only a deploy applies. A redirect source the site would refuse also names its rule in
166
174
  `error.problems[].rule`.
167
175
 
168
176
  `taproot-site validate <fixture-directory>` checks a complete offline fixture
@@ -585,6 +593,49 @@ not atomic together: a failure reports in `error.completedWrites` which steps
585
593
  completed, which failed, and which did not run, and the next `plan` shows
586
594
  finished steps as nothing to do. Redirects, forms, approval, and deployment
587
595
  stay separate commands.
596
+ Image defaults belong to the reusable image. Use the selected site's ordinary
597
+ library, without rewriting page bodies:
598
+
599
+ ```bash
600
+ taproot-site media images list --missing-description --page-size 25 --json
601
+ taproot-site media images get <image-id> --json
602
+ taproot-site media images describe <image-id> --description 'A red bird on a branch.' --expected-revision 0 --if-missing --json
603
+ taproot-site media images describe <image-id> --clear --expected-revision 1 --json
604
+ ```
605
+
606
+ List returns one page in `images.items`, `images.totalImages` for the filtered
607
+ library, and `images.nextPageToken`; pass a nonempty cursor unchanged with
608
+ `--page-token` to continue. `--page-size` accepts 1–100. Filtering happens on the
609
+ server before pagination, and reads make no per-image requests. If a page's
610
+ text and previews exceed the output bound, repeat the same cursor with a smaller
611
+ page size. No rows are silently omitted. Zero matches is a successful empty list.
612
+
613
+ Each image exposes `imageId`, `uploadedName`, nullable `defaultDescription`,
614
+ `descriptionRevision`, dimensions, processing state, `previewUrl` and
615
+ `responsivePreviewUrls`. Revisions are decimal strings so JavaScript cannot
616
+ round an int64 value; pass them unchanged as `--expected-revision`. Preview URLs
617
+ are validated HTTPS addresses for inspection, may expire, and stay out of
618
+ `GITHUB_OUTPUT`. No image metadata is written to a workspace file by these verbs.
619
+
620
+ Describe requires exactly one of `--description` and `--clear`. The server trims
621
+ outer whitespace, turns empty/whitespace input into null, and rejects text longer
622
+ than 1000 Unicode scalar values without truncation. Unicode and newlines are
623
+ preserved in JSON. `--if-missing` is checked atomically at the write and cannot
624
+ clear a default. A stale revision reports `media.image_description_conflict`; an
625
+ existing default under the condition reports `media.image_description_exists`.
626
+ Both exit 1 and leave the stored text alone. Read the current image, reconcile,
627
+ and use its revision for a new intentional save; mutations never retry
628
+ automatically. Invalid input exits 2. Permission and credential refusals keep the
629
+ ordinary API error codes. Every verb requires `site.media.manage`, and images
630
+ from another site, profile/account scope, or an installation remain inaccessible.
631
+
632
+ Defaults apply to inherited usages after the next selected deployment. Current
633
+ public output and in-flight releases retain their frozen text; custom overrides,
634
+ decorative choices and unrelated album captions remain independent. Missing
635
+ means the image's default is absent, even when a placement has contextual text.
636
+ These commands populate no production content automatically. `help media` has
637
+ the full read/write contract.
638
+
588
639
  `media upload [path...]` uploads raster files and records each one in
589
640
  `.taproot-site-media.json`; Markdown then names an image by its path (such as
590
641
  `media/photo.webp`) everywhere an image goes, from body images to component and
@@ -704,12 +755,17 @@ It stages; nothing reaches an audience until `deploy --staging` publishes the
704
755
  candidate and `deploy --production` promotes that completed staging
705
756
  deployment. `status` reports the platform authoring switch, the CLI release,
706
757
  deployments, readiness, image processing, and site health under Content
707
- permission. `siteHealth` lists the site checks' findings over the latest editable
708
- body of each page: a rule id, a severity (`error` or `warning`), the page, and
709
- the value found. It reports `covered: true`, `errors`, `warnings`, `pages`, and
710
- `findings`; an empty list means no check found anything. Output is bounded to 100
711
- findings, with an explicit `findingsTruncated` flag. A denied, malformed, or
712
- failed read fails `status` instead of implying a clean site.
758
+ permission. `siteHealth` reads the last successful production deployment's stored
759
+ report. It includes the deployment and output identities, input hash, checked time,
760
+ rule version, coverage, assessed page count and status. Unpublished edits are
761
+ outside that report. Page findings identify the rule, severity, page and value;
762
+ image findings group each missing default description with its affected pages.
763
+ Known reports include `covered: true`, `errors`, `warnings`, `pages` and `findings`.
764
+ Only a complete report with zero findings establishes that the assessed candidate
765
+ has no warnings. Missing, unsupported and incomplete assessments are explicit.
766
+ Output is bounded to 100 page findings, with an explicit `findingsTruncated` flag.
767
+ Preview URLs appear only in normal output and are omitted from GitHub output.
768
+ A denied, malformed or failed read fails `status` instead of implying a clean site.
713
769
 
714
770
  ## Output contract
715
771
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Taproot site authoring CLI: sign in once in the browser, then pull, validate, push, preview, approve, and deploy a site from the terminal",
5
5
  "type": "module",
6
6
  "license": "ISC",
@@ -28,7 +28,7 @@
28
28
  "README.md"
29
29
  ],
30
30
  "dependencies": {
31
- "@taprootio/espalier": "6.7.0",
31
+ "@taprootio/espalier": "6.8.0",
32
32
  "mediabunny": "1.61.1"
33
33
  },
34
34
  "scripts": {
package/src/api.js CHANGED
@@ -1,4 +1,6 @@
1
+ import { normalizeNavigationReadiness } from "./navigation-readiness.js";
1
2
  import { normalizeLatestCliVersion } from "./cli-release.js";
3
+ import { imageDescriptionRevision, isImageId, safeImagePreviewUrl } from "./image-description-contract.js";
2
4
  import {
3
5
  BANDWIDTH_ALLOWANCE_REFUSAL_REASON,
4
6
  CANONICAL_TIMESTAMP,
@@ -1153,6 +1155,65 @@ export async function listSiteImages(client, siteId, requestOptions = {}, { imag
1153
1155
  return { images, summary, truncated: true };
1154
1156
  }
1155
1157
 
1158
+ function normalizeDescriptionImage(value, siteId, expectedImageId) {
1159
+ const code = "api.image_description_contract";
1160
+ const record = requireObject(value, code, "image library entry");
1161
+ const wire = requireObject(record.image, code, "image");
1162
+ const image = normalizeImage(wire);
1163
+ if (!isImageId(image.imageId) || wire.siteId !== siteId
1164
+ || wire.ownershipScope !== IMAGE_OWNERSHIP_SCOPE_SITE
1165
+ || (expectedImageId !== undefined && image.imageId !== expectedImageId)) {
1166
+ throw new SiteAuthoringError(code, "Taproot returned an image outside the requested site library.");
1167
+ }
1168
+ const descriptionRevision = imageDescriptionRevision(wire.descriptionRevision ?? "0");
1169
+ const defaultDescription = wire.defaultDescription === undefined || wire.defaultDescription === null || wire.defaultDescription === ""
1170
+ ? null : wire.defaultDescription;
1171
+ if (descriptionRevision === undefined || (defaultDescription !== null
1172
+ && (typeof defaultDescription !== "string" || !defaultDescription.isWellFormed()))) {
1173
+ throw new SiteAuthoringError(code, "Taproot returned invalid image description metadata.");
1174
+ }
1175
+ return {
1176
+ imageId: image.imageId,
1177
+ uploadedName: image.uploadedName,
1178
+ defaultDescription,
1179
+ descriptionRevision,
1180
+ width: image.width,
1181
+ height: image.height,
1182
+ processingState: image.processingState,
1183
+ previewUrl: safeImagePreviewUrl(image.url),
1184
+ responsivePreviewUrls: image.responsiveUrls.slice(0, 8)
1185
+ .map((url) => ({ minWidth: url.minWidth, url: safeImagePreviewUrl(url.url) }))
1186
+ .filter((url) => url.url !== ""),
1187
+ };
1188
+ }
1189
+
1190
+ /** One bounded page, filtered by the server before its cursor is applied. */
1191
+ export async function listSiteImageDescriptions(client, siteId, { pageSize, pageToken, missingDescription }) {
1192
+ const response = requireObject(await client.request(sitePath(siteId, `images${query([
1193
+ ["pageSize", pageSize], ["pageToken", pageToken], ["missingDescription", missingDescription || undefined],
1194
+ ])}`)), "api.image_description_contract", "site images");
1195
+ if (response.images !== undefined && !Array.isArray(response.images))
1196
+ throw new SiteAuthoringError("api.image_description_contract", "Taproot returned an invalid image list.");
1197
+ if (response.nextPageToken !== undefined && typeof response.nextPageToken !== "string")
1198
+ throw new SiteAuthoringError("api.image_description_contract", "Taproot returned an invalid image cursor.");
1199
+ return {
1200
+ items: (response.images ?? []).map((entry) => normalizeDescriptionImage(entry, siteId)),
1201
+ nextPageToken: response.nextPageToken ?? "",
1202
+ totalImages: safeCount(response.totalImages),
1203
+ };
1204
+ }
1205
+
1206
+ export async function getSiteImageDescription(client, siteId, imageId) {
1207
+ return normalizeDescriptionImage(
1208
+ await client.request(sitePath(siteId, `images/${encodeURIComponent(imageId)}`)), siteId, imageId);
1209
+ }
1210
+
1211
+ export async function updateSiteImageDescription(client, siteId, imageId, body) {
1212
+ return normalizeDescriptionImage(await client.request(sitePath(siteId, `images/${encodeURIComponent(imageId)}/description`), {
1213
+ method: "PATCH", body,
1214
+ }), siteId, imageId);
1215
+ }
1216
+
1156
1217
  /** The site checks' findings over the latest editable page bodies, authorized by Content. */
1157
1218
  export async function getSiteHealth(client, siteId) {
1158
1219
  const code = "api.site_health_contract";
@@ -1191,10 +1252,55 @@ export async function getSiteHealth(client, siteId) {
1191
1252
  detail: text(finding.detail),
1192
1253
  };
1193
1254
  });
1255
+ const statuses = new Set(["not_checked", "complete", "incomplete", "unsupported"]);
1256
+ const status = statuses.has(response.status) ? response.status : "unknown";
1257
+ const optionalId = (value, field) => value === undefined || value === "" ? undefined : requireCanonicalUuid(value, code, field);
1258
+ const deploymentId = optionalId(response.deploymentId, "deploymentId");
1259
+ const outputReleaseId = optionalId(response.outputReleaseId, "outputReleaseId");
1260
+ const claimsAssessment = status === "complete" || status === "incomplete" && Boolean(deploymentId || outputReleaseId);
1261
+ if (claimsAssessment && (!deploymentId || !outputReleaseId || !CANONICAL_TIMESTAMP.test(response.checkedAt ?? "")
1262
+ || typeof response.inputHash !== "string" || !/^[0-9a-f]{64}$/u.test(response.inputHash)
1263
+ || typeof response.ruleVersion !== "string" || response.ruleVersion === ""
1264
+ || !Array.isArray(response.coverage) || response.coverage.length === 0)) {
1265
+ throw new SiteAuthoringError(code, "Taproot returned an assessed report without its immutable deployment identity.");
1266
+ }
1267
+ if (response.coverage !== undefined && (!Array.isArray(response.coverage) || response.coverage.some(area => typeof area !== "string"))) {
1268
+ throw new SiteAuthoringError(code, "Taproot returned invalid health coverage.", { field: "coverage" });
1269
+ }
1270
+ if (response.images !== undefined && !Array.isArray(response.images)) {
1271
+ throw new SiteAuthoringError(code, "Taproot returned invalid image findings.", { field: "images" });
1272
+ }
1273
+ const images = (response.images ?? []).map(value => {
1274
+ const image = requireObject(value, code, "image finding");
1275
+ if (!Array.isArray(image.affectedPages) || image.affectedPages.length === 0) {
1276
+ throw new SiteAuthoringError(code, "Taproot returned an image finding without affected pages.");
1277
+ }
1278
+ return {
1279
+ imageId: requireCanonicalUuid(image.imageId, code, "imageId"), name: text(image.name),
1280
+ previewUrl: safeImagePreviewUrl(image.previewUrl),
1281
+ affectedPages: image.affectedPages.map(page => ({
1282
+ pageId: requireCanonicalUuid(page?.pageId, code, "affectedPages.pageId"),
1283
+ title: text(page?.title), path: text(page?.path),
1284
+ })),
1285
+ };
1286
+ });
1287
+ const errors = count(response.errorCount, "errorCount");
1288
+ const warnings = count(response.warningCount, "warningCount");
1289
+ const keptErrors = findings.filter(finding => finding.severity === "error").length;
1290
+ const keptWarnings = findings.length - keptErrors + images.length;
1291
+ if (claimsAssessment && (errors < keptErrors || warnings < keptWarnings ||
1292
+ response.truncated !== true && (errors !== keptErrors || warnings !== keptWarnings))) {
1293
+ throw new SiteAuthoringError(code, "Taproot returned health totals that disagree with the stored findings.");
1294
+ }
1194
1295
  return {
1195
- findings,
1196
- errorCount: count(response.errorCount, "errorCount"),
1197
- warningCount: count(response.warningCount, "warningCount"),
1296
+ findings, images, status, assessmentKnown: status !== "unknown",
1297
+ ...(deploymentId ? { deploymentId } : {}), ...(outputReleaseId ? { outputReleaseId } : {}),
1298
+ inputHash: text(response.inputHash), checkedAt: text(response.checkedAt), ruleVersion: text(response.ruleVersion),
1299
+ coverage: response.coverage ?? [], assessedPageCount: count(response.assessedPageCount, "assessedPageCount"),
1300
+ environment: response.environment === "staging" ? "staging" : "production",
1301
+ stagedDeploymentId: optionalId(response.stagedDeploymentId, "stagedDeploymentId"),
1302
+ errorCount: errors,
1303
+ warningCount: warnings,
1198
1304
  pageCount: count(response.pageCount, "pageCount"),
1199
1305
  truncated: response.truncated === true,
1200
1306
  };
@@ -1257,6 +1363,7 @@ export async function getPublishingReadiness(client, siteId, selection = {}) {
1257
1363
  }));
1258
1364
  return {
1259
1365
  state: typeof response.state === "string" ? response.state : "PAGE_PUBLISHING_READINESS_STATE_UNKNOWN",
1366
+ navigationReadiness: normalizeNavigationReadiness(response.navigationReadiness),
1260
1367
  approvedPageCount: safeCount(response.approvedPageCount),
1261
1368
  selectedPageCount: safeCount(response.selectedPageCount),
1262
1369
  blockedPageCount: safeCount(response.blockedPageCount),
@@ -1,5 +1,24 @@
1
1
  {
2
2
  "entries": [
3
+ {
4
+ "version": "0.31.0",
5
+ "note": "Image defaults can be listed, read and changed safely through media images; metadata advice uses consistent title and description guidance.",
6
+ "behavior": [
7
+ "New verbs: media images list [--missing-description] [--page-size <1-100>] [--page-token <cursor>], media images get <image-id>, and media images describe <image-id> --description <text> (or --clear) --expected-revision <n> [--if-missing]. List returns one bounded page and its nextPageToken; the server filters missing defaults before pagination, without scanning page placements or fetching every image separately.",
8
+ "Get/list/describe report stable imageId, uploadedName, nullable defaultDescription, descriptionRevision as an exact decimal string, dimensions, processing state and validated preview URLs. Preview URLs are for inspection and may expire; they are omitted from GitHub Actions output and never written into a description or workspace manifest.",
9
+ "Description writes require the selected site's media permission and the expected revision from list/get. --if-missing is checked atomically by the server and cannot be combined with --clear or blank text. A stale revision or existing default is a refusal, with no automatic overwrite or mutation retry. The server trims outer whitespace, clears blank descriptions to null, and rejects more than 1000 Unicode scalar values without truncation. Existing published output keeps its frozen text until deployment; custom/decorative placement choices stay independent.",
10
+ "Local metadata advice checks known author-entered values consistently: a nonempty title outside 15–70 Unicode scalar values, a description outside 50–160, missing text, or a description matching the title after whitespace and case normalization. Warnings do not block saves. Fallback-dependent metadata remains deferred to the deployment evaluator.",
11
+ "The coordinated release replaces local a11y.image_alt_missing placement advice with a11y.image_default_missing in the stored deployment report. Completeness uses only the frozen image-owned default: contextual alt text, album captions and decorative choices do not waive a missing reusable description. status reads the report and never scans current drafts or triggers compilation.",
12
+ "Image Row and Product Grid image records now follow the shared description contract: descriptionMode selects inherit, custom or decorative, while defaultDescription and descriptionRevision are read-only hydrated metadata and are omitted from authored workspace sources. An Image Row image with descriptionMode inherit may omit alt locally, but its description must resolve to non-blank alt text before deployment; custom and legacy Image Row alt text remains required. See help component image-row and help component product-grid.",
13
+ "The help reference version is 50. Taproot accepts only this latest CLI after the API deploy; upgrade with npm install -g @taprootio/site-authoring@latest."
14
+ ],
15
+ "help": [
16
+ "media",
17
+ "pages",
18
+ "page",
19
+ "component"
20
+ ]
21
+ },
3
22
  {
4
23
  "version": "0.30.0",
5
24
  "note": "A card-grid can show each card's image whole at a set height, aligned and unframed, with chosen space on any side of the image, so a logo no longer needs a wide padded file.",
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,
@@ -902,7 +908,7 @@ const COMPONENT_DEFINITIONS = Object.freeze({
902
908
  "A wrapping row of pictures at one height, each with alt text and an optional link, never cropped or framed.",
903
909
  { height: "medium", align: "start" },
904
910
  [
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.",
911
+ "Every image needs alt text unless its descriptionMode is inherit, in which case the inherited description must resolve to non-blank text before deployment. A row of pictures carries no other text. For a linked image the alt is the link's name, so name the destination or action, such as \"Download on the App Store\"; for an unlinked image, describe the picture.",
906
912
  "In Markdown the alt comes from the media manifest entry the image path names; a blank alt is refused.",
907
913
  "Each image keeps its own shape at the chosen height and is never cropped or framed; an image wider than the row shrinks to fit. Width and height are required so the row can size the image; media upload records them.",
908
914
  "Links open in the same tab, as card-grid and feature-grid links do.",
@@ -1133,7 +1139,7 @@ function referenceSchema(spec) {
1133
1139
  description: `In Markdown, a media path recorded by media upload (such as "media/photo.webp")${
1134
1140
  spec.required ? "" : ", or null"
1135
1141
  }; pages push stores the image record. A .pm.json document carries that record: keep imageId, src, and urls keys so the server can rewrite delivery URLs.${
1136
- spec.altRequired ? " Alt text is required." : ""
1142
+ spec.altRequired ? " Alt text is required unless descriptionMode is inherit; inherited text must resolve before deployment." : ""
1137
1143
  }${spec.dimensionsRequired ? " Width and height are required." : ""}`,
1138
1144
  additionalProperties: false,
1139
1145
  properties: referenceProperties(imageFieldsFor(spec)),
@@ -1325,7 +1331,7 @@ function validateValue(spec, value, path, errors) {
1325
1331
  fail(spec.required ? "an image object" : "an image object or null");
1326
1332
  return;
1327
1333
  }
1328
- validateFields(imageFieldsFor(spec), value, path, errors);
1334
+ validateFields(imageFieldsFor(spec, value), value, path, errors);
1329
1335
  return;
1330
1336
  }
1331
1337
  default:
@@ -1593,9 +1599,10 @@ export function canonicalizeComponentData(componentType, data) {
1593
1599
  const fields = isComponentType(componentType) ? COMPONENT_SHAPES[componentType] : undefined;
1594
1600
  if (!fields) throw new TypeError("canonicalizeComponentData requires a known component type.");
1595
1601
  if (!isPlainObject(data)) throw new TypeError("canonicalizeComponentData requires an object.");
1602
+ const authored = stripImageDescriptionHydration(data);
1596
1603
  const ordered = {};
1597
1604
  for (const key of Object.keys(fields)) {
1598
- if (Object.hasOwn(data, key)) ordered[key] = orderValue(fields[key], data[key]);
1605
+ if (Object.hasOwn(authored, key)) ordered[key] = orderValue(fields[key], authored[key]);
1599
1606
  }
1600
1607
  for (const key of Object.keys(data)) {
1601
1608
  if (!Object.hasOwn(fields, key)) throw new TypeError("canonicalizeComponentData requires validated data.");