@taprootio/site-authoring 0.22.4 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -342,6 +342,11 @@ each step names the reference it relies on.
342
342
  `lightLogoId`, `darkLogoId` and `brand.faviconId` from the returned ids. There
343
343
  is no recolor, crop or SVG upload command.
344
344
  5. **Validate, preview, then push.** `validate` checks the whole workspace offline;
345
+ `theme resolve` prints, also offline, the colors Espalier compiles from the
346
+ theme for each scheme and context with the contrast of every ink-on-surface
347
+ pair and a flag for each one under WCAG AA (add `--json` for the stable
348
+ result, `--scheme` and `--context` to narrow it, `--site` for the site's
349
+ stored theme);
345
350
  `theme push --dry-run` reads the site and lists the JSON paths at which
346
351
  each settings file differs from it, and whether the baseline `pull`
347
352
  recorded is still current; `theme push` saves both themes, the appearance
@@ -358,7 +363,9 @@ each step names the reference it relies on.
358
363
  6. **Review on staging in both schemes.** `staging review` (or
359
364
  `deploy --staging --handoff`) returns a single-use handoff in
360
365
  `stagingPreview.url`, on a managed Docs site too; open it once and use the
361
- site's theme toggle. On a Standard site `deploy --staging` first waits until
366
+ site's theme toggle; the review session lasts five minutes (see
367
+ [How long a staging review lasts](#staging-verification-and-deployment-evidence)).
368
+ On a Standard site `deploy --staging` first waits until
362
369
  staging serves the new release (see `stagingServed`). No page draft is
363
370
  needed; `preview page` renders one draft and is a different tool.
364
371
  7. **Promote only after review.** `deploy --production`.
@@ -512,6 +519,26 @@ closed with `pages.push_conflict` when a page's stored-state revision has
512
519
  moved since this workspace last reconciled with it, naming both revisions;
513
520
  `pull` first, or push after a refused pull has shown you the site's version.
514
521
 
522
+ `pages delete <page-path> [--dry-run]` deletes one tracked page and removes its
523
+ source file and manifest entry, so the next
524
+ `pull` or whole-workspace push does not bring it back. It runs the same
525
+ revision fence as `pages push` first (`pages.push_conflict`, nothing deleted),
526
+ and refuses a page that navigation, the footer or a form's after-submit page
527
+ still uses (`pages.delete_referenced`, naming each use): remove those first,
528
+ then delete. A redirect that lands on the page is listed in `redirectsToPage`
529
+ and does not stop the delete; links from other pages are checked afterwards by
530
+ `validate` (`link.page_missing`). A page the live site serves is not taken off
531
+ it by the delete: the site stages the removal (`effect: removalStaged`) and the
532
+ page stays live until a deployment promotes it, so run `deploy --staging`,
533
+ review the staging site, then `deploy`. A page never published is removed
534
+ outright. The home page, 404, generated, legal and integration-managed pages
535
+ are refused. `--dry-run` runs every check and sends nothing. A page the site
536
+ already deleted or staged for removal (an interrupted run) is only cleaned from
537
+ the workspace and reports `effect: alreadyDeleted` or `alreadyRemovalStaged`.
538
+ `effect` is the answer: `deleted` is true only when the page is gone from the site
539
+ now, so a staged removal reports `deleted: false` and `removalStaged: true`. A
540
+ staged removal can be undone only in the app, with Keep page on the Pages list.
541
+
515
542
  `places search <name and city>` asks Google Places for the place a review
516
543
  names and reports predictions with a session token; `places select
517
544
  <googlePlaceId> <sessionToken>` records the match as a Taproot place and
@@ -524,7 +551,9 @@ they reference and the images the settings name, the footer and the pages it
524
551
  links, the theme, navigation — and orders what is left
525
552
  to send: media upload, pages push, footer push (theme push refuses unsaved
526
553
  footer content), theme push, nav push, each marked ready, blocked, or nothing
527
- to do. It writes nothing and reports a `planHash`. `apply --plan <planHash>`
554
+ to do. Navigation is also checked against the revision `pull` recorded: a tree that moved is a blocked
555
+ step (`nav.concurrent_modification`), and an unchanged tree under a stale revision is listed ready with
556
+ `settlesLostPush` so `apply` settles a push whose answer was lost. It writes nothing and reports a `planHash`. `apply --plan <planHash>`
528
557
  plans again, refuses with `apply.plan_stale` if the workspace or the site
529
558
  moved, and otherwise runs the steps through the ordinary push verbs. While
530
559
  they run, a workspace file read differently from the plan (or not read by it)
@@ -550,7 +579,10 @@ component (`help component video`), and
550
579
  `pages push` refuses a page that places a video the site does not have. `nav.json` and the footer
551
580
  document leave defaults out (no empty `externalUrl` or `children` on a navigation item, no `bold: false`
552
581
  on a footer text run); `pull`, `nav push` and `footer push` all write that form and accept either, so a
553
- pull after a push shows only changes you made. `nav push`, `theme push`, and `footer push` replace the whole
582
+ pull after a push shows only changes you made. `nav push` is fenced by the navigation revision `pull` recorded
583
+ (`navigation.revision` in the manifest): a tree edited in the app since is refused with `nav.concurrent_modification`
584
+ and nothing is written, and a manifest without a revision is refused with `nav.pull_required`; keep a copy of
585
+ `nav.json`, run `pull`, re-apply the edits and push again. `nav push`, `theme push`, and `footer push` replace the whole
554
586
  navigation tree, the complete light/dark theme pair with its appearance
555
587
  settings, and the closed footer document. `pull` writes each scheme's
556
588
  complete effective theme — the stored theme resolved over the same defaults
@@ -873,9 +905,26 @@ minted **after** checks; without it the check still authenticates with its own
873
905
  handoff and reports none. Opening it consumes it and establishes a five-minute staging cookie.
874
906
  Minting again replaces the same authority's previous handoff and cookie. Current
875
907
  site, host and credential permission are rechecked; key revocation stops access.
908
+
876
909
  Treat the URL as secret: it is returned only in final JSON, omitted from progress
877
910
  and `GITHUB_OUTPUT`. The same Actions exclusion applies to `preview page` URLs.
878
911
 
912
+ **How long a staging review lasts.** The handoff works once and expires two
913
+ minutes after it is minted. Opening it starts a five-minute review session in
914
+ that browser, and the session never renews. When it ends, the staging site
915
+ redirects to the Taproot sign-in. A reviewer whose Taproot account can preview
916
+ the site signs in and is sent straight back to the page with a fresh session; a
917
+ reviewer without one needs a fresh link from `taproot-site staging review`,
918
+ because a link minted with a key has no Taproot session behind it. Minting another handoff with the same
919
+ key for the same site, which `staging review`, `deploy --staging` (on a Standard
920
+ site, or with `--handoff`), `redirects check` and `delivery check --staging` do,
921
+ ends the earlier link and any session opened from it. That browser then shows "Staging preview
922
+ unavailable" rather than the sign-in redirect, so mint the link last, just
923
+ before the reviewer opens it. The staging site does not show a separate "link expired"
924
+ page for a missing cookie: the staging host cannot tell an expired link from a first visit, or see
925
+ whether the visitor is signed in to Taproot, and the sign-in redirect is what
926
+ renews a signed-in owner's review.
927
+
879
928
  Deployment results carry `phaseTimings`. New deployments retain up to 32 durable
880
929
  server-observed phase transitions, including repeated phases on retry. Durations
881
930
  are calculated only between recorded transitions. `truncated` marks missing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.22.4",
3
+ "version": "0.23.1",
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",
package/src/api.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  EXTERNAL_WRITES_SETTING_KEY,
11
11
  EXTERNAL_WRITES_SETTING_LOCATION,
12
12
  GRPC_NOT_FOUND,
13
+ HEX_DIGEST,
13
14
  HTTP_NOT_FOUND,
14
15
  LIMITS,
15
16
  REFUSAL_CAPABILITY_MISSING,
@@ -509,6 +510,11 @@ export async function withRefusalGuidance(onProgress, action, work) {
509
510
  }
510
511
  }
