@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.
@@ -26,11 +26,16 @@ export const RULE_H1_DEMOTED = "seo.h1_demoted";
26
26
  export const RULE_H1_MULTIPLE = "seo.h1_multiple";
27
27
  export const RULE_HEADING_RENUMBERED = "seo.heading_renumbered";
28
28
  export const RULE_HEADING_BEFORE_TITLE = "seo.heading_before_title";
29
- export const RULE_IMAGE_ALT_MISSING = "a11y.image_alt_missing";
29
+ // Image completeness is assessed from frozen library defaults during deployment.
30
+ export const RULE_IMAGE_DEFAULT_MISSING = "a11y.image_default_missing";
30
31
  export const RULE_LINK_TEXT_EMPTY = "a11y.link_text_empty";
32
+ export const RULE_TITLE_MISSING = "seo.title_missing";
33
+ export const RULE_TITLE_SHORT = "seo.title_short";
31
34
  export const RULE_TITLE_LONG = "seo.title_long";
32
35
  export const RULE_DESCRIPTION_MISSING = "seo.description_missing";
36
+ export const RULE_DESCRIPTION_SHORT = "seo.description_short";
33
37
  export const RULE_DESCRIPTION_LONG = "seo.description_long";
38
+ export const RULE_DESCRIPTION_MATCHES_TITLE = "seo.description_matches_title";
34
39
  export const RULE_SOCIAL_IMAGE_FALLBACK = "seo.social_image_fallback";
