@taprootio/site-authoring 0.19.0 → 0.21.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 +132 -28
- package/examples/riverbend-wellness/nav.json +3 -6
- package/examples/riverbend-wellness/pages/visit.md +1 -1
- package/package.json +2 -2
- package/src/api.js +118 -31
- package/src/changelog.js +48 -0
- package/src/changelog.json +71 -0
- package/src/cli-release.js +9 -1
- package/src/cli.js +128 -15
- package/src/constants.js +19 -0
- package/src/content/components.js +8 -5
- package/src/errors.js +3 -0
- package/src/footer-contract.js +5 -3
- package/src/form-references.js +103 -0
- package/src/forms-workspace.js +24 -0
- package/src/index.js +1 -0
- package/src/output.js +9 -0
- package/src/presentation-reference.js +41 -6
- package/src/problems.js +6 -1
- package/src/redirects-contract.js +50 -140
- package/src/reference-help.js +83 -36
- package/src/reserved-paths.js +4 -10
- package/src/setting-reference.js +438 -0
- package/src/site-check-findings.js +99 -0
- package/src/site-check-rules.json +22 -0
- package/src/site-checks.js +337 -0
- package/src/staging-check.js +48 -9
- package/src/staging-served.js +320 -0
- package/src/transport.js +13 -0
- package/src/typed-page-reference.js +128 -4
- package/src/typed-pages.js +1 -1
- package/src/verbs/changelog.js +62 -0
- package/src/verbs/deploy.js +38 -10
- package/src/verbs/forms-push.js +22 -1
- package/src/verbs/index.js +3 -0
- package/src/verbs/nav-push.js +49 -1
- package/src/verbs/pages-push.js +45 -1
- package/src/verbs/plan.js +4 -2
- package/src/verbs/pull.js +17 -3
- package/src/verbs/redirects-check.js +4 -1
- package/src/verbs/staging-review.js +1 -1
- package/src/verbs/status.js +35 -28
- package/src/verbs/validate.js +21 -0
package/src/cli-release.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
changelogCommandSince,
|
|
2
3
|
CLI_UPGRADE_COMMAND,
|
|
3
4
|
CLI_UPGRADE_REFUSAL_FIELD,
|
|
4
5
|
CLI_VERSION,
|
|
@@ -131,6 +132,11 @@ export function isBehindLatest(latestVersion, currentVersion = CLI_VERSION) {
|
|
|
131
132
|
* `api.request_rejected`.
|
|
132
133
|
*/
|
|
133
134
|
class CliOutdatedError extends SiteAuthoringError {
|
|
135
|
+
constructor(code, message, options, latestVersion) {
|
|
136
|
+
super(code, message, options);
|
|
137
|
+
this.latestVersion = latestVersion;
|
|
138
|
+
}
|
|
139
|
+
|
|
134
140
|
refusalKind() {
|
|
135
141
|
return REFUSAL_CLI_OUTDATED;
|
|
136
142
|
}
|
|
@@ -146,7 +152,9 @@ export function cliOutdatedError(latestVersion) {
|
|
|
146
152
|
`This CLI is version ${CLI_VERSION}, and the last Taproot sign-in exchange reported ${latestVersion} as the `
|
|
147
153
|
+ "latest published release. Taproot accepts only the latest, so every online verb is refused until this "
|
|
148
154
|
+ `package is upgraded. Upgrade with: ${CLI_UPGRADE_COMMAND}. If ${latestVersion} was published in the last `
|
|
149
|
-
+ "few minutes, npm may not serve it yet — wait a moment and retry the upgrade."
|
|
155
|
+
+ "few minutes, npm may not serve it yet — wait a moment and retry the upgrade. Once upgraded, run "
|
|
156
|
+
+ `'${changelogCommandSince(CLI_VERSION)}' to see what changed.`,
|
|
150
157
|
{ field: CLI_UPGRADE_REFUSAL_FIELD },
|
|
158
|
+
latestVersion,
|
|
151
159
|
);
|
|
152
160
|
}
|
package/src/cli.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { normalizeStagingReviewPath } from "./api.js";
|
|
2
|
+
import { parseCliVersion } from "./cli-release.js";
|
|
1
3
|
import { CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS, CAPABILITY_DESIGN } from "./capabilities.js";
|
|
2
4
|
import { capabilitiesForSurface } from "./surface.js";
|
|
3
5
|
import {
|
|
@@ -22,6 +24,7 @@ import {
|
|
|
22
24
|
VERB_DELIVERY_CHECK,
|
|
23
25
|
VERB_DEPLOY,
|
|
24
26
|
VERB_STAGING_REVIEW,
|
|
27
|
+
VERB_CHANGELOG,
|
|
25
28
|
VERB_ENV,
|
|
26
29
|
VERB_FOOTER_PUSH,
|
|
27
30
|
VERB_FORMS_PULL,
|
|
@@ -69,6 +72,7 @@ import {
|
|
|
69
72
|
getDesignBlueprint,
|
|
70
73
|
getFooterReference,
|
|
71
74
|
getPageTypeReference,
|
|
75
|
+
getSettingsReference,
|
|
72
76
|
getThemeReference,
|
|
73
77
|
getWorkflowReference,
|
|
74
78
|
listComponentTypeReferences,
|
|
@@ -118,7 +122,12 @@ const VERBS = Object.freeze([
|
|
|
118
122
|
+ "rather than one per run. A pulled workspace is checked offline against the pages (generated ones included, "
|
|
119
123
|
+ "so navigation to a section or tag page resolves), images, and baselines its manifests recorded at the pull. "
|
|
120
124
|
+ "A link whose address is only http(s):// plus its own text (left by older editor autolinking) is reported in "
|
|
121
|
-
+ "linkWarnings without refusing; pages push reports the same for the pages it sends.
|
|
125
|
+
+ "linkWarnings without refusing; pages push reports the same for the pages it sends. "
|
|
126
|
+
+ "The site checks that can run offline are reported in findings (a rule id, severity, file, and the value "
|
|
127
|
+
+ "found, such as link.page_missing for a link to a page the workspace does not have) and never refuse a "
|
|
128
|
+
+ "warning; siteChecks.checkedBySite names the rules that need the site's own records (images, forms), which "
|
|
129
|
+
+ "'status' reports under siteHealth. A redirect source the site would refuse names its rule in "
|
|
130
|
+
+ "error.problems[].rule. This proves local structure and semantics only. It does not prove "
|
|
122
131
|
+ "authorization, live site ownership, concurrency, persisted round trips, or rendering: 'plan' checks a "
|
|
123
132
|
+ "workspace against the live site, and 'pages push --dry-run' does that for pages alone. "
|
|
124
133
|
+ `See '${CLI_BINARY_NAME} help fixture' for the fixture manifest contract and for the path of the complete `
|
|
@@ -200,6 +209,21 @@ const VERBS = Object.freeze([
|
|
|
200
209
|
+ "its value. The sign-in secret is never printed; the credential is named by id and display prefix, which are "
|
|
201
210
|
+ "what an owner needs to revoke it.",
|
|
202
211
|
},
|
|
212
|
+
{
|
|
213
|
+
name: VERB_CHANGELOG,
|
|
214
|
+
tokens: ["changelog"],
|
|
215
|
+
summary: "List what changed in each CLI release, offline.",
|
|
216
|
+
credentialFree: true,
|
|
217
|
+
configFree: true,
|
|
218
|
+
offline: true,
|
|
219
|
+
sinceOption: true,
|
|
220
|
+
note: "Only the latest CLI is accepted, so an upgrade can be forced at any time. After one, run "
|
|
221
|
+
+ "'changelog --since <the version you upgraded from>' to see every release's note and the behavior it changed, "
|
|
222
|
+
+ "and which help topics (help <topic>) to read again. With no --since it prints the installed release's entry. "
|
|
223
|
+
+ "It reads the package's own record, needs no sign-in or site, and still runs when this CLI is outdated. "
|
|
224
|
+
+ "The result lists entries newest first, each { version, note, behavior, help }; complete is false when "
|
|
225
|
+
+ "--since starts before the oldest release the record covers. 'status' reports the installed release's note.",
|
|
226
|
+
},
|
|
203
227
|
{
|
|
204
228
|
name: VERB_ENV,
|
|
205
229
|
tokens: ["env"],
|
|
@@ -239,7 +263,9 @@ const VERBS = Object.freeze([
|
|
|
239
263
|
+ "and announced on the human channel. New downloads and compared bodies are excluded. "
|
|
240
264
|
+ "A page Taproot generates (a tag, an archive, a place, a folder) is pulled as an update-only '.pm.json' "
|
|
241
265
|
+ "(template 'generated'): you edit its custom title, breadcrumb title, custom description and introduction, "
|
|
242
|
-
+ "and the manifest keeps the title and description the site reports."
|
|
266
|
+
+ "and the manifest keeps the title and description the site reports. "
|
|
267
|
+
+ "A form a page places is written back by its key, the name 'forms/<key>.json' uses, though the site stores "
|
|
268
|
+
+ "the form's id.",
|
|
243
269
|
},
|
|
244
270
|
{
|
|
245
271
|
name: VERB_PAGES_META_SET,
|
|
@@ -305,6 +331,8 @@ const VERBS = Object.freeze([
|
|
|
305
331
|
positionals: "pagePaths",
|
|
306
332
|
note:
|
|
307
333
|
"Positional page paths narrow the push to those pages; with none, every workspace page is validated and sent. "
|
|
334
|
+
+ "A page names a form by key (formKey); the push looks each key up on the site, sends the form's id, and "
|
|
335
|
+
+ "refuses a key the site does not have or has archived (pages.form_unknown, pages.form_archived) before writing. "
|
|
308
336
|
+ "A link in a page being sent whose address is only http(s):// plus its own text is reported in linkWarnings "
|
|
309
337
|
+ "(content.link_autolinked) without refusing. "
|
|
310
338
|
+ "A page is created without an author unless its source names one ('author:' in the front matter, or 'pages "
|
|
@@ -394,7 +422,7 @@ const VERBS = Object.freeze([
|
|
|
394
422
|
capabilities: [CAPABILITY_CONTENT, CAPABILITY_DESIGN],
|
|
395
423
|
tokens: ["plan"],
|
|
396
424
|
summary: "Check the whole workspace against the live site and order what is left to send. Writes nothing.",
|
|
397
|
-
note: "Runs every check the pushes run — each page against the site's pages, revisions and
|
|
425
|
+
note: "Runs every check the pushes run — each page against the site's pages, revisions, videos and forms; media that "
|
|
398
426
|
+ "pages reference and media upload has not sent; the theme, appearance and footer colors against the site's "
|
|
399
427
|
+ "presentation revision; the footer against its draft; navigation against the live page list — and lists "
|
|
400
428
|
+ "every problem with its area, file, code and field. It then orders the steps that remain: media upload, "
|
|
@@ -436,9 +464,12 @@ const VERBS = Object.freeze([
|
|
|
436
464
|
capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
|
|
437
465
|
tokens: ["redirects", "check"],
|
|
438
466
|
summary: "Check the current redirect map through the authenticated staging edge.",
|
|
467
|
+
handoffOptions: true,
|
|
439
468
|
note: "Reports each entry's path, HTTP status and Location in redirects.items, and redirects.verified, without "
|
|
440
469
|
+ "following redirects. A redirect is live only with the release that carries it, so this reads the staged release. "
|
|
441
|
-
+ "The
|
|
470
|
+
+ "The check authenticates with its own handoff and never reports it. The result carries no review URL unless "
|
|
471
|
+
+ "you pass --handoff, which adds a fresh single-use one in stagingPreview.url (keep it private); "
|
|
472
|
+
+ "'staging review' mints one at any time.",
|
|
442
473
|
},
|
|
443
474
|
{
|
|
444
475
|
name: VERB_REDIRECTS_PULL,
|
|
@@ -585,11 +616,19 @@ const VERBS = Object.freeze([
|
|
|
585
616
|
tokens: ["deploy"],
|
|
586
617
|
summary: "Deploy to staging, or promote staging to production.",
|
|
587
618
|
target: true,
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
+ "
|
|
619
|
+
handoffOptions: true,
|
|
620
|
+
note: "deploy --staging does not report a review URL, because the URL is a live single-use credential and this "
|
|
621
|
+
+ "JSON is often saved to logs. Run 'staging review' to mint one, or pass --handoff to get a fresh "
|
|
622
|
+
+ "single-use one in stagingPreview.url (on a managed Docs site too, where it stages settings only): open it "
|
|
623
|
+
+ "once, then switch the site's theme toggle to review both schemes before deploy --production. Without "
|
|
624
|
+
+ "--handoff, stagingPreview.reason is staging.handoff_not_requested and stagingPreview.recovery names the "
|
|
625
|
+
+ "command. If a requested handoff could not be minted, stagingPreview.reason names why and "
|
|
591
626
|
+ "stagingPreview.recovery the next step; 'staging review' mints another. On a Standard site the result also "
|
|
592
|
-
+ "carries the automatic redirect check: routeCheck and redirects (see 'help redirects')."
|
|
627
|
+
+ "carries the automatic redirect check: routeCheck and redirects (see 'help redirects'). The edge can keep "
|
|
628
|
+
+ `serving the previous release for up to ${LIMITS.stagingPropagationCeilingSeconds} seconds after a deployment completes, so a Standard site's `
|
|
629
|
+
+ "deploy --staging waits until staging serves the new release and reports stagingServed: state served, "
|
|
630
|
+
+ "propagating (still the previous release at the bound) or unverified (the change cannot be attributed to this deployment, or the last read did not name the previous release), "
|
|
631
|
+
+ "with the time to wait or the seconds waited.",
|
|
593
632
|
},
|
|
594
633
|
{
|
|
595
634
|
name: VERB_PREVIEW_PAGE,
|
|
@@ -649,20 +688,23 @@ const VERBS = Object.freeze([
|
|
|
649
688
|
capabilities: [CAPABILITY_DEPLOYMENTS],
|
|
650
689
|
tokens: ["staging", "review"],
|
|
651
690
|
summary: "Mint a fresh single-use review handoff for the site's staging host.",
|
|
691
|
+
reviewPath: true,
|
|
652
692
|
note: "The handoff URL is reported only in the final JSON as stagingPreview.url, is single-use, and expires two "
|
|
653
|
-
+ "minutes after it is minted; keep it private.
|
|
654
|
-
+ "
|
|
693
|
+
+ "minutes after it is minted; keep it private. --path <page> opens the review on that page instead of the "
|
|
694
|
+
+ "home page: a page path as 'pull' records it ('about', '/posts/one'), optionally with a query. Open it once, then use the site's theme toggle to review both "
|
|
695
|
+
+ "schemes. deploy --staging and redirects check report no review URL unless you pass --handoff, so this is "
|
|
696
|
+
+ "the usual way to get one, on a managed Docs site too.",
|
|
655
697
|
},
|
|
656
698
|
{
|
|
657
699
|
name: VERB_STATUS,
|
|
658
700
|
surface: SURFACE_DOCS_PRESENTATION,
|
|
659
701
|
// Readiness and the deployment log are Deployments; the image list is
|
|
660
|
-
// Content, as is the
|
|
702
|
+
// Content, as is the site-health read.
|
|
661
703
|
capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
|
|
662
704
|
tokens: ["status"],
|
|
663
705
|
summary:
|
|
664
|
-
"Report the platform authoring switch, CLI release, deployments, readiness, image processing, and
|
|
665
|
-
note: "
|
|
706
|
+
"Report the platform authoring switch, CLI release, deployments, readiness, image processing, and site health.",
|
|
707
|
+
note: "Site health lists the site checks' findings (a rule id, severity, page, and the value found) for the latest editable body of each page."
|
|
666
708
|
+ " Content permission is required; a refused or failed read fails status rather than reporting a clean site.",
|
|
667
709
|
},
|
|
668
710
|
]);
|
|
@@ -729,7 +771,8 @@ credentials, and it expires 24 hours after it is issued.
|
|
|
729
771
|
|
|
730
772
|
Verbs write one JSON result to stdout and human progress to stderr. validate,
|
|
731
773
|
help, whoami, and env are offline and read-only. login, logout, sites, use,
|
|
732
|
-
whoami, and env need no configuration and no site.
|
|
774
|
+
whoami, and env need no configuration and no site. So does changelog, which
|
|
775
|
+
also still runs when this CLI is outdated. The offline help family is
|
|
733
776
|
human-readable by default; add --json for stable reference data. Exit codes: 0
|
|
734
777
|
success, 1 failure, 2 usage fault.
|
|
735
778
|
|
|
@@ -813,6 +856,18 @@ function verbHelp(verb) {
|
|
|
813
856
|
--production Promote the completed staging deployment to production.
|
|
814
857
|
--allow-failed-preview Explicitly override the matching candidate's failed preview.`
|
|
815
858
|
: "";
|
|
859
|
+
const handoffOption = verb.handoffOptions
|
|
860
|
+
? `\n --handoff Also mint a fresh single-use review URL and report it in stagingPreview.url.
|
|
861
|
+
--no-handoff Report no review URL (the default); accepted so scripts can say so explicitly.`
|
|
862
|
+
: "";
|
|
863
|
+
const sinceOption = verb.sinceOption
|
|
864
|
+
? "\n --since <version> List the releases after this one, up to the installed release."
|
|
865
|
+
: "";
|
|
866
|
+
const reviewPathOption = verb.reviewPath
|
|
867
|
+
? "\n --path <page> Open the review on this page instead of the home page, such as 'about' or '/posts/one'."
|
|
868
|
+
: verb.handoffOptions
|
|
869
|
+
? "\n --review-path <page> With --handoff, open the review URL on this page instead of the home page."
|
|
870
|
+
: "";
|
|
816
871
|
const metaOptions = verb.metaOptions
|
|
817
872
|
? `\n --title <text> The page's title.
|
|
818
873
|
--description <text> The page's description for search results and link previews; '' clears it.
|
|
@@ -903,7 +958,7 @@ function verbHelp(verb) {
|
|
|
903
958
|
${verb.summary}
|
|
904
959
|
${boundary}
|
|
905
960
|
|
|
906
|
-
${options}${targetOption}${deliveryTargetOption}${metaOptions}${authorOptions}${jsonOption}${dryRunOption}${planOption}${nameOption}${note}
|
|
961
|
+
${options}${targetOption}${handoffOption}${reviewPathOption}${sinceOption}${deliveryTargetOption}${metaOptions}${authorOptions}${jsonOption}${dryRunOption}${planOption}${nameOption}${note}
|
|
907
962
|
`;
|
|
908
963
|
}
|
|
909
964
|
|
|
@@ -964,6 +1019,7 @@ function parseReferenceArguments(arguments_) {
|
|
|
964
1019
|
|| topic === "preview"
|
|
965
1020
|
|| topic === "theme"
|
|
966
1021
|
|| topic === "appearance"
|
|
1022
|
+
|| topic === "settings"
|
|
967
1023
|
|| topic === "footer"
|
|
968
1024
|
|| topic === "fixture"
|
|
969
1025
|
|| topic === "redirects"
|
|
@@ -1066,6 +1122,8 @@ function referenceResult(parsed) {
|
|
|
1066
1122
|
return { ...result, topic: "presentation", referenceKind: "theme", reference: getThemeReference() };
|
|
1067
1123
|
case "appearance":
|
|
1068
1124
|
return { ...result, topic: "presentation", referenceKind: "appearance", reference: getAppearanceReference() };
|
|
1125
|
+
case "settings":
|
|
1126
|
+
return { ...result, topic: "presentation", referenceKind: "settings", reference: getSettingsReference() };
|
|
1069
1127
|
case "footer":
|
|
1070
1128
|
return { ...result, topic: "presentation", referenceKind: "footer", reference: getFooterReference() };
|
|
1071
1129
|
default:
|
|
@@ -1245,6 +1303,9 @@ function parseArguments(arguments_) {
|
|
|
1245
1303
|
let propagationWaitSeconds;
|
|
1246
1304
|
let browser;
|
|
1247
1305
|
let allowFailedPreview = false;
|
|
1306
|
+
let handoff;
|
|
1307
|
+
let reviewPath;
|
|
1308
|
+
let since;
|
|
1248
1309
|
let dryRun = false;
|
|
1249
1310
|
let json = false;
|
|
1250
1311
|
let keyName;
|
|
@@ -1310,6 +1371,42 @@ function parseArguments(arguments_) {
|
|
|
1310
1371
|
allowFailedPreview = true;
|
|
1311
1372
|
continue;
|
|
1312
1373
|
}
|
|
1374
|
+
if (verb.handoffOptions && (argument === "--handoff" || argument === "--no-handoff")) {
|
|
1375
|
+
if (handoff !== undefined) {
|
|
1376
|
+
throw usageError(
|
|
1377
|
+
handoff === (argument === "--handoff") ? "cli.duplicate_option" : "cli.handoff_conflict",
|
|
1378
|
+
handoff === (argument === "--handoff")
|
|
1379
|
+
? `${argument} may be supplied only once.`
|
|
1380
|
+
: "--handoff and --no-handoff cannot be used together.",
|
|
1381
|
+
);
|
|
1382
|
+
}
|
|
1383
|
+
handoff = argument === "--handoff";
|
|
1384
|
+
continue;
|
|
1385
|
+
}
|
|
1386
|
+
if ((verb.reviewPath && argument === "--path") || (verb.handoffOptions && argument === "--review-path")) {
|
|
1387
|
+
if (reviewPath !== undefined) {
|
|
1388
|
+
throw usageError("cli.duplicate_option", `${argument} may be supplied only once.`);
|
|
1389
|
+
}
|
|
1390
|
+
// An option-like value would otherwise become the page '/--json' and mint a handoff to a 404.
|
|
1391
|
+
const candidate = rest[index + 1];
|
|
1392
|
+
reviewPath = normalizeStagingReviewPath(typeof candidate === "string" && candidate.startsWith("--") ? "" : candidate);
|
|
1393
|
+
index += 1;
|
|
1394
|
+
continue;
|
|
1395
|
+
}
|
|
1396
|
+
if (verb.sinceOption && argument === "--since") {
|
|
1397
|
+
if (since !== undefined) throw usageError("cli.duplicate_option", "--since may be supplied only once.");
|
|
1398
|
+
const candidate = rest[index + 1];
|
|
1399
|
+
if (parseCliVersion(candidate) === undefined) {
|
|
1400
|
+
throw usageError(
|
|
1401
|
+
"changelog.since_invalid",
|
|
1402
|
+
"--since requires a version such as 0.20.0 (the version you upgraded from).",
|
|
1403
|
+
{ field: "since" },
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
since = candidate;
|
|
1407
|
+
index += 1;
|
|
1408
|
+
continue;
|
|
1409
|
+
}
|
|
1313
1410
|
if (verb.dryRun && argument === "--dry-run") {
|
|
1314
1411
|
if (dryRun) throw usageError("cli.duplicate_option", "--dry-run may be supplied only once.");
|
|
1315
1412
|
dryRun = true;
|
|
@@ -1390,6 +1487,16 @@ function parseArguments(arguments_) {
|
|
|
1390
1487
|
`${verb.tokens.join(" ")} requires exactly one of --staging or --production.`,
|
|
1391
1488
|
);
|
|
1392
1489
|
}
|
|
1490
|
+
if (verb.handoffOptions && reviewPath !== undefined && handoff !== true) {
|
|
1491
|
+
throw usageError(
|
|
1492
|
+
"cli.review_path_requires_handoff",
|
|
1493
|
+
"--review-path chooses the page a --handoff review URL opens on; pass --handoff with it, or use 'staging review --path'.",
|
|
1494
|
+
{ field: "reviewPath" },
|
|
1495
|
+
);
|
|
1496
|
+
}
|
|
1497
|
+
if (handoff === true && verb.target && deployTarget === DEPLOY_TARGET_PRODUCTION) {
|
|
1498
|
+
throw usageError("cli.handoff_target", "--handoff applies only to deploy --staging.", { field: "handoff" });
|
|
1499
|
+
}
|
|
1393
1500
|
if (verb.positionals === "fixturePath" && (positionals.length > 1 || (init && positionals.length !== 1))) {
|
|
1394
1501
|
throw usageError(
|
|
1395
1502
|
"validate.fixture_path_invalid",
|
|
@@ -1503,6 +1610,9 @@ function parseArguments(arguments_) {
|
|
|
1503
1610
|
quiet,
|
|
1504
1611
|
deployTarget,
|
|
1505
1612
|
allowFailedPreview,
|
|
1613
|
+
...(handoff === undefined ? {} : { handoff }),
|
|
1614
|
+
...(reviewPath === undefined ? {} : { reviewPath }),
|
|
1615
|
+
...(since === undefined ? {} : { since }),
|
|
1506
1616
|
...(deliveryUrl === undefined ? {} : { deliveryUrl }),
|
|
1507
1617
|
...(propagationWaitSeconds === undefined ? {} : { propagationWaitSeconds }),
|
|
1508
1618
|
...(browser === undefined ? {} : { browser }),
|
|
@@ -1595,6 +1705,9 @@ export async function runCli({
|
|
|
1595
1705
|
configPath: parsed.configPath,
|
|
1596
1706
|
deployTarget: parsed.deployTarget,
|
|
1597
1707
|
allowFailedPreview: parsed.allowFailedPreview,
|
|
1708
|
+
...(parsed.handoff === undefined ? {} : { handoff: parsed.handoff }),
|
|
1709
|
+
...(parsed.reviewPath === undefined ? {} : { reviewPath: parsed.reviewPath }),
|
|
1710
|
+
...(parsed.since === undefined ? {} : { since: parsed.since }),
|
|
1598
1711
|
init: parsed.init,
|
|
1599
1712
|
quiet: parsed.quiet,
|
|
1600
1713
|
dryRun: parsed.dryRun,
|
package/src/constants.js
CHANGED
|
@@ -106,6 +106,12 @@ export const VERB_STAGING_REVIEW = "staging review";
|
|
|
106
106
|
export const VERB_SITES = "sites";
|
|
107
107
|
export const VERB_USE = "use";
|
|
108
108
|
export const VERB_WHOAMI = "whoami";
|
|
109
|
+
export const VERB_CHANGELOG = "changelog";
|
|
110
|
+
|
|
111
|
+
/** The command that lists what changed since a version, for the CLI to name after an upgrade (TR01225). */
|
|
112
|
+
export function changelogCommandSince(version) {
|
|
113
|
+
return `${CLI_BINARY_NAME} ${VERB_CHANGELOG} --since ${version}`;
|
|
114
|
+
}
|
|
109
115
|
export const VERB_PLAN = "plan";
|
|
110
116
|
export const VERB_APPLY = "apply";
|
|
111
117
|
export const VERB_PLACES_SEARCH = "places search";
|
|
@@ -256,6 +262,19 @@ export const LIMITS = Object.freeze({
|
|
|
256
262
|
deploymentMilliseconds: 30 * 60_000,
|
|
257
263
|
previewMilliseconds: 5 * 60_000,
|
|
258
264
|
pollIntervalMilliseconds: 2_000,
|
|
265
|
+
// How long after a staging deployment completes the edge can still serve the
|
|
266
|
+
// previous release (TR01236): up to a minute for Workers KV to propagate the
|
|
267
|
+
// route, plus the Worker's 30-second KV read cache and 30-second isolate cache
|
|
268
|
+
// (docs/operations/published-site-worker-runbook.md, "Route resolution
|
|
269
|
+
// freshness budget"). The wait is longer than the ceiling so one slow read does
|
|
270
|
+
// not turn a propagating release into a false warning.
|
|
271
|
+
stagingPropagationCeilingSeconds: 120,
|
|
272
|
+
stagingServedMilliseconds: 180_000,
|
|
273
|
+
stagingServedPollMilliseconds: 3_000,
|
|
274
|
+
// Two reads in a row, because each read lands on one cached copy of the route.
|
|
275
|
+
stagingServedConfirmations: 2,
|
|
276
|
+
// The review notice sits at the end of the page, so the whole document is read.
|
|
277
|
+
stagingServedPageBytes: 4 * 1024 * 1024,
|
|
259
278
|
requestAttempts: 4,
|
|
260
279
|
uploadAttempts: 4,
|
|
261
280
|
diagnosticScalars: 2_000,
|
|
@@ -352,11 +352,14 @@ const SPACER = Object.freeze({
|
|
|
352
352
|
|
|
353
353
|
/**
|
|
354
354
|
* A form defined under Site → Forms (or with `forms push`), placed by key. The
|
|
355
|
-
*
|
|
356
|
-
*
|
|
355
|
+
* workspace names a form by its key so the same files push to any site; the site
|
|
356
|
+
* stores the form's id, and `pages push` and `pages pull` translate between the
|
|
357
|
+
* two (TR01216). The key is checked against the site's forms when the page is
|
|
358
|
+
* pushed, not here: this table only knows the shape.
|
|
357
359
|
*/
|
|
358
360
|
const FORM = Object.freeze({
|
|
359
|
-
|
|
361
|
+
// Required: a placement with no form chosen can never publish, so it is refused here, not at deploy.
|
|
362
|
+
formKey: str({ required: true, nonWhitespace: true }),
|
|
360
363
|
});
|
|
361
364
|
|
|
362
365
|
function deepFreeze(value) {
|
|
@@ -648,9 +651,9 @@ const COMPONENT_DEFINITIONS = Object.freeze({
|
|
|
648
651
|
FORM,
|
|
649
652
|
"Form",
|
|
650
653
|
"A form from Site → Forms that visitors fill in and submit.",
|
|
651
|
-
{
|
|
654
|
+
{},
|
|
652
655
|
[
|
|
653
|
-
"Set formKey to the key of an existing form ('forms push' creates it); an unknown or archived key
|
|
656
|
+
"Set formKey to the key of an existing form ('forms push' creates it); 'pages push' refuses an unknown or archived key, and the site stores the form's id, not the key.",
|
|
654
657
|
"The button text and what happens after a response is sent belong to the form, not the page: set submit_label and after_submit in the form's file (see 'help forms').",
|
|
655
658
|
"The form's field labels are its accessible names; write each so it makes sense out of context.",
|
|
656
659
|
],
|
package/src/errors.js
CHANGED
|
@@ -130,6 +130,8 @@ export class SiteAuthoringError extends Error {
|
|
|
130
130
|
this.previewDiagnostic = normalizePreviewDiagnostic(options.previewDiagnostic);
|
|
131
131
|
this.field = typeof options.field === "string" ? sanitizeDiagnostic(options.field, "") : undefined;
|
|
132
132
|
this.status = typeof options.status === "string" ? sanitizeDiagnostic(options.status, "") : undefined;
|
|
133
|
+
// The site check rule (shared/site-check-rules.json) this refusal comes from, when it comes from one.
|
|
134
|
+
this.rule = typeof options.rule === "string" && /^[a-z0-9_.]+$/u.test(options.rule) ? options.rule : undefined;
|
|
133
135
|
this.alternatives = Array.isArray(options.alternatives)
|
|
134
136
|
? options.alternatives
|
|
135
137
|
.filter((value) => typeof value === "string")
|
|
@@ -191,6 +193,7 @@ export class SiteAuthoringError extends Error {
|
|
|
191
193
|
...(typeof problem.file === "string" ? { file: sanitizeDiagnostic(problem.file, "") } : {}),
|
|
192
194
|
code: /^[a-z0-9_.-]+$/u.test(problem.code) ? problem.code : "site.failed",
|
|
193
195
|
...(typeof problem.field === "string" ? { field: sanitizeDiagnostic(problem.field, "") } : {}),
|
|
196
|
+
...(typeof problem.rule === "string" && /^[a-z0-9_.]+$/u.test(problem.rule) ? { rule: problem.rule } : {}),
|
|
194
197
|
message: sanitizeDiagnostic(problem.message),
|
|
195
198
|
}));
|
|
196
199
|
this.problemCount = list.length;
|
package/src/footer-contract.js
CHANGED
|
@@ -278,11 +278,13 @@ function richText(value, path, maximumLength, maximumParagraphs, knownPageResour
|
|
|
278
278
|
if (text.length === 0) continue;
|
|
279
279
|
totalLength += text.length;
|
|
280
280
|
totalRuns += 1;
|
|
281
|
+
// A style flag that is off is left out, so a run carries only what it sets and a hand-written
|
|
282
|
+
// file matches what pull writes (TR01232).
|
|
281
283
|
normalizedRuns.push({
|
|
282
284
|
text,
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
285
|
+
...(boolean(run.bold, false, `${runPath}.bold`) ? { bold: true } : {}),
|
|
286
|
+
...(boolean(run.italic, false, `${runPath}.italic`) ? { italic: true } : {}),
|
|
287
|
+
...(boolean(run.underline, false, `${runPath}.underline`) ? { underline: true } : {}),
|
|
286
288
|
...(run.link === undefined || run.link === null
|
|
287
289
|
? {}
|
|
288
290
|
: { link: inlineLink(run.link, `${runPath}.link`, knownPageResourceIds, tolerateStoredValues) }),
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { listSiteForms } from "./forms-api.js";
|
|
2
|
+
import { contentDocuments, parseComponentData } from "./typed-pages.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where the workspace and the site spell a placed form differently (TR01216).
|
|
6
|
+
*
|
|
7
|
+
* A workspace names a form by its key, as `forms/<key>.json` and the Markdown
|
|
8
|
+
* `form` fence do, so the same workspace can be pushed to a fresh site whose
|
|
9
|
+
* ids all differ. A page stored on the site names the form by its id. `pages
|
|
10
|
+
* push` turns keys into ids on the way out and `pages pull` turns ids back
|
|
11
|
+
* into keys, the way a form's `page_path` and `page_resource_id` already
|
|
12
|
+
* translate. The translation is block-scoped: a `formKey` anywhere but a `form`
|
|
13
|
+
* component block is author prose.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const FORM_COMPONENT = "form";
|
|
17
|
+
|
|
18
|
+
/** Each `form` component block in the document's content documents, with its parsed data. */
|
|
19
|
+
function formBlocks(document_) {
|
|
20
|
+
const found = [];
|
|
21
|
+
const visit = (value) => {
|
|
22
|
+
if (Array.isArray(value)) {
|
|
23
|
+
value.forEach(visit);
|
|
24
|
+
} else if (value !== null && typeof value === "object") {
|
|
25
|
+
if (value.type === "componentBlock" && value.attrs?.componentType === FORM_COMPONENT) {
|
|
26
|
+
const data = typeof value.attrs.componentData === "string"
|
|
27
|
+
? parseComponentData(value.attrs.componentData)
|
|
28
|
+
: undefined;
|
|
29
|
+
if (data !== null && typeof data === "object" && !Array.isArray(data)) found.push({ attrs: value.attrs, data });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
Object.values(value).forEach(visit);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
for (const { doc } of contentDocuments(document_)) visit(doc);
|
|
36
|
+
return found;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The distinct form keys a workspace document places, in document order. */
|
|
40
|
+
export function placedFormKeys(document_) {
|
|
41
|
+
const keys = [];
|
|
42
|
+
for (const { data } of formBlocks(document_)) {
|
|
43
|
+
if (typeof data.formKey === "string" && data.formKey !== "" && !keys.includes(data.formKey)) keys.push(data.formKey);
|
|
44
|
+
}
|
|
45
|
+
return keys;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Whether a document read from the site places any form by id. */
|
|
49
|
+
export function placesFormIds(document_) {
|
|
50
|
+
return formBlocks(document_).some(({ data }) => typeof data.formId === "string");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A copy of the document with every placed form named by id, for the wire. A key
|
|
55
|
+
* the index does not know is left as it is: `placedFormProblems` has already
|
|
56
|
+
* refused the push for it, so nothing sends it.
|
|
57
|
+
*/
|
|
58
|
+
export function withFormIds(document_, idByKey) {
|
|
59
|
+
return translate(document_, (data) => {
|
|
60
|
+
const id = typeof data.formKey === "string" ? idByKey.get(data.formKey) : undefined;
|
|
61
|
+
if (id === undefined) return undefined;
|
|
62
|
+
const rest = { ...data };
|
|
63
|
+
delete rest.formKey;
|
|
64
|
+
return { ...rest, formId: id };
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A copy of a document read from the site with every placed form named by key,
|
|
70
|
+
* for the workspace. An id the site no longer has stays an id: pull must not hide
|
|
71
|
+
* what the site holds, and a push of that file is refused until the author picks a form.
|
|
72
|
+
*/
|
|
73
|
+
export function withFormKeys(document_, keyById) {
|
|
74
|
+
return translate(document_, (data) => {
|
|
75
|
+
const key = typeof data.formId === "string" ? keyById.get(data.formId) : undefined;
|
|
76
|
+
if (key === undefined) return undefined;
|
|
77
|
+
const rest = { ...data };
|
|
78
|
+
delete rest.formId;
|
|
79
|
+
return { ...rest, formKey: key };
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function translate(document_, convert) {
|
|
84
|
+
const copy = structuredClone(document_);
|
|
85
|
+
for (const { attrs, data } of formBlocks(copy)) {
|
|
86
|
+
const converted = convert(data);
|
|
87
|
+
if (converted !== undefined) attrs.componentData = JSON.stringify(converted);
|
|
88
|
+
}
|
|
89
|
+
return copy;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The site's forms by key and by id, archived ones marked. One request serves a whole push or pull. */
|
|
93
|
+
export async function readFormIndex(client, siteId) {
|
|
94
|
+
const idByKey = new Map();
|
|
95
|
+
const keyById = new Map();
|
|
96
|
+
const archivedKeys = new Set();
|
|
97
|
+
for (const form of await listSiteForms(client, siteId)) {
|
|
98
|
+
keyById.set(form.id, form.key);
|
|
99
|
+
if (form.archived) archivedKeys.add(form.key);
|
|
100
|
+
else idByKey.set(form.key, form.id);
|
|
101
|
+
}
|
|
102
|
+
return { idByKey, keyById, archivedKeys };
|
|
103
|
+
}
|
package/src/forms-workspace.js
CHANGED
|
@@ -49,9 +49,33 @@ export async function readWorkspaceForms(workspaceDir, keys, validationOptions =
|
|
|
49
49
|
const document_ = await readWorkspaceJson(workspaceDir, file, FORM_LIMITS.fileBytes);
|
|
50
50
|
forms.push({ key, file, document: validateFormDocument(document_, key, validationOptions) });
|
|
51
51
|
}
|
|
52
|
+
requireDistinctNames(forms);
|
|
52
53
|
return forms;
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/** A form name as the site compares it: trimmed, ignoring case (TR01216). */
|
|
57
|
+
export function comparableFormName(name) {
|
|
58
|
+
return String(name).trim().toLowerCase();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Names are unique per site, so two files in one push cannot share one. */
|
|
62
|
+
function requireDistinctNames(forms) {
|
|
63
|
+
const seen = new Map();
|
|
64
|
+
for (const form of forms) {
|
|
65
|
+
const name = comparableFormName(form.document.name);
|
|
66
|
+
const other = seen.get(name);
|
|
67
|
+
if (other !== undefined) {
|
|
68
|
+
throw new SiteAuthoringError(
|
|
69
|
+
"forms.name_duplicate",
|
|
70
|
+
`${form.file} and ${other} both name the form '${form.document.name}'. A form name is unique on a site, `
|
|
71
|
+
+ "ignoring case; rename one of them.",
|
|
72
|
+
{ field: `${form.file}:name` },
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
seen.set(name, form.file);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
55
79
|
/**
|
|
56
80
|
* The forms baseline in the pull manifest (TR01061).
|
|
57
81
|
*
|
package/src/index.js
CHANGED
package/src/output.js
CHANGED
|
@@ -3,12 +3,14 @@ import { constants as fsConstants } from "node:fs";
|
|
|
3
3
|
import { lstat, open } from "node:fs/promises";
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
+
changelogCommandSince,
|
|
6
7
|
CLI_BINARY_NAME,
|
|
7
8
|
CLI_NAME,
|
|
8
9
|
CLI_VERSION,
|
|
9
10
|
GITHUB_OUTPUT_KEY_PREFIX,
|
|
10
11
|
GITHUB_OUTPUT_RESULT_KEY,
|
|
11
12
|
LIMITS,
|
|
13
|
+
REFUSAL_CLI_OUTDATED,
|
|
12
14
|
REFUSAL_UNCLASSIFIED,
|
|
13
15
|
RESULT_SCHEMA_VERSION,
|
|
14
16
|
} from "./constants.js";
|
|
@@ -37,6 +39,7 @@ export function failureResult(error) {
|
|
|
37
39
|
error: { code: error.code },
|
|
38
40
|
};
|
|
39
41
|
if (error.field) result.error.field = error.field;
|
|
42
|
+
if (error.rule) result.error.rule = error.rule;
|
|
40
43
|
if (error.details?.length) result.error.details = error.details;
|
|
41
44
|
if (error.status) result.error.status = error.status;
|
|
42
45
|
if (Array.isArray(error.alternatives) && error.alternatives.length > 0) {
|
|
@@ -67,6 +70,12 @@ export function failureResult(error) {
|
|
|
67
70
|
if (previewRecovery) result.error.preview = previewRecovery;
|
|
68
71
|
const refusal = typeof error.refusalKind === "function" ? error.refusalKind() : undefined;
|
|
69
72
|
if (typeof refusal === "string" && refusal !== REFUSAL_UNCLASSIFIED) result.error.refusal = refusal;
|
|
73
|
+
if (refusal === REFUSAL_CLI_OUTDATED) {
|
|
74
|
+
const latestVersion = typeof error.latestCliVersion === "function" ? error.latestCliVersion() : error.latestVersion;
|
|
75
|
+
if (typeof latestVersion === "string") result.error.latestVersion = latestVersion;
|
|
76
|
+
// What to run once the upgrade is done, so the agent need not diff the help topics.
|
|
77
|
+
result.error.changelogCommand = changelogCommandSince(CLI_VERSION);
|
|
78
|
+
}
|
|
70
79
|
return result;
|
|
71
80
|
}
|
|
72
81
|
|