511
512
 
513
+ /** A page the author no longer has: deleted, or deleted and waiting for a deployment to take it off the live site. */
514
+ export function isGonePage(summary) {
515
+ return summary.status === PAGE_STATUS_DELETED || summary.removalStaged === true;
516
+ }
517
+
512
518
  export function normalizePageSummary(value) {
513
519
  const summary = requireObject(value, "api.page_contract", "page summary");
514
520
  return {
@@ -521,7 +527,11 @@ export function normalizePageSummary(value) {
521
527
  templateType: typeof summary.templateType === "string" ? summary.templateType : "TEMPLATE_TYPE_UNKNOWN",
522
528
  status: enumValue(summary.status, PAGE_STATUSES, PAGE_STATUS_UNKNOWN, "api.page_status", "page status"),
523
529
  hasDraft: summary.hasDraft === true,
530
+ // The first publication, which outlives the status: a published page being edited reports a draft status.
531
+ datePublished: typeof summary.datePublished === "string" ? summary.datePublished : "",
524
532
  isGenerated: summary.isGenerated === true,
533
+ // A published page whose deletion waits for a deployment: it is still live, and the author has deleted it.
534
+ removalStaged: summary.removalStaged === true,
525
535
  // Absent against a Taproot that predates the revision contract, and absent
526
536
  // on the listings that never resolve stored body state. Both mean the same
527
537
  // thing to a caller — "this read cannot say what version you are looking
@@ -579,6 +589,15 @@ export async function createPage(client, body) {
579
589
  return normalizePageSummary(await client.request("v1/pages", { method: "POST", body }));
580
590
  }
581
591
 
592
+ /**
593
+ * Deletes one page. A page the live site does not serve is removed at once (a path it published stays reserved);
594
+ * one it serves is staged for removal and stays live until a deployment takes it off (TR01278). The answer says
595
+ * which: `removalStaged`.
596
+ */
597
+ export async function deletePage(client, pageId) {
598
+ return normalizePageSummary(await client.request(`v1/pages/${encodeURIComponent(pageId)}`, { method: "DELETE" }));
599
+ }
600
+
582
601
  export async function updatePage(client, pageId, body) {
583
602
  return normalizePageSummary(
584
603
  await client.request(`v1/pages/${encodeURIComponent(pageId)}`, { method: "PATCH", body }),
@@ -664,21 +683,29 @@ export async function publishDrafts(client, pageIds) {
664
683
  */
665
684
  export async function getNavigation(client, siteId) {
666
685
  const search = query([["environment", SITE_ENVIRONMENT_DRAFT]]);
667
- const response = requireObject(
668
- await client.request(sitePath(siteId, `navigation${search}`)),
669
- "api.navigation_contract",
670
- "site navigation",
671
- );
672
- return Array.isArray(response.navItems) ? response.navItems : [];
686
+ return requireNavigation(await client.request(sitePath(siteId, `navigation${search}`)));
673
687
  }
674
688
 
675
- export async function saveNavigation(client, siteId, navItems) {
676
- const response = requireObject(
677
- await client.request(sitePath(siteId, "navigation"), { method: "PUT", body: { siteId, navItems } }),
678
- "api.navigation_contract",
679
- "site navigation",
689
+ /**
690
+ * Replaces the draft tree. `expectedRevision` is the revision the tree was read
691
+ * at; the site refuses the write rather than erasing an item added since.
692
+ */
693
+ export async function saveNavigation(client, siteId, navItems, expectedRevision) {
694
+ return requireNavigation(
695
+ await client.request(sitePath(siteId, "navigation"), {
696
+ method: "PUT",
697
+ body: { siteId, navItems, expectedRevision },
698
+ }),
680
699
  );
681
- return Array.isArray(response.navItems) ? response.navItems : [];
700
+ }
701
+
702
+ /** A navigation answer: the tree and the revision a later save must present. */
703
+ function requireNavigation(value) {
704
+ const response = requireObject(value, "api.navigation_contract", "site navigation");
705
+ return {
706
+ navItems: Array.isArray(response.navItems) ? response.navItems : [],
707
+ revision: typeof response.revision === "string" ? response.revision : "",
708
+ };
682
709
  }
683
710
 
684
711
  /**
@@ -815,7 +842,7 @@ export async function saveSiteFooterSettings(client, siteId, footerSettings, exp
815
842
  * is a containment check on a server string that reaches the manifest, not a
816
843
  * claim about how the value is computed.
817
844
  */
818
- export const PRESENTATION_REVISION = /^[0-9a-f]{64}$/u;
845
+ export const PRESENTATION_REVISION = HEX_DIGEST;
819
846
 
820
847
  /**
821
848
  * Reads the site's draft presentation — the four theme-gated settings groups
@@ -1,5 +1,34 @@
1
1
  {
2
2
  "entries": [
3
+ {
4
+ "version": "0.23.1",
5
+ "note": "Image uploads support Taproot’s single-use upload gateway and send an operation key with each upload request.",
6
+ "behavior": [
7
+ "media upload sends an operation key when requesting an image upload. It accepts a single-use gateway capability only for the configured Taproot API origin and image-upload path, and never automatically retries that capability after a transport failure or retryable response. Direct signed object-store uploads retain their existing retry behavior."
8
+ ],
9
+ "help": []
10
+ },
11
+ {
12
+ "version": "0.23.0",
13
+ "note": "theme resolve prints the colors Espalier compiles from the theme, per scheme and context, with their contrast ratios, offline; the help and the staging review result say how long a review lasts and what ends it; pages delete with a dry run; nav push fenced by a navigation revision.",
14
+ "behavior": [
15
+ "New verb: taproot-site theme resolve [--scheme light|dark] [--context <name>] [--site] [--json]. It reads the pulled settings/taproot-styles.json with no credential and no network (--site reads the site's stored theme instead and needs a sign-in), compiles it through Espalier's own pipeline, and prints for the root page and every context in each scheme every semantic color as oklch and hex, whether it is compiled from the roles or pinned (origin compiled, pinned or inherited_pin), and the WCAG contrast of every ink-on-surface pair Espalier defines beside the APCA Lc and its target.",
16
+ "A pair under WCAG AA (4.5:1 text, 3:1 large text and UI) is listed in flags with the stable code theme.contrast_text_below_aa and the theme field to edit; flags never refuse, so the exit code is 0. An invalid or incomplete theme is refused with the code theme push and validate use (theme.settings_missing lists every absent path). Without --json the output is a table; with it, the stable result (schemes[].surfaces[].tokens and pairs, contrast, flags). A result too large for the output bound names what it left out in omittedSurfaces (items, count, truncated); --scheme and --context fetch it.",
17
+ "The help reference version is 41.",
18
+ "staging review, deploy and the delivery, preview, redirects and walkthrough help now state that a review handoff works once and expires after two minutes, that opening it starts a five-minute review session that never renews, that the staging site then redirects to the Taproot sign-in (a reviewer without a Taproot account that can preview the site needs a fresh link), and that minting another handoff with the same key (staging review, deploy --staging, redirects check, delivery check --staging) ends the earlier link and any session opened from it. The same text is in stagingPreview.review for staging review and for deploy --staging --handoff on a managed Docs site. No command or refusal changed.",
19
+ "Correction to 0.22.4: analyticsConsentPrompt is read-only in the CLI. A pull records it in the publishing preferences, but no push verb writes it (theme push writes only the appearance scalars, both themes and the footer colours); switch it on or off in the app under Settings, then deploy. help settings has said so since 0.22.4.",
20
+ "pages delete <path> [--dry-run] deletes one tracked page and removes its source and manifest entry, so a test page no longer needs the app. It runs the same revision fence as pages push first (pages.push_conflict, nothing deleted), refuses a page navigation, the footer or a form's after-submit page still uses (pages.delete_referenced, naming each use), reports redirects that land on the page in redirectsToPage, and stages the removal of a page the live site serves (effect removalStaged): the page stays live until a deployment promotes the removal, so deploy --staging then deploy finish the job, and pull, push, plan and approve treat it as deleted. The home page, 404, generated, legal and integration-managed pages are refused. A page the site already deleted or staged for removal is only cleaned from the workspace (effect alreadyDeleted or alreadyRemovalStaged).",
21
+ "pull records the navigation revision beside the tree (navigation.revision in the manifest and in pull's result), and nav push presents it. A tree edited in the app since the pull is refused with nav.concurrent_modification and nothing is written; keep a copy of nav.json, run pull, re-apply the edits and push again. A manifest with no navigation revision (a workspace pulled by an earlier release) is refused with nav.pull_required: run pull once. A successful push records the revision it saved, so the next push is fenced against it, and a push whose answer was lost after it committed is settled by running it again (navigation.alreadyApplied, nothing sent). The result no longer carries lastWriteWins; navigation.revision is the saved revision."
22
+ ],
23
+ "help": [
24
+ "theme",
25
+ "walkthrough",
26
+ "delivery",
27
+ "preview",
28
+ "redirects",
29
+ "nav"
30
+ ]
31
+ },
3
32
  {
4
33
  "version": "0.22.4",
5
34
  "note": "The publishing preferences gain analyticsConsentPrompt, the switch that asks readers before the visit cookie is set.",
package/src/cli.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  PLAN_HASH,
16
16
  PUBLISH_KEY_ENVIRONMENT_VARIABLE,
17
17
  RESULT_SCHEMA_VERSION,
18
+ STAGING_REVIEW_SESSION_NOTE,
18
19
  SURFACE_DOCS_PRESENTATION,
19
20
  SURFACE_STANDARD,
20
21
  VERB_APPLY,
@@ -35,6 +36,7 @@ import {
35
36
  VERB_LOGOUT,
36
37
  VERB_MEDIA_UPLOAD,
37
38
  VERB_NAV_PUSH,
39
+ VERB_PAGES_DELETE,
38
40
  VERB_PAGES_META_SET,
39
41
  VERB_PAGES_PUSH,
40
42
  VERB_PLACES_CATEGORY_CLEAR,
@@ -52,6 +54,7 @@ import {
52
54
  VERB_SITES,
53
55
  VERB_STATUS,
54
56
  VERB_THEME_PUSH,
57
+ VERB_THEME_RESOLVE,
55
58
  VERB_USE,
56
59
  VERB_VALIDATE,
57
60
  VERB_WHOAMI,
@@ -85,6 +88,8 @@ import {
85
88
  } from "./reference-help.js";
86
89
  import { FONT_CATEGORIES, getFontCatalogReference } from "./font-catalog.js";
87
90
  import { assertCliCurrent } from "./session.js";
91
+ import { formatThemeResolve } from "./theme-resolve.js";
92
+ import { MAXIMUM_THEME_OPEN_MAP_KEY_SCALARS } from "./theme-validation.js";
88
93
  import { VERB_HANDLERS } from "./verbs/index.js";
89
94
 
90
95
  /**
@@ -97,7 +102,13 @@ import { VERB_HANDLERS } from "./verbs/index.js";
97
102
  * are outdated. `--help` and `--version` are outside it for the same reason:
98
103
  * they are how someone finds out what they have.
99
104
  */
100
- const VERSION_GATED_OFFLINE_VERBS = Object.freeze([VERB_HELP, VERB_VALIDATE, VERB_FORMS_VALIDATE, VERB_WHOAMI]);
105
+ const VERSION_GATED_OFFLINE_VERBS = Object.freeze([
106
+ VERB_HELP,
107
+ VERB_VALIDATE,
108
+ VERB_FORMS_VALIDATE,
109
+ VERB_THEME_RESOLVE,
110
+ VERB_WHOAMI,
111
+ ]);
101
112
 
102
113
  // One entry per verb, in help order. `tokens` is the exact leading positional
103
114
  // sequence; matching prefers the longest sequence, so a two-token family can
@@ -296,6 +307,32 @@ const VERBS = Object.freeze([
296
307
  + "A description longer than 160 characters is warned about (search results cut it off) and one longer than "
297
308
  + "1000 is refused. Nothing is sent: run 'pages push', then 'approve'.",
298
309
  },
310
+ {
311
+ name: VERB_PAGES_DELETE,
312
+ surface: SURFACE_STANDARD,
313
+ // Deleting is Content; the navigation read that checks for a dangling item is Design.
314
+ capabilities: [CAPABILITY_CONTENT, CAPABILITY_DESIGN],
315
+ tokens: ["pages", "delete"],
316
+ dryRun: true,
317
+ positionals: "deletePagePath",
318
+ summary: "Delete one page (a live page is staged for removal until you deploy), and remove its source from the workspace.",
319
+ note: "Names one page this workspace tracks by its path and deletes it from the site, "
320
+ + "then removes its source file and manifest entry so the next pull or push does not bring it back. "
321
+ + "A draft or staged page that was never published is removed outright and its path is free again. A page "
322
+ + "the live site serves is not taken off it by the delete: the site stages the removal (effect "
323
+ + "removalStaged) and the page stays live until a deployment promotes it, so run 'deploy --staging', "
324
+ + "review the staging site, then 'deploy'. A deployment is also how a staging candidate that held a page "
325
+ + "is refreshed. A staged removal can be undone only in the app, with Keep page on the Pages list. The same fence as 'pages push' runs first: a page changed on "
326
+ + "the site since this workspace last reconciled with it is refused with pages.push_conflict and nothing is "
327
+ + "deleted; run 'pull' and decide again. A page that navigation, the footer or a form's after-submit page "
328
+ + "still uses is refused with pages.delete_referenced, naming each use; remove those first. A redirect that "
329
+ + "lands on the path is reported in redirectsToPage and does not stop the delete. Links from other pages "
330
+ + "are not checked here: run 'validate' afterwards (link.page_missing). The home page, 404, generated pages, "
331
+ + "legal documents and integration-managed pages are refused. --dry-run runs every check and reports what "
332
+ + "would be deleted, or staged for removal, without sending anything. A page the site has already deleted "
333
+ + "or staged for removal (an interrupted run) is only cleaned from the workspace and reports effect "
334
+ + "alreadyDeleted or alreadyRemovalStaged.",
335
+ },
299
336
  {
300
337
  name: VERB_AUTHORS_LIST,
301
338
  surface: SURFACE_STANDARD,
@@ -430,7 +467,7 @@ const VERBS = Object.freeze([
430
467
  summary: "Check the whole workspace against the live site and order what is left to send. Writes nothing.",
431
468
  note: "Runs every check the pushes run — each page against the site's pages, revisions, videos and forms; media that "
432
469
  + "pages reference and media upload has not sent; the theme, appearance and footer colors against the site's "
433
- + "presentation revision; the footer against its draft; navigation against the live page list — and lists "
470
+ + "presentation revision; the footer against its draft; navigation against the live page list and the revision pull recorded (an unchanged tree under a stale revision is listed ready with settlesLostPush, so apply settles a push whose answer was lost) — and lists "
434
471
  + "every problem with its area, file, code and field. It then orders the steps that remain: media upload, "
435
472
  + "pages push, footer push (theme push refuses unsaved footer content), theme push, nav push, each marked "
436
473
  + "ready, blocked, or nothing to do with the reason for its place. The result's planHash covers what each "
@@ -463,6 +500,13 @@ const VERBS = Object.freeze([
463
500
  capabilities: [CAPABILITY_CONTENT, CAPABILITY_DESIGN],
464
501
  tokens: ["nav", "push"],
465
502
  summary: "Replace the whole navigation tree from the local workspace.",
503
+ note: "Fenced by the navigation revision that 'pull' recorded in the manifest: a tree edited in the app since is "
504
+ + "refused with nav.concurrent_modification and nothing is written; keep a copy of nav.json, run 'pull', "
505
+ + "re-apply the edits and push again. A manifest with no navigation revision is refused with nav.pull_required. "
506
+ + "The result's navigation.revision is the revision this push saved, and nav.json and the manifest are updated "
507
+ + "to match, so the next push is fenced against it. A push whose answer was lost after it committed is settled by running it again: the site already holds the file's tree, so nothing is sent and the result says navigation.alreadyApplied. A page deleted in the app also drops its navigation items "
508
+ + "and so moves the revision: pull before the next push. Every PAGE target is checked against the site's page "
509
+ + "list, and the items the replace removes are named in navigation.removed.",
466
510
  },
467
511
  {
468
512
  name: VERB_REDIRECTS_CHECK,
@@ -475,7 +519,8 @@ const VERBS = Object.freeze([
475
519
  + "following redirects. A redirect is live only with the release that carries it, so this reads the staged release. "
476
520
  + "The check authenticates with its own handoff and never reports it. The result carries no review URL unless "
477
521
  + "you pass --handoff, which adds a fresh single-use one in stagingPreview.url (keep it private); "
478
- + "'staging review' mints one at any time.",
522
+ + "'staging review' mints one at any time. Every run mints its own handoff, which ends an earlier review "
523
+ + "link minted with the same key (see 'help preview').",
479
524
  },
480
525
  {
481
526
  name: VERB_REDIRECTS_PULL,
@@ -566,6 +611,34 @@ const VERBS = Object.freeze([
566
611
  + "at render time, so never copy default mappings into a theme: a pinned token shadows its role. Contrast and "
567
612
  + "status-color warnings name the nearest passing value the CLI found and checked.",
568
613
  },
614
+ {
615
+ name: VERB_THEME_RESOLVE,
616
+ surface: SURFACE_DOCS_PRESENTATION,
617
+ // Only --site reads the site, and that is the one read of the presentation theme push also makes.
618
+ capabilities: [CAPABILITY_DESIGN],
619
+ tokens: ["theme", "resolve"],
620
+ json: true,
621
+ resolveOptions: true,
622
+ summary: "Print the colors Espalier compiles from the theme, per scheme and context, with their contrast ratios.",
623
+ note: "Reads the pulled workspace's settings/taproot-styles.json offline (run pull first; no credential, no network) "
624
+ + "and compiles it through Espalier's own pipeline, the one theme push lints and the published page renders "
625
+ + "through, so a value here cannot differ from the render. For the root page and every declared context in "
626
+ + "each scheme it lists every semantic color token as oklch and hex (hex is the sRGB the engine gamut-maps the "
627
+ + "color to), whether it is compiled from the roles or pinned in semanticMappings (origin compiled, pinned, "
628
+ + "or inherited_pin for a root pin a context carries), and the ramp stop or anchor it came from. It also "
629
+ + "measures every ink-on-surface pairing Espalier defines (text on background, links, headings, the action "
630
+ + "label, and so on): the WCAG ratio against the AA minimum (4.5:1 for text, 3:1 for large text and UI) and "
631
+ + "the APCA Lc against Espalier's target. A pair under AA is listed in flags with the stable code "
632
+ + "theme.contrast_text_below_aa and the field to edit (for example "
633
+ + "darkTheme.contexts.inverted); flags never refuse, so the exit code stays 0. An invalid or incomplete theme "
634
+ + "is refused with the code theme push and validate use, the first fault in error.code and error.field. Without --json the output "
635
+ + "is a readable table; --json writes the stable result (schemes[].surfaces[].tokens and pairs, contrast, flags). "
636
+ + "--scheme and --context narrow the output only; the whole pair is always validated. A result too large for "
637
+ + "the output bound names the surfaces it left out in omittedSurfaces (items, count, truncated). --site reads the site's stored theme "
638
+ + "instead of the workspace (the effective theme pull would write), which compares a workspace edit with what is "
639
+ + "live: run it with and without --site. A pair under AA is a finding to read, not a failure: a brand color "
640
+ + "may be a deliberate choice. See 'taproot-site help theme'.",
641
+ },
569
642
  {
570
643
  name: VERB_FOOTER_PUSH,
571
644
  surface: SURFACE_DOCS_PRESENTATION,
@@ -634,7 +707,8 @@ const VERBS = Object.freeze([
634
707
  + `serving the previous release for up to ${LIMITS.stagingPropagationCeilingSeconds} seconds after a deployment completes, so a Standard site's `
635
708
  + "deploy --staging waits until staging serves the new release and reports stagingServed: state served, "
636
709
  + "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), "
637
- + "with the time to wait or the seconds waited.",
710
+ + "with the time to wait or the seconds waited. "
711
+ + STAGING_REVIEW_SESSION_NOTE,
638
712
  },
639
713
  {
640
714
  name: VERB_PREVIEW_PAGE,
@@ -695,11 +769,11 @@ const VERBS = Object.freeze([
695
769
  tokens: ["staging", "review"],
696
770
  summary: "Mint a fresh single-use review handoff for the site's staging host.",
697
771
  reviewPath: true,
698
- note: "The handoff URL is reported only in the final JSON as stagingPreview.url, is single-use, and expires two "
699
- + "minutes after it is minted; keep it private. --path <page> opens the review on that page instead of the "
772
+ note: "The handoff URL is reported only in the final JSON as stagingPreview.url; keep it private. --path <page> opens the review on that page instead of the "
700
773
  + "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 "
701
774
  + "schemes. deploy --staging and redirects check report no review URL unless you pass --handoff, so this is "
702
- + "the usual way to get one, on a managed Docs site too.",
775
+ + "the usual way to get one, on a managed Docs site too. "
776
+ + STAGING_REVIEW_SESSION_NOTE,
703
777
  },
704
778
  {
705
779
  name: VERB_STATUS,
@@ -845,6 +919,8 @@ function verbHelp(verb) {
845
919
  ? " <place-id> <category>"
846
920
  : verb.positionals === "placeId"
847
921
  ? " <place-id>"
922
+ : verb.positionals === "deletePagePath"
923
+ ? " <page-path>"
848
924
  : verb.positionals === "metaPagePath"
849
925
  ? " <page-path> [--title <text>] [--description <text>] [--author <handle-or-email>]"
850
926
  : verb.positionals === "authorHandle"
@@ -883,9 +959,15 @@ function verbHelp(verb) {
883
959
  ? `\n --name <text> The name readers see on the byline (required).
884
960
  --email <address> A private address, kept to link the person to an account later (optional).`
885
961
  : "";
886
- const jsonOption = verb.json
962
+ const jsonOption = verb.json && !verb.resolveOptions
887
963
  ? "\n --json Emit the stable JSON contract (operational output is always JSON)."
888
964
  : "";
965
+ const resolveOptions = verb.resolveOptions
966
+ ? `\n --scheme <light|dark> Describe one scheme (default: both).
967
+ --context <name> Describe one declared context only, not the root page.
968
+ --site Read the site's theme instead of the workspace's; needs a sign-in.
969
+ --json Write the stable JSON result instead of the table.`
970
+ : "";
889
971
  const dryRunOption = verb.dryRun
890
972
  ? "\n --dry-run Read the site and report what a push would change, without writing."
891
973
  : "";
@@ -914,7 +996,11 @@ function verbHelp(verb) {
914
996
  ? " It refuses only when a previous sign-in exchange recorded a newer published release than this CLI, because "
915
997
  + "Taproot accepts only the latest; with nothing recorded it runs."
916
998
  : "";
917
- const boundary = verb.name === VERB_VALIDATE
999
+ const boundary = verb.name === VERB_THEME_RESOLVE
1000
+ ? "By default this verb reads the pulled workspace and uses no credential, makes no network request, and writes "
1001
+ + `nothing to the workspace or the site (GITHUB_OUTPUT, when set, receives the JSON result). With --site it reads the site's presentation with the site-scoped credential from ${PUBLISH_KEY_ENVIRONMENT_VARIABLE} `
1002
+ + `or the sign-in '${CLI_BINARY_NAME} ${VERB_LOGIN}' stores.${upgradeGate}`
1003
+ : verb.name === VERB_VALIDATE
918
1004
  ? "Validation uses no credential, network or write; it reads the configuration only to find the workspace when "
919
1005
  + "no directory is given. With --init it reads the pulled workspace or selected configuration and writes a new "
920
1006
  + `fixture directory, still without credentials or network.${upgradeGate}`
@@ -964,7 +1050,7 @@ function verbHelp(verb) {
964
1050
  ${verb.summary}
965
1051
  ${boundary}
966
1052
 
967
- ${options}${targetOption}${handoffOption}${reviewPathOption}${sinceOption}${deliveryTargetOption}${metaOptions}${authorOptions}${jsonOption}${dryRunOption}${planOption}${nameOption}${note}
1053
+ ${options}${targetOption}${handoffOption}${reviewPathOption}${sinceOption}${deliveryTargetOption}${metaOptions}${authorOptions}${jsonOption}${resolveOptions}${dryRunOption}${planOption}${nameOption}${note}
968
1054
  `;
969
1055
  }
970
1056
 
@@ -1254,6 +1340,32 @@ function parseNameOption(arguments_, index) {
1254
1340
  return candidate;
1255
1341
  }
1256
1342
 
1343
+ const THEME_RESOLVE_SCHEMES = Object.freeze(["light", "dark"]);
1344
+
1345
+ function parseThemeScheme(arguments_, index) {
1346
+ const candidate = arguments_[index + 1];
1347
+ if (!THEME_RESOLVE_SCHEMES.includes(candidate)) {
1348
+ throw usageError("theme.scheme_invalid", "--scheme requires 'light' or 'dark'.", { field: "scheme" });
1349
+ }
1350
+ return candidate;
1351
+ }
1352
+
1353
+ function parseThemeContext(arguments_, index) {
1354
+ const candidate = arguments_[index + 1];
1355
+ if (
1356
+ typeof candidate !== "string"
1357
+ || candidate.length === 0
1358
+ || [...candidate].length > MAXIMUM_THEME_OPEN_MAP_KEY_SCALARS
1359
+ || hasAsciiControl(candidate)
1360
+ || candidate.startsWith("--")
1361
+ ) {
1362
+ throw usageError("theme.context_invalid", "--context requires the name of one declared context.", {
1363
+ field: "context",
1364
+ });
1365
+ }
1366
+ return candidate;
1367
+ }
1368
+
1257
1369
  function parseArguments(arguments_) {
1258
1370
  if (!Array.isArray(arguments_) || arguments_.some((argument) => typeof argument !== "string")) {
1259
1371
  throw usageError("cli.usage", "The command line must be a list of strings.");
@@ -1314,6 +1426,9 @@ function parseArguments(arguments_) {
1314
1426
  let since;
1315
1427
  let dryRun = false;
1316
1428
  let json = false;
1429
+ let themeScheme;
1430
+ let themeContext;
1431
+ let fromSite = false;
1317
1432
  let keyName;
1318
1433
  let planHash;
1319
1434
  let metaTitle;
@@ -1423,6 +1538,23 @@ function parseArguments(arguments_) {
1423
1538
  json = true;
1424
1539
  continue;
1425
1540
  }
1541
+ if (verb.resolveOptions && argument === "--scheme") {
1542
+ if (themeScheme !== undefined) throw usageError("cli.duplicate_option", "--scheme may be supplied only once.");
1543
+ themeScheme = parseThemeScheme(rest, index);
1544
+ index += 1;
1545
+ continue;
1546
+ }
1547
+ if (verb.resolveOptions && argument === "--context") {
1548
+ if (themeContext !== undefined) throw usageError("cli.duplicate_option", "--context may be supplied only once.");
1549
+ themeContext = parseThemeContext(rest, index);
1550
+ index += 1;
1551
+ continue;
1552
+ }
1553
+ if (verb.resolveOptions && argument === "--site") {
1554
+ if (fromSite) throw usageError("cli.duplicate_option", "--site may be supplied only once.");
1555
+ fromSite = true;
1556
+ continue;
1557
+ }
1426
1558
  if (verb.planHash && argument === "--plan") {
1427
1559
  if (planHash !== undefined) throw usageError("cli.duplicate_option", "--plan may be supplied only once.");
1428
1560
  planHash = parsePlanOption(rest, index);
@@ -1554,6 +1686,11 @@ function parseArguments(arguments_) {
1554
1686
  { field: "environmentSelector" },
1555
1687
  );
1556
1688
  }
1689
+ if (verb.positionals === "deletePagePath" && positionals.length !== 1) {
1690
+ throw usageError("pages.delete_path_invalid", "pages delete names exactly one page path.", {
1691
+ field: "pagePath",
1692
+ });
1693
+ }
1557
1694
  if (verb.positionals === "metaPagePath" && positionals.length !== 1) {
1558
1695
  throw usageError("pages.meta_path_invalid", "pages meta set names exactly one page path ('/' for the homepage).", {
1559
1696
  field: "pagePath",
@@ -1624,7 +1761,11 @@ function parseArguments(arguments_) {
1624
1761
  ...(browser === undefined ? {} : { browser }),
1625
1762
  init,
1626
1763
  dryRun,
1764
+ json,
1627
1765
  keyName,
1766
+ ...(themeScheme === undefined ? {} : { themeScheme }),
1767
+ ...(themeContext === undefined ? {} : { themeContext }),
1768
+ ...(fromSite ? { fromSite } : {}),
1628
1769
  ...(planHash === undefined ? {} : { planHash }),
1629
1770
  ...(metaTitle === undefined ? {} : { metaTitle }),
1630
1771
  ...(metaDescription === undefined ? {} : { metaDescription }),
@@ -1636,7 +1777,7 @@ function parseArguments(arguments_) {
1636
1777
  key: verb.positionals,
1637
1778
  values: positionals,
1638
1779
  scalar: verb.positionals === "pageSelector" || verb.positionals === "fixturePath" || verb.positionals === "metaPagePath"
1639
- || verb.positionals === "siteSelector" || verb.positionals === "environmentSelector"
1780
+ || verb.positionals === "deletePagePath" || verb.positionals === "siteSelector" || verb.positionals === "environmentSelector"
1640
1781
  || verb.positionals === "authorHandle",
1641
1782
  }
1642
1783
  : undefined,
@@ -1718,6 +1859,9 @@ export async function runCli({
1718
1859
  quiet: parsed.quiet,
1719
1860
  dryRun: parsed.dryRun,
1720
1861
  keyName: parsed.keyName,
1862
+ ...(parsed.themeScheme === undefined ? {} : { themeScheme: parsed.themeScheme }),
1863
+ ...(parsed.themeContext === undefined ? {} : { themeContext: parsed.themeContext }),
1864
+ ...(parsed.fromSite === undefined ? {} : { fromSite: parsed.fromSite }),
1721
1865
  ...(parsed.planHash === undefined ? {} : { planHash: parsed.planHash }),
1722
1866
  // Only when given, like the other optional fields, so each verb keeps its default (TR00925).
1723
1867
  ...(parsed.deliveryUrl === undefined ? {} : { deliveryUrl: parsed.deliveryUrl }),
@@ -1756,7 +1900,8 @@ export async function runCli({
1756
1900
  if (!isValidationInvocation && environment.GITHUB_OUTPUT) {
1757
1901
  await writeGithubActionsOutput(environment.GITHUB_OUTPUT, result);
1758
1902
  }
1759
- stdout.write(`${json}\n`);
1903
+ // The table is this verb's default view; --json asks for the contract. A failure is always the JSON result.
1904
+ stdout.write(parsed.verb === VERB_THEME_RESOLVE && !parsed.json ? formatThemeResolve(result) : `${json}\n`);
1760
1905
  return 0;
1761
1906
  } catch (unknownError) {
1762
1907
  progress?.stop();
package/src/constants.js CHANGED
@@ -84,6 +84,7 @@ export const VERB_LOGIN = "login";
84
84
  export const VERB_LOGOUT = "logout";
85
85
  export const VERB_PAGES_PUSH = "pages push";
86
86
  export const VERB_PAGES_META_SET = "pages meta set";
87
+ export const VERB_PAGES_DELETE = "pages delete";
87
88
  export const VERB_AUTHORS_LIST = "authors list";
88
89
  export const VERB_AUTHORS_ADD = "authors add";
89
90
  export const VERB_NAV_PUSH = "nav push";
@@ -94,6 +95,7 @@ export const VERB_FORMS_PULL = "forms pull";
94
95
  export const VERB_FORMS_PUSH = "forms push";
95
96
  export const VERB_FORMS_VALIDATE = "forms validate";
96
97
  export const VERB_THEME_PUSH = "theme push";
98
+ export const VERB_THEME_RESOLVE = "theme resolve";
97
99
  export const VERB_FOOTER_PUSH = "footer push";
98
100
  export const VERB_MEDIA_UPLOAD = "media upload";
99
101
  export const VERB_APPROVE = "approve";
@@ -112,6 +114,19 @@ export const VERB_CHANGELOG = "changelog";
112
114
  export function changelogCommandSince(version) {
113
115
  return `${CLI_BINARY_NAME} ${VERB_CHANGELOG} --since ${version}`;
114
116
  }
117
+
118
+ /**
119
+ * How long a staging review lasts and what ends it (TR01237). The numbers come from the API's
120
+ * StagingPreviewProtocol (handoff 2 minutes, session 5 minutes, no renewal); every help topic, verb note and
121
+ * result that tells a reader about them repeats this one text.
122
+ */
123
+ export const STAGING_REVIEW_SESSION_NOTE = "A review handoff works once and expires two minutes after it is minted. "
124
+ + "Opening it starts a five-minute review session in that browser, and the session never renews: afterwards the "
125
+ + "staging site redirects to the Taproot sign-in, so a reviewer without a Taproot account that can preview the "
126
+ + "site needs a fresh link. Minting another handoff with the same key for the same site (staging review, "
127
+ + "deploy --staging on a Standard site or with --handoff, redirects check or delivery check --staging) ends the earlier link and any session opened "
128
+ + "from it, and that browser then shows 'Staging preview unavailable'; mint the link last, just before the "
129
+ + "reviewer opens it.";
115
130
  export const VERB_PLAN = "plan";
116
131
  export const VERB_APPLY = "apply";
117
132
  export const VERB_PLACES_SEARCH = "places search";
@@ -119,6 +134,8 @@ export const VERB_PLACES_SELECT = "places select";
119
134
  export const VERB_PLACES_CATEGORY_SET = "places category set";
120
135
  export const VERB_PLACES_CATEGORY_CLEAR = "places category clear";
121
136
  export const VERB_PLACES_CATEGORY_LIST = "places category list";
137
+ /** A bare 64-character lowercase hex digest: the shape of every revision and draft hash the site reports. */
138
+ export const HEX_DIGEST = /^[0-9a-f]{64}$/u;
122
139
  /** The identity `plan` reports and `apply --plan` requires. */
123
140
  export const PLAN_HASH = /^sha256:[0-9a-f]{64}$/u;
124
141
  export const VERB_ENV = "env";
@@ -1,3 +1,4 @@
1
+ import { HEX_DIGEST } from "./constants.js";
1
2
  import { isCanonicalUuid, SiteAuthoringError } from "./errors.js";
2
3
  import { appearanceImageIds } from "./appearance-contract.js";
3
4
  import { footerImageIds } from "./footer-contract.js";
@@ -6,7 +7,7 @@ import { computeFooterContentHash, computeFooterDraftHash, stableJson } from "./
6
7
  import { readManifest, readMediaManifest, writeManifest } from "./workspace.js";
7
8
 
8
9
  export const FOOTER_SETTINGS_FILE = "settings/site-publishing-preferences.json";
9
- const SHA256 = /^[0-9a-f]{64}$/u;
10
+ const SHA256 = HEX_DIGEST;
10
11
 
11
12
  function invalid(message, field) {
12
13
  throw new SiteAuthoringError("footer.pull_required", message, { field });