@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 +68 -12
- package/package.json +2 -2
- package/src/api.js +110 -3
- package/src/changelog.json +19 -0
- package/src/cli.js +102 -6
- package/src/constants.js +3 -0
- package/src/content/components.js +16 -9
- package/src/content/free-form-section-registry.json +24 -4
- package/src/content/free-form-sections.js +11 -0
- package/src/content/image-description-document.js +108 -0
- package/src/content/image-description-validation.js +70 -0
- package/src/content/tiptap-prosemirror.ts +10 -0
- package/src/content/validate-document.js +2 -0
- package/src/image-description-contract.js +84 -0
- package/src/navigation-readiness.js +47 -0
- package/src/output.js +8 -0
- package/src/page-metadata.js +10 -10
- package/src/page-structure-checks.js +50 -45
- package/src/reference-help.js +8 -4
- package/src/site-check-findings.js +18 -12
- package/src/site-check-rules.json +10 -6
- package/src/typed-pages.js +25 -9
- package/src/verbs/deploy.js +11 -0
- package/src/verbs/index.js +10 -0
- package/src/verbs/media-images.js +67 -0
- package/src/verbs/pages-push.js +2 -1
- package/src/verbs/status.js +19 -5
package/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.
|
|
161
|
-
`a11y.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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`
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
findings
|
|
712
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1197
|
-
|
|
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),
|
package/src/changelog.json
CHANGED
|
@@ -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.
|
|
152
|
-
+ "
|
|
153
|
-
+ "
|
|
154
|
-
+ "
|
|
155
|
+
+ "a11y.link_text_empty. Image-default completeness is deferred to the stored deployment report that status reads; "
|
|
156
|
+
+ "placement alt strings do not decide whether an image default is missing. Known author-entered metadata also receives missing, "
|
|
157
|
+
+ "short, long and repeated-title description advice (title guidance 15–70 characters; description 50–160). "
|
|
158
|
+
+ "These are writing suggestions, never requirements. Generated custom values are checked; generated "
|
|
159
|
+
+ "defaults, home-page settings and unknown fields are listed in siteChecks.deferredToDeployment. "
|
|
160
|
+
+ "siteChecks.checkedInEditor names rules only the page editor evaluates, such as a selected cover; "
|
|
161
|
+
+ "siteChecks.checkedBySite names rules whose evidence comes from site records; 'status' reads their "
|
|
162
|
+
+ "stored deployment report under siteHealth without compiling or rescanning drafts. A redirect source the site would refuse names its rule in "
|
|
155
163
|
+ "error.problems[].rule. This proves local structure and semantics only. It does not prove "
|
|
156
164
|
+ "authorization, live site ownership, concurrency, persisted round trips, or rendering: 'plan' checks a "
|
|
157
165
|
+ "workspace against the live site, and 'pages push --dry-run' does that for pages alone. "
|
|
@@ -712,6 +720,46 @@ const VERBS = Object.freeze([
|
|
|
712
720
|
note: "Positional arguments name the files or directories to upload;"
|
|
713
721
|
+ " with none, the workspace's media/ directory is walked.",
|
|
714
722
|
},
|
|
723
|
+
{
|
|
724
|
+
name: VERB_MEDIA_IMAGES_LIST,
|
|
725
|
+
surface: SURFACE_DOCS_PRESENTATION,
|
|
726
|
+
capabilities: [CAPABILITY_CONTENT],
|
|
727
|
+
surfaceCapabilities: { [SURFACE_DOCS_PRESENTATION]: [CAPABILITY_DESIGN] },
|
|
728
|
+
tokens: ["media", "images", "list"],
|
|
729
|
+
summary: "Read one page of the site's image defaults, optionally only missing descriptions.",
|
|
730
|
+
imageListOptions: true,
|
|
731
|
+
json: true,
|
|
732
|
+
note: "Returns images.items and images.nextPageToken. Pass that cursor unchanged for the next page. "
|
|
733
|
+
+ "Filtering happens on the server before pagination; no page bodies or per-image reads are scanned. "
|
|
734
|
+
+ "Description revisions are decimal strings. Preview URLs are for inspection, may expire, and are omitted from GITHUB_OUTPUT.",
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
name: VERB_MEDIA_IMAGES_GET,
|
|
738
|
+
surface: SURFACE_DOCS_PRESENTATION,
|
|
739
|
+
capabilities: [CAPABILITY_CONTENT],
|
|
740
|
+
surfaceCapabilities: { [SURFACE_DOCS_PRESENTATION]: [CAPABILITY_DESIGN] },
|
|
741
|
+
tokens: ["media", "images", "get"],
|
|
742
|
+
summary: "Read one site-owned library image's default description and revision.",
|
|
743
|
+
positionals: "imageId",
|
|
744
|
+
json: true,
|
|
745
|
+
note: "The image must belong to the selected site's ordinary library; profile, account and installation media are excluded. "
|
|
746
|
+
+ "Use the returned descriptionRevision unchanged when saving. No workspace file is written.",
|
|
747
|
+
},
|
|
748
|
+
{
|
|
749
|
+
name: VERB_MEDIA_IMAGES_DESCRIBE,
|
|
750
|
+
surface: SURFACE_DOCS_PRESENTATION,
|
|
751
|
+
capabilities: [CAPABILITY_CONTENT],
|
|
752
|
+
surfaceCapabilities: { [SURFACE_DOCS_PRESENTATION]: [CAPABILITY_DESIGN] },
|
|
753
|
+
tokens: ["media", "images", "describe"],
|
|
754
|
+
summary: "Save or clear an image-owned default with its expected revision.",
|
|
755
|
+
positionals: "imageId",
|
|
756
|
+
imageDescriptionOptions: true,
|
|
757
|
+
json: true,
|
|
758
|
+
note: "Supply exactly one of --description and --clear, plus --expected-revision from list/get. "
|
|
759
|
+
+ "Text is outer-trimmed and limited by the server to 1000 Unicode scalar values; blank text clears to null. "
|
|
760
|
+
+ "--if-missing is checked atomically on the server and cannot clear. A conflict never retries or overwrites another edit. "
|
|
761
|
+
+ "This changes the reusable image default, never a page-body override. Existing published output changes only after deployment.",
|
|
762
|
+
},
|
|
715
763
|
{
|
|
716
764
|
name: VERB_APPROVE,
|
|
717
765
|
surface: SURFACE_STANDARD,
|
|
@@ -977,6 +1025,8 @@ function verbHelp(verb) {
|
|
|
977
1025
|
? " <page-path> [--title <text>] [--description <text>] [--author <handle-or-email>]"
|
|
978
1026
|
: verb.positionals === "authorHandle"
|
|
979
1027
|
? " <handle> --name <display-name> [--email <address>]"
|
|
1028
|
+
: verb.positionals === "imageId"
|
|
1029
|
+
? " <image-id>"
|
|
980
1030
|
: verb.positionals
|
|
981
1031
|
? ` [${verb.positionals === "paths" ? "path" : "page-path"}...]`
|
|
982
1032
|
: "";
|
|
@@ -1007,6 +1057,16 @@ function verbHelp(verb) {
|
|
|
1007
1057
|
--description <text> The page's description for search results and link previews; '' clears it.
|
|
1008
1058
|
--author <ref> Credit a page that has no author: a site author's handle or a member's email address.`
|
|
1009
1059
|
: "";
|
|
1060
|
+
const imageOptions = verb.imageListOptions
|
|
1061
|
+
? `\n --missing-description Filter to images whose own default is missing.
|
|
1062
|
+
--page-size <n> Images in this page, 1–100 (default 25).
|
|
1063
|
+
--page-token <cursor> The preceding result's images.nextPageToken, unchanged.`
|
|
1064
|
+
: verb.imageDescriptionOptions
|
|
1065
|
+
? `\n --description <text> Set the shared image default; --description=<text> accepts leading hyphens.
|
|
1066
|
+
--clear Clear the default instead of supplying --description.
|
|
1067
|
+
--expected-revision <n> Required decimal revision from list/get, including 0.
|
|
1068
|
+
--if-missing Save only while the stored default is still null; cannot clear.`
|
|
1069
|
+
: "";
|
|
1010
1070
|
const authorOptions = verb.authorOptions
|
|
1011
1071
|
? `\n --name <text> The name readers see on the byline (required).
|
|
1012
1072
|
--email <address> A private address, kept to link the person to an account later (optional).`
|
|
@@ -1116,7 +1176,7 @@ function verbHelp(verb) {
|
|
|
1116
1176
|
${verb.summary}
|
|
1117
1177
|
${boundary}
|
|
1118
1178
|
|
|
1119
|
-
${options}${targetOption}${handoffOption}${reviewPathOption}${sinceOption}${deliveryTargetOption}${metaOptions}${authorOptions}${jsonOption}${resolveOptions}${dryRunOption}${archiveOption}${linkOptions}${planOption}${nameOption}${note}
|
|
1179
|
+
${options}${targetOption}${handoffOption}${reviewPathOption}${sinceOption}${deliveryTargetOption}${metaOptions}${authorOptions}${imageOptions}${jsonOption}${resolveOptions}${dryRunOption}${archiveOption}${linkOptions}${planOption}${nameOption}${note}
|
|
1120
1180
|
`;
|
|
1121
1181
|
}
|
|
1122
1182
|
|
|
@@ -1166,6 +1226,15 @@ function parseLinkOption(rest, index, argument) {
|
|
|
1166
1226
|
return { name, value, consumed: inline === undefined ? 1 : 0 };
|
|
1167
1227
|
}
|
|
1168
1228
|
|
|
1229
|
+
function parseImageOption(rest, index, argument) {
|
|
1230
|
+
const equals = argument.indexOf("=");
|
|
1231
|
+
const name = equals < 0 ? argument : argument.slice(0, equals);
|
|
1232
|
+
const value = equals < 0 ? rest[index + 1] : argument.slice(equals + 1);
|
|
1233
|
+
if (typeof value !== "string" || (equals < 0 && value.startsWith("--")))
|
|
1234
|
+
throw usageError("cli.option_value", `${name} requires a value.`, { field: name.slice(2) });
|
|
1235
|
+
return { name, value, consumed: equals < 0 ? 1 : 0 };
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1169
1238
|
function usageError(code, message, options = {}) {
|
|
1170
1239
|
return new SiteAuthoringError(code, message, { ...options, exitCode: 2 });
|
|
1171
1240
|
}
|
|
@@ -1546,6 +1615,7 @@ function parseArguments(arguments_) {
|
|
|
1546
1615
|
let planHash;
|
|
1547
1616
|
let archive = false;
|
|
1548
1617
|
const linkValues = {};
|
|
1618
|
+
const imageValues = {};
|
|
1549
1619
|
let metaTitle;
|
|
1550
1620
|
let metaDescription;
|
|
1551
1621
|
let metaAuthor;
|
|
@@ -1735,6 +1805,23 @@ function parseArguments(arguments_) {
|
|
|
1735
1805
|
index += 1;
|
|
1736
1806
|
continue;
|
|
1737
1807
|
}
|
|
1808
|
+
if ((verb.imageListOptions && (argument === "--missing-description"))
|
|
1809
|
+
|| (verb.imageDescriptionOptions && (argument === "--clear" || argument === "--if-missing"))) {
|
|
1810
|
+
const field = { "--missing-description": "missingDescription", "--clear": "clearDescription", "--if-missing": "onlyIfMissing" }[argument];
|
|
1811
|
+
if (imageValues[field]) throw usageError("cli.duplicate_option", `${argument} may be supplied only once.`);
|
|
1812
|
+
imageValues[field] = true;
|
|
1813
|
+
continue;
|
|
1814
|
+
}
|
|
1815
|
+
const imageOption = argument.split("=", 1)[0];
|
|
1816
|
+
if ((verb.imageListOptions && ["--page-size", "--page-token"].includes(imageOption))
|
|
1817
|
+
|| (verb.imageDescriptionOptions && ["--description", "--expected-revision"].includes(imageOption))) {
|
|
1818
|
+
const field = { "--page-size": "pageSize", "--page-token": "pageToken", "--description": "imageDescription", "--expected-revision": "expectedRevision" }[imageOption];
|
|
1819
|
+
if (imageValues[field] !== undefined) throw usageError("cli.duplicate_option", `${imageOption} may be supplied only once.`);
|
|
1820
|
+
const parsed = parseImageOption(rest, index, argument);
|
|
1821
|
+
imageValues[field] = parsed.value;
|
|
1822
|
+
index += parsed.consumed;
|
|
1823
|
+
continue;
|
|
1824
|
+
}
|
|
1738
1825
|
if (argument.startsWith("-")) {
|
|
1739
1826
|
throw usageError("cli.unknown_option", "The command contains an unknown option.");
|
|
1740
1827
|
}
|
|
@@ -1838,6 +1925,12 @@ function parseArguments(arguments_) {
|
|
|
1838
1925
|
{ field: "title" },
|
|
1839
1926
|
);
|
|
1840
1927
|
}
|
|
1928
|
+
if (verb.positionals === "imageId") {
|
|
1929
|
+
if (positionals.length !== 1) throw usageError("media.image_id_invalid", "Name exactly one image UUID.", { field: "imageId" });
|
|
1930
|
+
requireImageId(positionals[0]);
|
|
1931
|
+
}
|
|
1932
|
+
if (verb.imageListOptions) imageListOptions(imageValues);
|
|
1933
|
+
if (verb.imageDescriptionOptions) imageDescriptionOptions(imageValues);
|
|
1841
1934
|
if (verb.positionals === "authorHandle" && positionals.length !== 1) {
|
|
1842
1935
|
throw usageError("authors.handle_missing", "authors add needs exactly one handle, such as jane-doe.", {
|
|
1843
1936
|
field: "handle",
|
|
@@ -1908,6 +2001,7 @@ function parseArguments(arguments_) {
|
|
|
1908
2001
|
...(planHash === undefined ? {} : { planHash }),
|
|
1909
2002
|
...(archive ? { archive } : {}),
|
|
1910
2003
|
...linkValues,
|
|
2004
|
+
...imageValues,
|
|
1911
2005
|
...(metaTitle === undefined ? {} : { metaTitle }),
|
|
1912
2006
|
...(metaDescription === undefined ? {} : { metaDescription }),
|
|
1913
2007
|
...(metaAuthor === undefined ? {} : { metaAuthor }),
|
|
@@ -1919,7 +2013,7 @@ function parseArguments(arguments_) {
|
|
|
1919
2013
|
values: positionals,
|
|
1920
2014
|
scalar: verb.positionals === "pageSelector" || verb.positionals === "fixturePath" || verb.positionals === "metaPagePath"
|
|
1921
2015
|
|| verb.positionals === "deletePagePath" || verb.positionals === "siteSelector" || verb.positionals === "environmentSelector"
|
|
1922
|
-
|| verb.positionals === "authorHandle",
|
|
2016
|
+
|| verb.positionals === "authorHandle" || verb.positionals === "imageId",
|
|
1923
2017
|
}
|
|
1924
2018
|
: undefined,
|
|
1925
2019
|
};
|
|
@@ -2016,6 +2110,8 @@ export async function runCli({
|
|
|
2016
2110
|
...(parsed.metaAuthor === undefined ? {} : { metaAuthor: parsed.metaAuthor }),
|
|
2017
2111
|
...(parsed.authorName === undefined ? {} : { authorName: parsed.authorName }),
|
|
2018
2112
|
...(parsed.authorEmail === undefined ? {} : { authorEmail: parsed.authorEmail }),
|
|
2113
|
+
...Object.fromEntries(["pageSize", "pageToken", "missingDescription", "imageDescription", "clearDescription", "expectedRevision", "onlyIfMissing"]
|
|
2114
|
+
.filter((field) => parsed[field] !== undefined).map((field) => [field, parsed[field]])),
|
|
2019
2115
|
capabilities: parsed.capabilities,
|
|
2020
2116
|
surface: parsed.surface,
|
|
2021
2117
|
surfaceCapabilities: parsed.surfaceCapabilities,
|
package/src/constants.js
CHANGED
|
@@ -101,6 +101,9 @@ export const VERB_THEME_PUSH = "theme push";
|
|
|
101
101
|
export const VERB_THEME_RESOLVE = "theme resolve";
|
|
102
102
|
export const VERB_FOOTER_PUSH = "footer push";
|
|
103
103
|
export const VERB_MEDIA_UPLOAD = "media upload";
|
|
104
|
+
export const VERB_MEDIA_IMAGES_LIST = "media images list";
|
|
105
|
+
export const VERB_MEDIA_IMAGES_GET = "media images get";
|
|
106
|
+
export const VERB_MEDIA_IMAGES_DESCRIBE = "media images describe";
|
|
104
107
|
export const VERB_APPROVE = "approve";
|
|
105
108
|
export const VERB_DEPLOY = "deploy";
|
|
106
109
|
export const VERB_PREVIEW_PAGE = "preview page";
|
|
@@ -8,6 +8,8 @@ import registry from "./free-form-section-registry.json" with { type: "json" };
|
|
|
8
8
|
import { hasAsciiControl } from "../errors.js";
|
|
9
9
|
import { isSocialLinkUrl, SOCIAL_LINK_URL_MAX_LENGTH, trimSocialLinkInput } from "../social-link-url.js";
|
|
10
10
|
import socialPlatforms from "../social-platforms.json" with { type: "json" };
|
|
11
|
+
import { IMAGE_DESCRIPTION_MODES } from "./image-description-validation.js";
|
|
12
|
+
import { stripImageDescriptionHydration } from "./image-description-document.js";
|
|
11
13
|
import {
|
|
12
14
|
CONTENT_ERROR_CODES as CODES,
|
|
13
15
|
CONTENT_LIMITS,
|
|
@@ -69,8 +71,8 @@ const list = (item, options = {}) => Object.freeze({ kind: "array", item, ...opt
|
|
|
69
71
|
const shape = (fields, options = {}) => Object.freeze({ kind: "object", fields: Object.freeze(fields), ...options });
|
|
70
72
|
/**
|
|
71
73
|
* A processed-image record or null. `required` refuses null, `altRequired`
|
|
72
|
-
*
|
|
73
|
-
* height
|
|
74
|
+
* requires authored alt unless inherited, and `dimensionsRequired` demands
|
|
75
|
+
* positive width and height for a component whose images are the content (TR01322).
|
|
74
76
|
*/
|
|
75
77
|
const image = (options = {}) => Object.freeze({ kind: "image", ...options });
|
|
76
78
|
/**
|
|
@@ -115,6 +117,7 @@ const COMPONENT_IMAGE_FIELDS = Object.freeze({
|
|
|
115
117
|
width: num({ integer: true, minimum: 0 }),
|
|
116
118
|
height: num({ integer: true, minimum: 0 }),
|
|
117
119
|
alt: str(),
|
|
120
|
+
descriptionMode: Object.freeze({ ...enumOf(...IMAGE_DESCRIPTION_MODES), nullable: true }),
|
|
118
121
|
});
|
|
119
122
|
|
|
120
123
|
/**
|
|
@@ -320,6 +323,7 @@ const CARD_GRID = Object.freeze({
|
|
|
320
323
|
const IMAGE_BANNER = Object.freeze({
|
|
321
324
|
image: image(),
|
|
322
325
|
altText: str(),
|
|
326
|
+
descriptionMode: Object.freeze({ ...enumOf(...IMAGE_DESCRIPTION_MODES), nullable: true }),
|
|
323
327
|
overlayText: str(),
|
|
324
328
|
// One supporting sentence and at most one link under the headline: the copy
|
|
325
329
|
// stack Espalier 6.1 lays out with a bounded measure (TR00819).
|
|
@@ -429,9 +433,11 @@ const FORM = Object.freeze({
|
|
|
429
433
|
* The image field table an image spec validates against. The shared table
|
|
430
434
|
* leaves alt and dimensions optional; a spec that requires them swaps in
|
|
431
435
|
* stricter entries at the same positions so canonical order is unchanged.
|
|
436
|
+
* Inherited alt is delivery state, removed before a workspace round trip.
|
|
432
437
|
*/
|
|
433
|
-
function imageFieldsFor(spec) {
|
|
434
|
-
|
|
438
|
+
function imageFieldsFor(spec, value) {
|
|
439
|
+
const altRequired = spec.altRequired && value?.descriptionMode !== "inherit";
|
|
440
|
+
if (!altRequired && !spec.dimensionsRequired) return COMPONENT_IMAGE_FIELDS;
|
|
435
441
|
return Object.freeze({
|
|
436
442
|
...COMPONENT_IMAGE_FIELDS,
|
|
437
443
|
...(spec.dimensionsRequired
|
|
@@ -440,7 +446,7 @@ function imageFieldsFor(spec) {
|
|
|
440
446
|
height: num({ integer: true, minimum: 1, required: true }),
|
|
441
447
|
}
|
|
442
448
|
: {}),
|
|
443
|
-
...(
|
|
449
|
+
...(altRequired
|
|
444
450
|
? {
|
|
445
451
|
alt: str({
|
|
446
452
|
required: true,
|
|
@@ -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
|
|
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(
|
|
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.");
|