35
40
  const RULE_ORDER = [
36
41
  RULE_H1_MISSING,
@@ -38,17 +43,42 @@ const RULE_ORDER = [
38
43
  RULE_H1_MULTIPLE,
39
44
  RULE_HEADING_RENUMBERED,
40
45
  RULE_HEADING_BEFORE_TITLE,
41
- RULE_IMAGE_ALT_MISSING,
42
46
  RULE_LINK_TEXT_EMPTY,
47
+ RULE_TITLE_MISSING,
48
+ RULE_TITLE_SHORT,
43
49
  RULE_TITLE_LONG,
44
50
  RULE_DESCRIPTION_MISSING,
51
+ RULE_DESCRIPTION_SHORT,
45
52
  RULE_DESCRIPTION_LONG,
53
+ RULE_DESCRIPTION_MATCHES_TITLE,
46
54
  RULE_SOCIAL_IMAGE_FALLBACK,
47
55
  ];
48
- /** Search results cut a title off after about this many characters. */
49
- export const TITLE_WARN_LENGTH = 60;
50
- /** Search results cut a description off after about this many characters. */
56
+ /** Product guidance for useful metadata, not search-engine limits. */
57
+ export const TITLE_MINIMUM_LENGTH = 15;
58
+ export const TITLE_WARN_LENGTH = 70;
59
+ export const DESCRIPTION_MINIMUM_LENGTH = 50;
51
60
  export const DESCRIPTION_WARN_LENGTH = 160;
61
+ export const METADATA_RULE_IDS = [
62
+ RULE_TITLE_MISSING,
63
+ RULE_TITLE_SHORT,
64
+ RULE_TITLE_LONG,
65
+ RULE_DESCRIPTION_MISSING,
66
+ RULE_DESCRIPTION_SHORT,
67
+ RULE_DESCRIPTION_LONG,
68
+ RULE_DESCRIPTION_MATCHES_TITLE,
69
+ ];
70
+ /** Local authoring cannot resolve a home page's site settings or generated fallbacks. */
71
+ export function localAuthoringMetadata(input) {
72
+ if (input.homePage)
73
+ return {};
74
+ if (input.generated) {
75
+ return {
76
+ title: input.generated.customTitle?.trim() ? input.generated.customTitle : undefined,
77
+ description: input.generated.customDescription?.trim() ? input.generated.customDescription : undefined,
78
+ };
79
+ }
80
+ return { title: input.title, description: input.description };
81
+ }
52
82
  const MAXIMUM_DEPTH = 256;
53
83
  const MAXIMUM_SUBJECT_LENGTH = 80;
54
84
  const characters = (value) => [...value].length;
@@ -162,16 +192,6 @@ function walkBody(body) {
162
192
  case "taprootImage": {
163
193
  imageCount++;
164
194
  seeContent();
165
- if (!nonBlank(node.attrs?.alt)) {
166
- const id = node.attrs?.imageId;
167
- findings.push({
168
- ruleId: RULE_IMAGE_ALT_MISSING,
169
- severity: "warning",
170
- subject: typeof id === "string" ? id : "",
171
- detail: "",
172
- path,
173
- });
174
- }
175
195
  return;
176
196
  }
177
197
  case "componentBlock": {
@@ -182,19 +202,10 @@ function walkBody(body) {
182
202
  const fixedLevel = componentFixedHeadingLevel(type, data);
183
203
  if (type === "image-banner" && imageSource(data.image)) {
184
204
  imageCount++;
185
- if (!nonBlank(data.altText)) {
186
- findings.push({ ruleId: RULE_IMAGE_ALT_MISSING, severity: "warning", subject: "", detail: "", path });
187
- }
188
205
  }
189
206
  // The social preview also draws on a hero's picture.
190
207
  if (type === "hero-section" && imageSource(data.media)) {
191
208
  imageCount++;
192
- if (!nonBlank(data.media.alt)) {
193
- findings.push({ ruleId: RULE_IMAGE_ALT_MISSING, severity: "warning", subject: "", detail: "", path });
194
- }
195
- }
196
- if (type === "cta" && imageSource(data.mediaImage) && !nonBlank(data.mediaImage.alt)) {
197
- findings.push({ ruleId: RULE_IMAGE_ALT_MISSING, severity: "warning", subject: "", detail: "", path });
198
209
  }
199
210
  if (opensPage) {
200
211
  recordFixedHeading(outline, 1);
@@ -322,27 +333,21 @@ function collectFindings(page) {
322
333
  findings.push(...walked.findings);
323
334
  imageCount = walked.imageCount;
324
335
  }
325
- if (typeof page.title === "string" && characters(page.title.trim()) > TITLE_WARN_LENGTH) {
326
- findings.push({
327
- ruleId: RULE_TITLE_LONG,
328
- severity: "warning",
329
- subject: "",
330
- detail: String(characters(page.title.trim())),
331
- });
332
- }
333
- if (typeof page.description === "string") {
334
- const description = page.description.trim();
335
- if (description === "") {
336
- findings.push({ ruleId: RULE_DESCRIPTION_MISSING, severity: "warning", subject: "", detail: "" });
337
- }
338
- else if (characters(description) > DESCRIPTION_WARN_LENGTH) {
339
- findings.push({
340
- ruleId: RULE_DESCRIPTION_LONG,
341
- severity: "warning",
342
- subject: "",
343
- detail: String(characters(description)),
344
- });
345
- }
336
+ const checkText = (value, minimum, maximum, missingRule, shortRule, longRule) => {
337
+ if (typeof value !== "string")
338
+ return;
339
+ const count = characters(value.trim());
340
+ const ruleId = count === 0 ? missingRule : count < minimum ? shortRule : count > maximum ? longRule : undefined;
341
+ if (ruleId)
342
+ findings.push({ ruleId, severity: "warning", subject: "", detail: count === 0 ? "" : String(count) });
343
+ };
344
+ checkText(page.title, TITLE_MINIMUM_LENGTH, TITLE_WARN_LENGTH, RULE_TITLE_MISSING, RULE_TITLE_SHORT, RULE_TITLE_LONG);
345
+ checkText(page.description, DESCRIPTION_MINIMUM_LENGTH, DESCRIPTION_WARN_LENGTH, RULE_DESCRIPTION_MISSING, RULE_DESCRIPTION_SHORT, RULE_DESCRIPTION_LONG);
346
+ const normalize = (value) => typeof value === "string" ? value.trim().replace(/\s+/gu, " ").toLowerCase() : undefined;
347
+ const title = normalize(page.title);
348
+ const description = normalize(page.description);
349
+ if (title && description && title === description) {
350
+ findings.push({ ruleId: RULE_DESCRIPTION_MATCHES_TITLE, severity: "warning", subject: "", detail: "" });
346
351
  }
347
352
  if (page.freeForm && page.hasCover === false) {
348
353
  findings.push({
@@ -95,7 +95,7 @@ import {
95
95
 
96
96
  export { getAppearanceReference, getFooterReference, getSettingsReference, getThemeReference };
97
97
 
98
- export const REFERENCE_VERSION = 49;
98
+ export const REFERENCE_VERSION = 50;
99
99
 
100
100
  // The date-limit examples `help forms` prints; tests run them through the form validator.
101
101
  export const FORM_DATE_WINDOW_FIELD = Object.freeze({
@@ -1018,10 +1018,14 @@ const WORKFLOW_REFERENCES = Object.freeze({
1018
1018
  }),
1019
1019
  }),
1020
1020
  media: Object.freeze({
1021
- title: "Media upload contract",
1022
- summary: "Upload workspace-root-relative raster files and videos; images are recorded in .taproot-site-media.json and videos report their ids.",
1023
- usage: `${CLI_BINARY_NAME} media upload [path...]`,
1021
+ title: "Image defaults and media upload",
1022
+ summary: "Read and change site-owned image defaults by ID, or upload workspace-root-relative raster files and videos.",
1023
+ usage: `${CLI_BINARY_NAME} media images list|get|describe | media upload [path...]`,
1024
1024
  details: Object.freeze([
1025
+ "media images list returns one page in images.items and images.nextPageToken; pass --page-size 1–100 (default 25) and --page-token with that exact cursor for the next page. --missing-description is a server-side filter for a null image-owned default, independent of placement alt text, captions or decorative mode. No per-result reads or page-body scans run. The authoritative default-completeness findings are in the frozen deployment report that status reads; local validate does not infer them from placement strings.",
1026
+ "media images get <image-id> reads one library image in the selected site. Both reads expose imageId, uploadedName, defaultDescription (null when missing), descriptionRevision as a decimal string, width, height, processingState, previewUrl and responsivePreviewUrls. Preview URLs are for inspection, may expire, and are omitted from GITHUB_OUTPUT. They are not descriptions and these verbs write no workspace file.",
1027
+ "media images describe <image-id> --description <text> --expected-revision <n> saves the default; --clear replaces --description to remove it. Supply the exact revision from list/get, including 0. --if-missing is an atomic server condition, requires nonblank text, and refuses an existing default. Conflicts report media.image_description_conflict or media.image_description_exists with exit 1; read the current image and reconcile, never retry by overwriting it blindly. Input/option errors use exit 2.",
1028
+ "The server owns the 1000-Unicode-scalar limit: outer whitespace is trimmed, blank text becomes null, and oversized text is rejected without truncation. Descriptions may contain Unicode and newlines. The CLI preserves their text in JSON and never copies preview URLs or resolved defaults into page bodies. Site media management is required for all three verbs; profile/account/installation media are excluded. A default edit affects inherited usages at the next selected deployment; current published and in-flight snapshots keep their frozen text, while contextual overrides and decorative placements keep their choices.",
1025
1029
  "Paths are relative to the configured workspace root, not the shell's current directory.",
1026
1030
  "PNG, JPEG, GIF, and WebP are accepted. Each file uploads as its own image: a name such as logo@2x.png is only a filename, nothing pairs it with logo.png, and the platform generates the display sizes from the one file you assign (see help appearance for logos).",
1027
1031
  "For header logos prefer genuine transparent PNG/WebP, check contrast in both themes, and include a simplified square favicon. See help appearance for branding guidance.",
@@ -2,7 +2,7 @@ import { sanitizeDiagnostic } from "./errors.js";
2
2
  import { boundedByBytes } from "./session.js";
3
3
  import rules from "./site-check-rules.json" with { type: "json" };
4
4
  import { isFormKey } from "./forms-contract.js";
5
- import { DESCRIPTION_WARN_LENGTH, pageStructureFindings, TITLE_WARN_LENGTH } from "./page-structure-checks.js";
5
+ import { DESCRIPTION_MINIMUM_LENGTH, DESCRIPTION_WARN_LENGTH, localAuthoringMetadata, METADATA_RULE_IDS, pageStructureFindings, TITLE_MINIMUM_LENGTH, TITLE_WARN_LENGTH } from "./page-structure-checks.js";
6
6
  import { missingPageLinkFindings, normalizedPathSet } from "./site-checks.js";
7
7
  import { documentTemplate, TEMPLATE_FREE_FORM, TEMPLATE_GENERATED } from "./typed-pages.js";
8
8
  import { displayPagePath } from "./workspace.js";
@@ -51,13 +51,15 @@ export const SITE_CHECK_WORDING = Object.freeze({
51
51
  },
52
52
  "seo.heading_before_title": (finding) =>
53
53
  `has the heading '${finding.subject}' before the page's h1; it publishes as an h${finding.detail}`,
54
- "a11y.image_alt_missing": () => "places an image with no alt text",
54
+ "a11y.image_default_missing": () => "has no default image description; set one with media images describe, then deploy",
55
55
  "a11y.link_text_empty": (finding) => `has a link to ${finding.subject} with no text`,
56
- "seo.title_long": (finding) =>
57
- `has a title of ${finding.detail} characters; search results show about ${TITLE_WARN_LENGTH}`,
58
- "seo.description_missing": () => "has no description; search results use the start of the page instead",
59
- "seo.description_long": (finding) =>
60
- `has a description of ${finding.detail} characters; search results show about ${DESCRIPTION_WARN_LENGTH}`,
56
+ "seo.title_missing": () => "has no title; add one that identifies the page",
57
+ "seo.title_short": (finding) => `has a title of ${finding.detail} characters; consider a more specific title (guidance: ${TITLE_MINIMUM_LENGTH}–${TITLE_WARN_LENGTH})`,
58
+ "seo.title_long": (finding) => `has a title of ${finding.detail} characters; consider shortening it (guidance: ${TITLE_MINIMUM_LENGTH}–${TITLE_WARN_LENGTH})`,
59
+ "seo.description_missing": () => "has no description; add a short account of what the page offers",
60
+ "seo.description_short": (finding) => `has a description of ${finding.detail} characters; consider adding useful detail (guidance: ${DESCRIPTION_MINIMUM_LENGTH}–${DESCRIPTION_WARN_LENGTH})`,
61
+ "seo.description_long": (finding) => `has a description of ${finding.detail} characters; consider shortening it (guidance: ${DESCRIPTION_MINIMUM_LENGTH}–${DESCRIPTION_WARN_LENGTH})`,
62
+ "seo.description_matches_title": () => "has a description that repeats its title; explain what readers will find",
61
63
  "seo.social_image_fallback": (finding) =>
62
64
  finding.detail === "first_image"
63
65
  ? "has no cover image; link previews use the page's first image only when it is public and at least 1200 pixels wide"
@@ -91,15 +93,17 @@ const OFFLINE_DOCUMENT_RULES = new Set(
91
93
  function structureFindingsFor(page) {
92
94
  if (page.document === null || typeof page.document !== "object") return [];
93
95
  const template = documentTemplate(page.document);
94
- // A generated page's title and description come from the site, not from an author.
95
- if (template === TEMPLATE_GENERATED) return [];
96
96
  const freeForm = template === TEMPLATE_FREE_FORM;
97
97
  return pageStructureFindings(
98
98
  {
99
99
  freeForm,
100
100
  body: freeForm ? page.document : undefined,
101
- title: page.title,
102
- description: page.declaredDescription ?? page.description,
101
+ ...localAuthoringMetadata({
102
+ title: page.title,
103
+ description: page.metadataDescriptionKnown === false ? undefined : page.declaredDescription ?? page.description,
104
+ homePage: page.pagePath === "" || page.pagePath === "/",
105
+ generated: template === TEMPLATE_GENERATED ? page.document.data ?? {} : undefined,
106
+ }),
103
107
  },
104
108
  OFFLINE_DOCUMENT_RULES,
105
109
  );
@@ -166,6 +170,8 @@ export function evaluateOfflineSiteChecks({
166
170
  },
167
171
  siteChecks: {
168
172
  ranOffline: rulesRunningAt("validate"),
173
+ // Local values are checked above; only a deployment knows every effective fallback.
174
+ deferredToDeployment: [...METADATA_RULE_IDS],
169
175
  // Rules that need the site's own records; `taproot-site status` shows what the site finds.
170
176
  checkedBySite: SITE_CHECK_RULES.filter((rule) => !rule.runsAt.includes("validate") && rule.runsAt.includes("health"))
171
177
  .map((rule) => rule.id),
@@ -173,7 +179,7 @@ export function evaluateOfflineSiteChecks({
173
179
  checkedAtDeploy: SITE_CHECK_RULES.filter((rule) =>
174
180
  !rule.runsAt.includes("validate") && !rule.runsAt.includes("health") && rule.runsAt.includes("deploy")
175
181
  ).map((rule) => rule.id),
176
- // Rules only the page editor evaluates: a cover, a description field, or a description's length (which the CLI reports as descriptionWarnings).
182
+ // Rules only the page editor evaluates, such as its selected cover.
177
183
  checkedInEditor: SITE_CHECK_RULES.filter((rule) =>
178
184
  !rule.runsAt.includes("validate") && !rule.runsAt.includes("health") && !rule.runsAt.includes("deploy")
179
185
  && rule.runsAt.includes("edit")
@@ -1,10 +1,11 @@
1
1
  {
2
- "version": 1,
3
- "about": "The catalogue of site checks (TR01085, TR00947). Severity: 'error' blocks a deploy, 'warning' informs. Scope: 'page' rules read one page body plus cheap site lookups, 'site' rules read the site's own records, 'document' rules read only one document and its metadata, so the editor and the CLI evaluate them from shared/page-structure-checks.ts and the server does not. runsAt names the run points that evaluate the rule: save, validate (the CLI, offline), deploy, health, edit (the editor, over the open document in the browser). appliesTo, when present, limits a rule to those page types. subject and detail say what a finding's two values hold. The typed registry in Taproot.Domain/SiteChecks/SiteCheckRules.cs must list the same rules; SiteCheckTests fails on a difference.",
2
+ "version": 2,
3
+ "about": "The catalogue of site checks (TR01085, TR00947). Severity: 'error' blocks a deploy, 'warning' informs. Scope: 'page' rules read one page body plus cheap site lookups, 'site' rules read the site's own records, 'document' rules read only one document and its metadata, so the editor and the CLI evaluate them from shared/page-structure-checks.ts and the generator compiles deployment advice from the same pure evaluator. runsAt names the run points that evaluate the rule: save, validate (the CLI, offline), deploy, health, edit (the editor, over the open document in the browser). appliesTo, when present, limits a rule to those page types. subject and detail say what a finding's two values hold. The typed registry in Taproot.Domain/SiteChecks/SiteCheckRules.cs must list the same rules; SiteCheckTests fails on a difference.",
4
4
  "runPoints": ["save", "validate", "deploy", "health", "edit"],
5
5
  "rules": [
6
6
  { "id": "link.page_missing", "severity": "warning", "scope": "page", "runsAt": ["save", "validate", "health"], "subject": "page_path", "detail": "none" },
7
7
  { "id": "image.missing", "severity": "warning", "scope": "page", "runsAt": ["save", "health"], "subject": "image_id", "detail": "none" },
8
+ { "id": "a11y.image_default_missing", "severity": "warning", "scope": "site", "runsAt": ["deploy", "health"], "subject": "image_id", "detail": "none" },
8
9
  { "id": "form.none_chosen", "severity": "error", "scope": "page", "runsAt": ["save", "deploy", "health"], "subject": "none", "detail": "none" },
9
10
  { "id": "form.unreadable", "severity": "error", "scope": "page", "runsAt": ["save", "deploy", "health"], "subject": "form_reference_text", "detail": "none" },
10
11
  { "id": "form.missing", "severity": "error", "scope": "page", "runsAt": ["save", "deploy", "health"], "subject": "form_id", "detail": "none" },
@@ -24,11 +25,14 @@
24
25
  { "id": "seo.h1_multiple", "severity": "warning", "scope": "document", "appliesTo": ["free_form"], "runsAt": ["validate", "edit"], "subject": "heading_text", "detail": "h1_claimant" },
25
26
  { "id": "seo.heading_renumbered", "severity": "warning", "scope": "document", "appliesTo": ["free_form"], "runsAt": ["validate", "edit"], "subject": "heading_text", "detail": "authored_and_published_level" },
26
27
  { "id": "seo.heading_before_title", "severity": "warning", "scope": "document", "appliesTo": ["free_form"], "runsAt": ["validate", "edit"], "subject": "heading_text", "detail": "published_level" },
27
- { "id": "a11y.image_alt_missing", "severity": "warning", "scope": "document", "appliesTo": ["free_form"], "runsAt": ["validate", "edit"], "subject": "image_id", "detail": "none" },
28
28
  { "id": "a11y.link_text_empty", "severity": "warning", "scope": "document", "appliesTo": ["free_form"], "runsAt": ["validate", "edit"], "subject": "link_target", "detail": "none" },
29
- { "id": "seo.title_long", "severity": "warning", "scope": "document", "runsAt": ["validate", "edit"], "subject": "none", "detail": "character_count" },
30
- { "id": "seo.description_missing", "severity": "warning", "scope": "document", "runsAt": ["validate", "edit"], "subject": "none", "detail": "none" },
31
- { "id": "seo.description_long", "severity": "warning", "scope": "document", "runsAt": ["edit"], "subject": "none", "detail": "character_count" },
29
+ { "id": "seo.title_missing", "severity": "warning", "scope": "document", "runsAt": ["validate", "edit", "deploy", "health"], "subject": "none", "detail": "none" },
30
+ { "id": "seo.title_short", "severity": "warning", "scope": "document", "runsAt": ["validate", "edit", "deploy", "health"], "subject": "none", "detail": "character_count" },
31
+ { "id": "seo.title_long", "severity": "warning", "scope": "document", "runsAt": ["validate", "edit", "deploy", "health"], "subject": "none", "detail": "character_count" },
32
+ { "id": "seo.description_missing", "severity": "warning", "scope": "document", "runsAt": ["validate", "edit", "deploy", "health"], "subject": "none", "detail": "none" },
33
+ { "id": "seo.description_short", "severity": "warning", "scope": "document", "runsAt": ["validate", "edit", "deploy", "health"], "subject": "none", "detail": "character_count" },
34
+ { "id": "seo.description_long", "severity": "warning", "scope": "document", "runsAt": ["validate", "edit", "deploy", "health"], "subject": "none", "detail": "character_count" },
35
+ { "id": "seo.description_matches_title", "severity": "warning", "scope": "document", "runsAt": ["validate", "edit", "deploy", "health"], "subject": "none", "detail": "none" },
32
36
  { "id": "seo.social_image_fallback", "severity": "warning", "scope": "document", "appliesTo": ["free_form"], "runsAt": ["edit"], "subject": "none", "detail": "social_image_source" }
33
37
  ]
34
38
  }
@@ -1,6 +1,8 @@
1
1
  import { FREE_FORM_TEMPLATE_VERSION, TEMPLATE_TYPE_FREE_FORM } from "./api.js";
2
2
  import { isVideoEmbedId } from "./content/video-embed-url.js";
3
3
  import { SiteAuthoringError } from "./errors.js";
4
+ import { isImageDescriptionMode } from "./content/image-description-validation.js";
5
+ import { stripImageDescriptionHydration } from "./content/image-description-document.js";
4
6
  import { requireDescriptionLength } from "./page-metadata.js";
5
7
 
6
8
  /**
@@ -323,7 +325,7 @@ const RECIPE_KEYS = [
323
325
  "cookingMethod",
324
326
  ];
325
327
  const ALBUM_KEYS = ["introductionBody", "images", "seamless", "borderWidth", "place"];
326
- const ALBUM_IMAGE_KEYS = ["imageId", "caption", "width", "height"];
328
+ const ALBUM_IMAGE_KEYS = ["imageId", "caption", "width", "height", "altText", "descriptionMode"];
327
329
  const ALBUM_MAXIMUM_BORDER_WIDTH = 20;
328
330
  const MAXIMUM_MINUTES = 100_000;
329
331
  const MAXIMUM_SERVINGS = 10_000;
@@ -372,12 +374,17 @@ const DATA_BUILDERS = Object.freeze({
372
374
  ["introductionBody", raw.introductionBody],
373
375
  [
374
376
  "images",
375
- raw.images.map((image) => ({
376
- imageId: image.imageId,
377
- caption: image.caption ?? "",
378
- width: image.width ?? 0,
379
- height: image.height ?? 0,
380
- })),
377
+ raw.images.map((image) => {
378
+ const authored = stripImageDescriptionHydration(image);
379
+ return definedEntries([
380
+ ["imageId", authored.imageId],
381
+ ["caption", authored.caption ?? ""],
382
+ ["width", authored.width ?? 0],
383
+ ["height", authored.height ?? 0],
384
+ ["altText", authored.altText],
385
+ ["descriptionMode", authored.descriptionMode],
386
+ ]);
387
+ }),
381
388
  ],
382
389
  ["seamless", raw.seamless],
383
390
  ["borderWidth", raw.borderWidth],
@@ -486,6 +493,14 @@ function canonicalSourceData(template, data, file) {
486
493
  requireKeys(image, ALBUM_IMAGE_KEYS, file, at);
487
494
  requireIdentifier(image.imageId, file, `${at}.imageId`);
488
495
  if (image.caption !== undefined) requireString(image.caption, file, `${at}.caption`);
496
+ if (!isImageDescriptionMode(image.descriptionMode)) {
497
+ throw fail(
498
+ "pages.document_shape",
499
+ `'${file}' ${at}.descriptionMode must be inherit, custom or decorative.`,
500
+ `${file}:${at}.descriptionMode`,
501
+ );
502
+ }
503
+ if (image.altText !== undefined && image.altText !== null) requireString(image.altText, file, `${at}.altText`);
489
504
  if (image.width !== undefined) requireCount(image.width, file, `${at}.width`, 100_000);
490
505
  if (image.height !== undefined) requireCount(image.height, file, `${at}.height`, 100_000);
491
506
  });
@@ -684,8 +699,9 @@ export function workspaceDocumentFromPage(page, listedTemplateType) {
684
699
  if (name === undefined) return undefined;
685
700
  const raw = template[PAGE_TEMPLATES[name].dataKey];
686
701
  if (!isPlainObject(raw)) return undefined;
687
- if (name === TEMPLATE_FREE_FORM) return isPlainObject(raw.body) ? raw.body : undefined;
688
- const data = PAGE_PROJECTIONS[name](raw);
702
+ const authored = stripImageDescriptionHydration(raw);
703
+ if (name === TEMPLATE_FREE_FORM) return isPlainObject(authored.body) ? authored.body : undefined;
704
+ const data = PAGE_PROJECTIONS[name](authored);
689
705
  if (data === undefined) return undefined;
690
706
  // A generated page's date and cover are not part of its source: a push leaves them as the site holds them.
691
707
  const generated = name === TEMPLATE_GENERATED || name === TEMPLATE_LEGAL;
@@ -1,3 +1,4 @@
1
+ import { describeNavigationReadiness, isNavigationReady, reportNavigationReadiness } from "../navigation-readiness.js";
1
2
  import {
2
3
  DEPLOYMENT_ENVIRONMENT_PRODUCTION,
3
4
  DEPLOYMENT_ENVIRONMENT_STAGING,
@@ -154,6 +155,7 @@ function reportReadiness(readiness) {
154
155
  const blockers = boundedList(readiness.blockers, MAXIMUM_REPORTED_BLOCKERS);
155
156
  return {
156
157
  state: readiness.state,
158
+ navigationReadiness: reportNavigationReadiness(readiness.navigationReadiness),
157
159
  approvedPageCount: readiness.approvedPageCount,
158
160
  selectedPageCount: readiness.selectedPageCount,
159
161
  blockedPageCount: readiness.blockedPageCount,
@@ -350,6 +352,15 @@ export async function deploy(invocation) {
350
352
  // The estimate must recognize the request it is about to be followed by.
351
353
  ...(invocation.allowFailedPreview === true ? { allowFailedPreview: true } : {}),
352
354
  });
355
+ onProgress(describeNavigationReadiness(readiness.navigationReadiness));
356
+ if (!isNavigationReady(readiness.navigationReadiness)) {
357
+ const media = readiness.blockers.length ? ` This candidate also has ${readiness.blockers.length} media blocker(s).` : "";
358
+ throw new SiteAuthoringError(
359
+ "deploy.navigation_blocked",
360
+ describeNavigationReadiness(readiness.navigationReadiness) + media,
361
+ { field: "Navigation", status: readiness.navigationReadiness.state },
362
+ );
363
+ }
353
364
  if (readiness.blockers.length > 0) {
354
365
  throw new SiteAuthoringError(
355
366
  "deploy.media_blocked",
@@ -17,6 +17,9 @@ import {
17
17
  VERB_MARKETING_PULL,
18
18
  VERB_MARKETING_PUSH,
19
19
  VERB_MEDIA_UPLOAD,
20
+ VERB_MEDIA_IMAGES_LIST,
21
+ VERB_MEDIA_IMAGES_GET,
22
+ VERB_MEDIA_IMAGES_DESCRIBE,
20
23
  VERB_NAV_PUSH,
21
24
  VERB_PAGES_DELETE,
22
25
  VERB_PAGES_META_SET,
@@ -58,6 +61,7 @@ import { logout } from "./logout.js";
58
61
  import { marketingPull } from "./marketing-pull.js";
59
62
  import { marketingPush } from "./marketing-push.js";
60
63
  import { mediaUpload } from "./media-upload.js";
64
+ import { mediaImagesDescribe, mediaImagesGet, mediaImagesList } from "./media-images.js";
61
65
  import { navPush } from "./nav-push.js";
62
66
  import { pagesDelete } from "./pages-delete.js";
63
67
  import { pagesMetaSet } from "./pages-meta-set.js";
@@ -118,6 +122,9 @@ import { whoami } from "./whoami.js";
118
122
  * ```
119
123
  * {
120
124
  * fixturePath, // validate: one offline authoring-fixture directory
125
+ * imageId, // media images get/describe: site-owned library image UUID
126
+ * pageSize, pageToken, missingDescription, // media images list
127
+ * imageDescription, clearDescription, expectedRevision, onlyIfMissing, // media images describe
121
128
  * paths, // media upload: files and/or directories to upload;
122
129
  * // with none, the workspace's media/ directory is walked
123
130
  * formKeys, // forms validate / push: narrow to these form keys;
@@ -187,6 +194,9 @@ export const VERB_HANDLERS = Object.freeze({
187
194
  [VERB_MARKETING_PUSH]: marketingPush,
188
195
  [VERB_LINK_BUILD]: linkBuild,
189
196
  [VERB_MEDIA_UPLOAD]: mediaUpload,
197
+ [VERB_MEDIA_IMAGES_LIST]: mediaImagesList,
198
+ [VERB_MEDIA_IMAGES_GET]: mediaImagesGet,
199
+ [VERB_MEDIA_IMAGES_DESCRIBE]: mediaImagesDescribe,
190
200
  [VERB_APPROVE]: approve,
191
201
  [VERB_DEPLOY]: deploy,
192
202
  [VERB_PREVIEW_PAGE]: previewPage,
@@ -0,0 +1,67 @@
1
+ import { getSiteImageDescription, listSiteImageDescriptions, updateSiteImageDescription, withRefusalGuidance } from "../api.js";
2
+ import { VERB_MEDIA_IMAGES_DESCRIBE, VERB_MEDIA_IMAGES_GET, VERB_MEDIA_IMAGES_LIST } from "../constants.js";
3
+ import { SiteAuthoringError } from "../errors.js";
4
+ import { imageDescriptionOptions, imageListOptions, requireImageId } from "../image-description-contract.js";
5
+ import { serializeResult } from "../output.js";
6
+ import { openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
7
+ import { ApiError } from "../transport.js";
8
+
9
+ export async function mediaImagesList(invocation) {
10
+ const options = imageListOptions(invocation);
11
+ const { client, siteId, onProgress } = await openSession(invocation);
12
+ const images = await withRefusalGuidance(onProgress, VERB_MEDIA_IMAGES_LIST, () =>
13
+ listSiteImageDescriptions(client, siteId, options));
14
+ const result = successResult(VERB_MEDIA_IMAGES_LIST, siteId, {
15
+ images: { ...images, count: images.items.length, missingDescription: options.missingDescription },
16
+ });
17
+ try {
18
+ serializeResult(result);
19
+ } catch (error) {
20
+ if (!(error instanceof SiteAuthoringError) || error.code !== "output.too_large") throw error;
21
+ throw new SiteAuthoringError("media.image_page_too_large",
22
+ "This page exceeds the output bound. Repeat the same cursor with a smaller --page-size; no items were omitted.",
23
+ { field: "pageSize" });
24
+ }
25
+ onProgress(`${images.items.length} image(s) returned${images.nextPageToken ? "; use images.nextPageToken for the next page" : ""}.`);
26
+ return result;
27
+ }
28
+
29
+ export async function mediaImagesGet(invocation) {
30
+ const imageId = requireImageId(invocation.imageId);
31
+ const { client, siteId, onProgress } = await openSession(invocation);
32
+ const image = await withRefusalGuidance(onProgress, VERB_MEDIA_IMAGES_GET, () =>
33
+ getSiteImageDescription(client, siteId, imageId));
34
+ onProgress(`Read image ${image.imageId}, description revision ${image.descriptionRevision}.`);
35
+ return successResult(VERB_MEDIA_IMAGES_GET, siteId, { image });
36
+ }
37
+
38
+ export async function mediaImagesDescribe(invocation) {
39
+ const imageId = requireImageId(invocation.imageId);
40
+ const body = imageDescriptionOptions(invocation);
41
+ const session = await openSession(invocation);
42
+ warnIfExternalWritesPaused(session, VERB_MEDIA_IMAGES_DESCRIBE);
43
+ const { client, siteId, onProgress } = session;
44
+ const image = await withRefusalGuidance(onProgress, VERB_MEDIA_IMAGES_DESCRIBE, async () => {
45
+ try {
46
+ return await updateSiteImageDescription(client, siteId, imageId, body);
47
+ } catch (error) {
48
+ if (error instanceof ApiError && error.hasField("ExpectedRevision")) {
49
+ throw new SiteAuthoringError("media.image_description_conflict",
50
+ "The description changed. Read it with 'media images get', reconcile the text, and supply that revision.",
51
+ { field: "expectedRevision", status: error.status });
52
+ }
53
+ if (error instanceof ApiError && error.hasField("OnlyIfMissing")) {
54
+ throw new SiteAuthoringError("media.image_description_exists", "This image already has a default description; nothing changed.",
55
+ { field: "onlyIfMissing", status: error.status });
56
+ }
57
+ if (error instanceof ApiError && error.hasField("DefaultDescription")) {
58
+ throw new SiteAuthoringError("media.image_description_invalid",
59
+ error.descriptionFor("DefaultDescription") ?? "Taproot refused the default description.",
60
+ { field: "defaultDescription", status: error.status });
61
+ }
62
+ throw error;
63
+ }
64
+ });
65
+ onProgress(`Saved image ${image.imageId}, description revision ${image.descriptionRevision}. Deploy to update published usages.`);
66
+ return successResult(VERB_MEDIA_IMAGES_DESCRIBE, siteId, { image });
67
+ }
@@ -1548,7 +1548,8 @@ export async function planPages({
1548
1548
  pagePath,
1549
1549
  title: sentTitle,
1550
1550
  // Only the owner's own description is warned about for length: the derived one is not theirs to shorten.
1551
- ...(generated ? { declaredDescription: document_.data.customDescription } : {}),
1551
+ declaredDescription: generated ? document_.data.customDescription : declaredDescription,
1552
+ metadataDescriptionKnown: generated || typeof declaredDescription === "string",
1552
1553
  contentKey,
1553
1554
  unchanged,
1554
1555
  description: sentDescription,
@@ -1,3 +1,4 @@
1
+ import { describeNavigationReadiness, reportNavigationReadiness } from "../navigation-readiness.js";
1
2
  import {
2
3
  getDeploySelection,
3
4
  getPublishingReadiness,
@@ -162,7 +163,7 @@ export async function status(invocation) {
162
163
  if (!presentationOnly) {
163
164
  onProgress("Reading image processing state.");
164
165
  ({ images, summary, truncated } = await listSiteImages(client, siteId));
165
- onProgress("Reading site health.");
166
+ onProgress("Reading the stored deployment health report.");
166
167
  health = await getSiteHealth(client, siteId);
167
168
  }
168
169
  const reportedFindings = boundedList(health.findings, MAXIMUM_HEALTH_FINDINGS);
@@ -184,10 +185,14 @@ export async function status(invocation) {
184
185
  const reportedDeployments = boundedList(deployments, MAXIMUM_DEPLOYMENTS);
185
186
  const blockers = boundedList(readiness.blockers, MAXIMUM_BLOCKERS);
186
187
 
188
+ onProgress(describeNavigationReadiness(readiness.navigationReadiness));
189
+ const assessed = ["complete", "incomplete"].includes(health.status) && Boolean(health.deploymentId);
187
190
  if (!presentationOnly) {
188
- onProgress(
189
- `Site health: ${health.errorCount} problem(s) and ${health.warningCount} warning(s) on ${health.pageCount} page(s).`,
190
- );
191
+ onProgress(assessed
192
+ ? `Site health (${health.environment}, checked ${health.checkedAt}): ${health.errorCount} problem(s) and ${health.warningCount} warning(s). Unpublished changes are outside this report.`
193
+ : health.status === "not_checked"
194
+ ? "Site health: not checked yet. Deploy the site to create a report."
195
+ : "Site health: no complete supported deployment assessment is available.");
191
196
  }
192
197
 
193
198
  return successResult(VERB_STATUS, siteId, {
@@ -196,6 +201,7 @@ export async function status(invocation) {
196
201
  ...(presentationOnly ? { authoringSurface: SURFACE_DOCS_PRESENTATION } : {}),
197
202
  readiness: {
198
203
  state: readiness.state,
204
+ navigationReadiness: reportNavigationReadiness(readiness.navigationReadiness),
199
205
  approvedPageCount: readiness.approvedPageCount,
200
206
  blockedPageCount: readiness.blockedPageCount,
201
207
  hasCandidateChanges: readiness.hasCandidateChanges,
@@ -227,12 +233,20 @@ export async function status(invocation) {
227
233
  ...(truncated ? { listTruncated: true } : {}),
228
234
  },
229
235
  siteHealth: presentationOnly ? { covered: false, reason: DOCS_SITE_NOT_COVERED_NOTE } : {
230
- covered: true,
236
+ covered: assessed,
237
+ status: health.status ?? "unknown",
238
+ ...(assessed ? {
239
+ deploymentId: health.deploymentId, outputReleaseId: health.outputReleaseId,
240
+ inputHash: health.inputHash, checkedAt: health.checkedAt, ruleVersion: health.ruleVersion,
241
+ coverage: health.coverage, assessedPages: health.assessedPageCount, environment: health.environment,
242
+ unpublishedChangesAssessed: false,
231
243
  errors: health.errorCount,
232
244
  warnings: health.warningCount,
233
245
  pages: health.pageCount,
234
246
  findings: reportedFindings.items,
247
+ imageFindings: health.images,
235
248
  ...(reportedFindings.truncated || health.truncated ? { findingsTruncated: true } : {}),
249
+ } : { reason: health.status === "not_checked" ? "Not checked yet; deploy the site to create a report." : "A complete supported deployment assessment is unavailable." }),
236
250
  },
237
251
  });
238
252
  });