@taprootio/site-authoring 0.14.0 → 0.15.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.
Files changed (39) hide show
  1. package/README.md +62 -10
  2. package/examples/riverbend-wellness/.taproot-site-media.json +23 -0
  3. package/examples/riverbend-wellness/pages/index.md +1 -17
  4. package/package.json +2 -2
  5. package/src/api.js +59 -1
  6. package/src/cli.js +147 -22
  7. package/src/constants.js +6 -0
  8. package/src/content/components.js +64 -6
  9. package/src/content/free-form-sections.js +21 -4
  10. package/src/content/markdown.js +161 -26
  11. package/src/content/tiptap-prosemirror.ts +66 -8
  12. package/src/content/validate-document.js +30 -6
  13. package/src/content/vocabulary.js +26 -0
  14. package/src/errors.js +22 -0
  15. package/src/espalier-shared.js +11 -0
  16. package/src/font-catalog.js +87 -0
  17. package/src/footer-contract.js +1 -1
  18. package/src/output.js +19 -1
  19. package/src/presentation-reference.js +21 -7
  20. package/src/problems.js +116 -0
  21. package/src/reference-help.js +100 -34
  22. package/src/session.js +13 -0
  23. package/src/theme-proposals.js +393 -0
  24. package/src/theme-validation.js +31 -10
  25. package/src/typed-page-reference.js +11 -3
  26. package/src/typed-pages.js +31 -9
  27. package/src/verbs/deploy.js +1 -0
  28. package/src/verbs/index.js +13 -0
  29. package/src/verbs/media-upload.js +127 -30
  30. package/src/verbs/nav-push.js +7 -0
  31. package/src/verbs/pages-push.js +406 -199
  32. package/src/verbs/places.js +61 -0
  33. package/src/verbs/plan.js +585 -0
  34. package/src/verbs/theme-push.js +68 -36
  35. package/src/verbs/validate.js +264 -217
  36. package/src/video-prepare.js +48 -12
  37. package/src/workspace-check.js +199 -0
  38. package/src/workspace-ledger.js +173 -0
  39. package/src/workspace.js +47 -4
package/README.md CHANGED
@@ -119,8 +119,16 @@ dark logo on the rendered dark header at desktop and compact/mobile widths;
119
119
  transparent canvas pixels do not count as contrast. Supply separate
120
120
  scheme-specific assets when one visible mark cannot satisfy both surfaces.
121
121
 
122
+ `taproot-site validate` checks the pulled workspace `taproot-site.json` names,
123
+ with no credential, no network, and no write: every page source (metadata,
124
+ Markdown conversion, media references, content vocabulary, section contexts),
125
+ page paths and templates against the pages the pull recorded, navigation,
126
+ redirects, forms, and the complete theme, appearance, header, brand, and
127
+ footer. It reports every problem at once in `error.problems`, each with its
128
+ file, code, and field, instead of one per run.
129
+
122
130
  `taproot-site validate <fixture-directory>` checks a complete offline fixture
123
- with no credential, no network, and no write: a directory laid out like a
131
+ the same way: a directory laid out like a
124
132
  pulled workspace — `pages/`, `nav.json`, `redirects.json`, and the four
125
133
  `settings/` documents —
126
134
  whose `manifest.fixture.json` binds deterministic page, resource, image, and
@@ -143,9 +151,10 @@ taproot-site validate "$(npm root --global)/@taprootio/site-authoring/examples/r
143
151
  The fixture is a fictional wellness studio — `example.test` hostnames, a
144
152
  reserved `555-01xx` telephone number, an invented street and town — with two
145
153
  free-form pages and one each of the article, recipe, album and place-review
146
- templates, a three-item navigation tree, and all four settings documents. Nothing in it describes a real business. Copy the directory
147
- somewhere writable before you edit it; `validate` never writes to the fixture
148
- it reads.
154
+ templates, a three-item navigation tree, all four settings documents, and a
155
+ `.taproot-site-media.json` that maps its one media path to a fixture image id.
156
+ Nothing in it describes a real business. Copy the directory somewhere writable
157
+ before you edit it; `validate` never writes to the fixture it reads.
149
158
 
150
159
  ## Authorize
151
160
 
@@ -285,14 +294,15 @@ each step names the reference it relies on.
285
294
  3. **Edit both schemes and the appearance.** `help theme` lists every field,
286
295
  the role slots, how anchor references resolve and what wins when a pin and
287
296
  a role disagree; `help appearance` covers the default scheme, header, logos
288
- and favicon.
297
+ and favicon; `help fonts` lists the font families, by category, that the
298
+ font fields can name.
289
299
  4. **Prepare media locally.** Raster files only (PNG, JPEG, GIF, WebP),
290
300
  relative to the workspace root; make the transparent logo, its `@2x` copy,
291
301
  a dark-scheme logo when needed and a square favicon with your own tools,
292
302
  upload them with `media upload`, then assign `lightLogoId`, `darkLogoId`
293
303
  and `brand.faviconId` from the returned ids. There is no recolor, crop or
294
304
  SVG upload command.
295
- 5. **Validate, preview, then push.** `validate` runs offline;
305
+ 5. **Validate, preview, then push.** `validate` checks the whole workspace offline;
296
306
  `theme push --dry-run` reads the site and lists the JSON paths at which
297
307
  each settings file differs from it, and whether the baseline `pull`
298
308
  recorded is still current; `theme push` saves both themes, the appearance
@@ -351,12 +361,40 @@ it is created: a source for another template is refused with
351
361
  template's fields and one worked example.
352
362
 
353
363
  `pages push [page-path...]` validates and sends the selected pages, or every
354
- workspace page when none is named; the homepage is addressed as `/`. It fails
364
+ workspace page when none is named; the homepage is addressed as `/`. Every
365
+ selected page is checked before anything is sent, and a refusal lists every
366
+ problem in `error.problems`. `pages push --dry-run` runs the same checks
367
+ against the live site, lists which pages would be created, updated, or left
368
+ unchanged, and sends nothing. It fails
355
369
  closed with `pages.push_conflict` when a page's stored-state revision has
356
370
  moved since this workspace last reconciled with it, naming both revisions;
357
371
  `pull` first, or push after a refused pull has shown you the site's version.
358
- `media upload [path...]` uploads raster files and records component-ready
359
- delivery fields; MP4, MOV, and WebM files are uploaded as videos. Taproot does not
372
+
373
+ `places search <name and city>` asks Google Places for the place a review
374
+ names and reports predictions with a session token; `places select
375
+ <googlePlaceId> <sessionToken>` records the match as a Taproot place and
376
+ reports the `placeId` a place review's front matter needs. An article or an
377
+ album can name its place the same way with `place:`.
378
+
379
+ `plan` checks the whole workspace against the live site — pages, the media
380
+ they reference and the images the settings name, the footer and the pages it
381
+ links, the theme, navigation — and orders what is left
382
+ to send: media upload, pages push, footer push (theme push refuses unsaved
383
+ footer content), theme push, nav push, each marked ready, blocked, or nothing
384
+ to do. It writes nothing and reports a `planHash`. `apply --plan <planHash>`
385
+ plans again, refuses with `apply.plan_stale` if the workspace or the site
386
+ moved, and otherwise runs the steps through the ordinary push verbs. While
387
+ they run, a workspace file read differently from the plan (or not read by it)
388
+ stops the step before it writes, naming the file, and so does a page revision
389
+ or the site's navigation that moved. Steps are
390
+ not atomic together: a failure reports in `error.completedWrites` which steps
391
+ completed, which failed, and which did not run, and the next `plan` shows
392
+ finished steps as nothing to do. Redirects, forms, approval, and deployment
393
+ stay separate commands.
394
+ `media upload [path...]` uploads raster files and records each one in
395
+ `.taproot-site-media.json`; Markdown then names an image by its path (such as
396
+ `media/photo.webp`) everywhere an image goes, from body images to component and
397
+ section image fields. MP4, MOV, and WebM files are uploaded as videos. Taproot does not
360
398
  encode: the command reads each file with mediabunny, rewrites an H.264 and AAC
361
399
  file that is not an MP4 with its index first as one (copying the tracks, never
362
400
  re-encoding), declares its codecs, and prints the server's one-line refusal as
@@ -372,7 +410,9 @@ settings, and the closed footer document. `pull` writes each scheme's
372
410
  complete effective theme — the stored theme resolved over the same defaults
373
411
  every consumer renders — so a fresh workspace validates as pulled, and it
374
412
  keeps only authored `semanticMappings` pins (listed in
375
- `explicitMappingTokens`) because a pinned token shadows its role.
413
+ `explicitMappingTokens` at the root; a pin inside a context needs no marker)
414
+ because a pinned token shadows its role. Contrast and status-color warnings
415
+ name the nearest passing value the CLI found and checked.
376
416
  `theme push` — and `theme push --dry-run`, which writes nothing — also runs
377
417
  Espalier's fit report over the root and every context in both schemes and
378
418
  prints each lint as a warning, such as a filled action that renders the
@@ -505,6 +545,18 @@ Other C0/C1 controls, U+2028/U+2029, U+FEFF, and literal `<br>` tags are rejecte
505
545
  with the exact fact field path. A label is optional and stays outside the value's link. See
506
546
  `taproot-site help page free-form` and `taproot-site help component cta`.
507
547
 
548
+ ### Link rel in 0.15.0
549
+
550
+ A link publishes followed unless its author chooses otherwise. A link mark's
551
+ `rel` takes space-separated link types (`nofollow`, `sponsored`, `ugc`, `me`,
552
+ and the other standard types except `opener`), on any link; `target: "_blank"`
553
+ opens a new tab with `noopener noreferrer` added. In Markdown, write
554
+ `[Shop](https://shop.example){rel="sponsored nofollow" target="_blank"}`
555
+ (either attribute alone works). Other tokens and
556
+ targets are refused (`content.link_rel`, `content.link_target`). The editor's
557
+ old default, `noopener noreferrer nofollow`, reads as no choice. See
558
+ `taproot-site help page free-form`.
559
+
508
560
  ### Staging verification and deployment evidence
509
561
 
510
562
  `deploy --staging` selects the same changed pages, settings groups and navigation
@@ -0,0 +1,23 @@
1
+ {
2
+ "mediaManifestVersion": 2,
3
+ "siteId": "a0000000-0000-4000-8000-000000000001",
4
+ "media": {
5
+ "media/riverbend-studio.webp": {
6
+ "imageId": "a0000000-0000-4000-8000-0000000000a2",
7
+ "src": "https://static.example.test/site/img/riverbend-studio-1280.webp",
8
+ "urls": [
9
+ {
10
+ "minWidth": 640,
11
+ "url": "https://static.example.test/site/img/riverbend-studio-640.webp"
12
+ },
13
+ {
14
+ "minWidth": 1280,
15
+ "url": "https://static.example.test/site/img/riverbend-studio-1280.webp"
16
+ }
17
+ ],
18
+ "width": 1600,
19
+ "height": 1067,
20
+ "alt": "An example photograph placeholder for the fixture studio"
21
+ }
22
+ }
23
+ }
@@ -19,23 +19,7 @@ description: An example home page for the offline authoring fixture.
19
19
  "url": "https://schedule.example.test/riverbend"
20
20
  },
21
21
  "alignment": "start",
22
- "media": {
23
- "imageId": "a0000000-0000-4000-8000-0000000000a2",
24
- "src": "https://static.example.test/site/img/riverbend-studio-1280.webp",
25
- "urls": [
26
- {
27
- "minWidth": 640,
28
- "url": "https://static.example.test/site/img/riverbend-studio-640.webp"
29
- },
30
- {
31
- "minWidth": 1280,
32
- "url": "https://static.example.test/site/img/riverbend-studio-1280.webp"
33
- }
34
- ],
35
- "width": 1600,
36
- "height": 1067,
37
- "alt": "An example photograph placeholder for the fixture studio"
38
- },
22
+ "media": "media/riverbend-studio.webp",
39
23
  "mediaArrangement": "split",
40
24
  "mediaPosition": "after",
41
25
  "mediaWidth": "equal"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taprootio/site-authoring",
3
- "version": "0.14.0",
3
+ "version": "0.15.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": "5.0.0",
31
+ "@taprootio/espalier": "6.2.0",
32
32
  "mediabunny": "1.61.1"
33
33
  },
34
34
  "scripts": {
package/src/api.js CHANGED
@@ -178,7 +178,8 @@ const AUTHORING_PREVIEW_FIELD_ERRORS = Object.freeze({
178
178
  ],
179
179
  [AUTHORING_PREVIEW_FIELDS.staging]: [
180
180
  "preview.staging_unavailable",
181
- "The site does not have an available staging hostname for authoring previews.",
181
+ "The site's staging address is not ready for previews. If the site has never deployed to staging, run "
182
+ + "'deploy --staging' once; otherwise check its staging host with 'taproot-site status'.",
182
183
  ],
183
184
  [AUTHORING_PREVIEW_FIELDS.expiry]: ["preview.expired", "The authoring preview has expired."],
184
185
  [AUTHORING_PREVIEW_FIELDS.readiness]: ["preview.not_ready", "The authoring preview is not ready for a handoff."],
@@ -233,6 +234,11 @@ function requireIdentifier(value, code, field) {
233
234
  return value;
234
235
  }
235
236
 
237
+ /** A Google place id as Taproot stores it: the server's characters and its 256-character column. */
238
+ export function isGooglePlaceId(value) {
239
+ return typeof value === "string" && value.length <= 256 && /^[A-Za-z0-9_-]+$/u.test(value);
240
+ }
241
+
236
242
  function requireCanonicalUuid(value, code, field) {
237
243
  if (!isCanonicalUuid(value)) {
238
244
  throw new SiteAuthoringError(code, `Taproot returned an invalid ${field}.`, { field });
@@ -874,6 +880,57 @@ export async function listVideos(client, siteId, requestOptions = {}) {
874
880
  return { videos, truncated: true };
875
881
  }
876
882
 
883
+ /**
884
+ * Google Places autocomplete for a place review (TR01003). A key searches for
885
+ * the site it was issued for; every search and the select that follows share
886
+ * `sessionToken`, which is what groups them into one billed session.
887
+ */
888
+ export async function searchPlaces(client, siteId, { text: searchText, sessionToken }) {
889
+ const search = query([["query", searchText], ["siteId", siteId], ["sessionToken", sessionToken]]);
890
+ const response = requireObject(
891
+ await client.request(`v1/places/search${search}`),
892
+ "api.place_contract",
893
+ "place search",
894
+ );
895
+ return (Array.isArray(response.predictions) ? response.predictions : []).map((value) => {
896
+ const prediction = requireObject(value, "api.place_contract", "place prediction");
897
+ return {
898
+ googlePlaceId: requireGooglePlaceId(prediction.googlePlaceId),
899
+ name: text(prediction.name),
900
+ formattedAddress: text(prediction.formattedAddress),
901
+ };
902
+ });
903
+ }
904
+
905
+ function requireGooglePlaceId(value) {
906
+ if (!isGooglePlaceId(value)) {
907
+ throw new SiteAuthoringError("api.place_contract", "Taproot returned an invalid googlePlaceId.", { field: "googlePlaceId" });
908
+ }
909
+ return value;
910
+ }
911
+
912
+ /** Records a prediction as a Taproot place, ending the search's billed session, and returns its id. */
913
+ export async function selectPlace(client, siteId, { googlePlaceId, sessionToken }) {
914
+ const place = requireObject(
915
+ await client.request("v1/places/select", {
916
+ method: "POST",
917
+ body: { siteId, googlePlaceId, sessionToken },
918
+ }),
919
+ "api.place_contract",
920
+ "selected place",
921
+ );
922
+ return {
923
+ // Any UUID version: the server mints place ids.
924
+ placeId: requireIdentifier(place.id, "api.place_contract", "id"),
925
+ name: text(place.name),
926
+ category: text(place.category),
927
+ address: text(place.address),
928
+ city: text(place.city),
929
+ region: text(place.region),
930
+ country: text(place.country),
931
+ };
932
+ }
933
+
877
934
  /**
878
935
  * Processing is observed through `ListSiteImages`; `GetImageById` is
879
936
  * session-only under TR00602's read list, so a key-authorized client watches
@@ -1008,6 +1065,7 @@ export async function getPublishingReadiness(client, siteId, selection = {}) {
1008
1065
  ["stagedPageIds", selection.stagedPageIds],
1009
1066
  ["selectedSettingsTypes", selection.selectedSettingsTypes],
1010
1067
  ["includeNavigation", selection.includeNavigation],
1068
+ ["useExactPageSelection", selection.useExactPageSelection],
1011
1069
  ])
1012
1070
  }`,
1013
1071
  )),
package/src/cli.js CHANGED
@@ -10,10 +10,12 @@ import {
10
10
  DEPLOY_TARGET_STAGING,
11
11
  LIMITS,
12
12
  LOGIN_KEY_NAME_MAXIMUM,
13
+ PLAN_HASH,
13
14
  PUBLISH_KEY_ENVIRONMENT_VARIABLE,
14
15
  RESULT_SCHEMA_VERSION,
15
16
  SURFACE_DOCS_PRESENTATION,
16
17
  SURFACE_STANDARD,
18
+ VERB_APPLY,
17
19
  VERB_APPROVE,
18
20
  VERB_DELIVERY_CHECK,
19
21
  VERB_DEPLOY,
@@ -29,6 +31,9 @@ import {
29
31
  VERB_MEDIA_UPLOAD,
30
32
  VERB_NAV_PUSH,
31
33
  VERB_PAGES_PUSH,
34
+ VERB_PLACES_SEARCH,
35
+ VERB_PLACES_SELECT,
36
+ VERB_PLAN,
32
37
  VERB_PREVIEW_PAGE,
33
38
  VERB_PREVIEW_REVOKE,
34
39
  VERB_PULL,
@@ -68,6 +73,7 @@ import {
68
73
  REFERENCE_TOPICS,
69
74
  REFERENCE_VERSION,
70
75
  } from "./reference-help.js";
76
+ import { FONT_CATEGORIES, getFontCatalogReference } from "./font-catalog.js";
71
77
  import { assertCliCurrent } from "./session.js";
72
78
  import { VERB_HANDLERS } from "./verbs/index.js";
73
79
 
@@ -95,15 +101,20 @@ const VERBS = Object.freeze([
95
101
  {
96
102
  name: VERB_VALIDATE,
97
103
  tokens: ["validate"],
98
- summary: "Validate an offline fixture, or initialize one from a pulled workspace with --init.",
104
+ summary: "Check a whole workspace or offline fixture at once, or initialize a fixture with --init.",
99
105
  positionals: "fixturePath",
100
106
  offline: true,
101
107
  note:
102
- "Reads manifest.fixture.json plus the fixture's page, navigation, theme, appearance, header, brand, and footer files. "
103
- + "This proves local structure and semantics only. It does not prove authorization, live site ownership, concurrency, "
104
- + "persisted round trips, or rendering; run a real pull and authorized preview before deployment. "
105
- + `See '${CLI_BINARY_NAME} help fixture' for the manifest contract and for the path of the complete example `
106
- + "fixture this package ships, which needs no credential and no pulled site. "
108
+ "With no directory, checks the pulled workspace taproot-site.json names; with one, checks that directory, which "
109
+ + "is either an offline fixture (manifest.fixture.json) or a pulled workspace. Every page source, the theme, "
110
+ + "appearance, header, brand, footer, navigation, redirects, and forms are checked with the validators the "
111
+ + "pushes use, and every problem is reported at once in error.problems (each with its file, code, and field) "
112
+ + "rather than one per run. A pulled workspace is checked against the pages, images, and baselines its "
113
+ + "manifests recorded at the pull. This proves local structure and semantics only. It does not prove "
114
+ + "authorization, live site ownership, concurrency, persisted round trips, or rendering: 'plan' checks a "
115
+ + "workspace against the live site, and 'pages push --dry-run' does that for pages alone. "
116
+ + `See '${CLI_BINARY_NAME} help fixture' for the fixture manifest contract and for the path of the complete `
117
+ + "example fixture this package ships, which needs no credential and no pulled site. "
107
118
  + "With --init, the directory is a new destination; the current pulled workspace or --config supplies the source.",
108
119
  },
109
120
  {
@@ -224,6 +235,7 @@ const VERBS = Object.freeze([
224
235
  surface: SURFACE_STANDARD,
225
236
  capabilities: [CAPABILITY_CONTENT],
226
237
  tokens: ["pages", "push"],
238
+ dryRun: true,
227
239
  summary: "Create and update pages from the local workspace.",
228
240
  positionals: "pagePaths",
229
241
  note:
@@ -237,8 +249,71 @@ const VERBS = Object.freeze([
237
249
  + "documents of pages it is not sending: an unrelated page left on an obsolete contract is reported by the "
238
250
  + "whole-workspace push, not used to block this one. The result states the selection and how many sources were "
239
251
  + "discovered and validated. "
252
+ + "Every selected page is checked before anything is sent, and a refusal lists every problem found in "
253
+ + "error.problems with its file, code, and field. --dry-run does all of that against the live site, reports "
254
+ + "which pages would be created, updated, or left unchanged, and sends nothing; it exits 1 when there are "
255
+ + "problems. "
240
256
  + "See 'taproot-site help page free-form' for the stable manifest and error contract.",
241
257
  },
258
+ {
259
+ name: VERB_PLACES_SEARCH,
260
+ surface: SURFACE_STANDARD,
261
+ // A key finds the places for the place reviews it writes, so page editing
262
+ // is what it needs; each search is a billed Google Places request.
263
+ capabilities: [CAPABILITY_CONTENT],
264
+ tokens: ["places", "search"],
265
+ positionals: "placeQuery",
266
+ summary: "Search Google Places for the place a review names; reports predictions and a session token.",
267
+ note: "Give the place's name and city, such as 'places search Blue Bottle Coffee Oakland'. Each prediction "
268
+ + "carries a googlePlaceId; pass the one that matches, with the reported sessionToken, to 'places select' to "
269
+ + "get the Taproot placeId a place review's front matter needs. Searches and the select that follows them are "
270
+ + "one billed session, so search with a precise query rather than many broad ones.",
271
+ },
272
+ {
273
+ name: VERB_PLACES_SELECT,
274
+ surface: SURFACE_STANDARD,
275
+ capabilities: [CAPABILITY_CONTENT],
276
+ tokens: ["places", "select"],
277
+ positionals: "placeSelection",
278
+ summary: "Record a places search prediction as a Taproot place and report its placeId.",
279
+ note: "Takes the googlePlaceId from 'places search', then that search's sessionToken. The place is recorded "
280
+ + "once for every site, so selecting it again returns the same placeId. Use it as placeId in a place review, "
281
+ + "or as place in an article or album.",
282
+ },
283
+ {
284
+ name: VERB_PLAN,
285
+ surface: SURFACE_STANDARD,
286
+ // Pages, media and navigation reads are Content; the presentation read and
287
+ // the footer draft are Design. The same set apply writes with.
288
+ capabilities: [CAPABILITY_CONTENT, CAPABILITY_DESIGN],
289
+ tokens: ["plan"],
290
+ summary: "Check the whole workspace against the live site and order what is left to send. Writes nothing.",
291
+ note: "Runs every check the pushes run — each page against the site's pages, revisions and videos; media that "
292
+ + "pages reference and media upload has not sent; the theme, appearance and footer colors against the site's "
293
+ + "presentation revision; the footer against its draft; navigation against the live page list — and lists "
294
+ + "every problem with its area, file, code and field. It then orders the steps that remain: media upload, "
295
+ + "pages push, footer push (theme push refuses unsaved footer content), theme push, nav push, each marked "
296
+ + "ready, blocked, or nothing to do with the reason for its place. The result's planHash covers what each "
297
+ + "step would send and the live state it would meet; pass it to 'apply'. Redirects and forms are not "
298
+ + "covered: push them on their own. Approval and deployment stay separate.",
299
+ },
300
+ {
301
+ name: VERB_APPLY,
302
+ surface: SURFACE_STANDARD,
303
+ capabilities: [CAPABILITY_CONTENT, CAPABILITY_DESIGN],
304
+ tokens: ["apply"],
305
+ planHash: true,
306
+ summary: "Run the steps a plan listed, in order, through the ordinary push verbs.",
307
+ note: "Plans again first and refuses with apply.plan_stale when the workspace or the site moved since the plan "
308
+ + "you read, and with the plan's problems when it has any; nothing is written either way. While the steps "
309
+ + "run, any workspace file read differently from the plan, or not read by the plan at all, stops the step "
310
+ + "before it writes (apply.plan_stale, naming the file); so does a page revision or the site's navigation "
311
+ + "that moved, and the theme and footer saves are refused by the site when it moved. Steps are not "
312
+ + "atomic together: when one fails, the error's completedWrites lists the steps that completed, the one that "
313
+ + "failed, and those that did not run. Run 'plan' again — finished steps read as nothing to do, and a step "
314
+ + "whose answer was lost is reconciled by its own verb, never repeated blindly. Pages land as drafts; "
315
+ + "'approve' and 'deploy' remain separate.",
316
+ },
242
317
  {
243
318
  name: VERB_NAV_PUSH,
244
319
  surface: SURFACE_STANDARD,
@@ -344,8 +419,9 @@ const VERBS = Object.freeze([
344
419
  + "as a warning naming 'fit lint <id>' and does not stop the push, so read them in a --dry-run first. "
345
420
  + "pull writes each scheme's complete effective theme — the stored theme resolved over the same defaults every "
346
421
  + "consumer renders — so a fresh workspace validates as pulled. semanticMappings holds only authored pins, listed "
347
- + "in explicitMappingTokens; every other token compiles from roles at render time, so never copy default "
348
- + "mappings into a theme: a pinned token shadows its role.",
422
+ + "in explicitMappingTokens at the root (a context's pins need no marker); every other token compiles from roles "
423
+ + "at render time, so never copy default mappings into a theme: a pinned token shadows its role. Contrast and "
424
+ + "status-color warnings name the nearest passing value the CLI found and checked.",
349
425
  },
350
426
  {
351
427
  name: VERB_FOOTER_PUSH,
@@ -591,7 +667,7 @@ ${COMMON_OPTIONS}
591
667
  function verbHelp(verb) {
592
668
  const targetUsage = verb.target ? " (--staging | --production)" : "";
593
669
  const positionalUsage = verb.positionals === "fixturePath"
594
- ? " <fixture-directory>"
670
+ ? " [<directory>]"
595
671
  : verb.positionals === "pageSelector"
596
672
  ? " <page-path-or-id>"
597
673
  : verb.positionals === "previewIds"
@@ -602,6 +678,10 @@ function verbHelp(verb) {
602
678
  ? " <site-name-or-id>"
603
679
  : verb.positionals === "formKeys"
604
680
  ? " [form-key...]"
681
+ : verb.positionals === "placeQuery"
682
+ ? " <query...>"
683
+ : verb.positionals === "placeSelection"
684
+ ? " <google-place-id> <session-token>"
605
685
  : verb.positionals
606
686
  ? ` [${verb.positionals === "paths" ? "path" : "page-path"}...]`
607
687
  : "";
@@ -621,6 +701,9 @@ function verbHelp(verb) {
621
701
  const dryRunOption = verb.dryRun
622
702
  ? "\n --dry-run Read the site and report what a push would change, without writing."
623
703
  : "";
704
+ const planOption = verb.planHash
705
+ ? "\n --plan <hash> The planHash 'plan' reported; required."
706
+ : "";
624
707
  const nameOption = verb.keyName
625
708
  ? `\n --name <text> Name recorded on the issued key (default "${DEFAULT_LOGIN_KEY_NAME}",\n`
626
709
  + ` 1-${LOGIN_KEY_NAME_MAXIMUM} characters). The approval screen shows it.`
@@ -644,8 +727,9 @@ function verbHelp(verb) {
644
727
  + "Taproot accepts only the latest; with nothing recorded it runs."
645
728
  : "";
646
729
  const boundary = verb.name === VERB_VALIDATE
647
- ? "Validation uses no credential, configuration, network or write. With --init it reads the pulled workspace "
648
- + `or selected configuration and writes a new fixture directory, still without credentials or network.${upgradeGate}`
730
+ ? "Validation uses no credential, network or write; it reads the configuration only to find the workspace when "
731
+ + "no directory is given. With --init it reads the pulled workspace or selected configuration and writes a new "
732
+ + `fixture directory, still without credentials or network.${upgradeGate}`
649
733
  : selfContained
650
734
  ? "This offline verb uses no credential and reads no configuration, and performs no network request and no "
651
735
  + `write.${upgradeGate}`
@@ -661,7 +745,8 @@ function verbHelp(verb) {
661
745
  const options = verb.name === VERB_VALIDATE
662
746
  ? `Options:
663
747
  --init Export a pulled workspace into the new fixture directory.
664
- --config <path> Before the verb; source configuration for --init only.
748
+ --config <path> Before the verb; the configuration naming the workspace (no
749
+ directory) or the --init source.
665
750
  --quiet Suppress human progress. The JSON result is unchanged.
666
751
  --help Show this help.
667
752
  --version Show the package version.`
@@ -688,7 +773,7 @@ function verbHelp(verb) {
688
773
  ${verb.summary}
689
774
  ${boundary}
690
775
 
691
- ${options}${targetOption}${deliveryTargetOption}${jsonOption}${dryRunOption}${nameOption}${note}
776
+ ${options}${targetOption}${deliveryTargetOption}${jsonOption}${dryRunOption}${planOption}${nameOption}${note}
692
777
  `;
693
778
  }
694
779
 
@@ -761,6 +846,9 @@ function parseReferenceArguments(arguments_) {
761
846
  ) {
762
847
  throw usageError("help.usage", `The '${topic}' topic does not accept a name.`);
763
848
  }
849
+ if (topic === "fonts" && extra.length > 0) {
850
+ throw usageError("help.usage", "The 'fonts' topic accepts at most one category.");
851
+ }
764
852
  if ((topic === "page" || topic === "component" || topic === "design") && (subject === undefined || extra.length > 0)) {
765
853
  throw usageError("help.usage", `The '${topic}' topic requires exactly one name.`);
766
854
  }
@@ -833,6 +921,17 @@ function referenceResult(parsed) {
833
921
  referenceKind: parsed.topic,
834
922
  reference: getWorkflowReference(parsed.topic),
835
923
  };
924
+ case "fonts": {
925
+ const fonts = getFontCatalogReference(parsed.subject);
926
+ if (!fonts) {
927
+ throw usageError(
928
+ "help.font_category_unknown",
929
+ `Unknown font category. Expected one of: ${FONT_CATEGORIES.join(", ")}.`,
930
+ { alternatives: FONT_CATEGORIES },
931
+ );
932
+ }
933
+ return { ...result, topic: "fonts", fonts };
934
+ }
836
935
  case "theme":
837
936
  return { ...result, topic: "presentation", referenceKind: "theme", reference: getThemeReference() };
838
937
  case "appearance":
@@ -896,6 +995,16 @@ function parseWaitOption(arguments_, index) {
896
995
  return seconds;
897
996
  }
898
997
 
998
+ function parsePlanOption(arguments_, index) {
999
+ const candidate = arguments_[index + 1];
1000
+ if (typeof candidate !== "string" || !PLAN_HASH.test(candidate)) {
1001
+ throw usageError("apply.plan_required", "--plan requires the planHash 'plan' reported (sha256:<64 hex>).", {
1002
+ field: "planHash",
1003
+ });
1004
+ }
1005
+ return candidate;
1006
+ }
1007
+
899
1008
  function parseNameOption(arguments_, index) {
900
1009
  const candidate = arguments_[index + 1];
901
1010
  if (
@@ -948,10 +1057,11 @@ function parseArguments(arguments_) {
948
1057
  // origin the sign-in belongs to — so they accept it. whoami is offline and
949
1058
  // reads both the store and the configuration, so it accepts it too: the test
950
1059
  // is "reads nothing local", not "makes no request".
951
- if (
952
- verb.offline && !verb.readsLocalState && configPath !== undefined
953
- && !(verb.name === VERB_VALIDATE && rest.includes("--init"))
954
- ) {
1060
+ // validate reads the configuration in two cases: --init, and no directory,
1061
+ // which checks the workspace the configuration names.
1062
+ const validateReadsConfig = verb.name === VERB_VALIDATE
1063
+ && (rest.includes("--init") || rest.every((argument) => argument.startsWith("-")));
1064
+ if (verb.offline && !verb.readsLocalState && configPath !== undefined && !validateReadsConfig) {
955
1065
  throw usageError("cli.config_option", "--config applies only to verbs that read the site configuration.", {
956
1066
  field: "configPath",
957
1067
  });
@@ -969,6 +1079,7 @@ function parseArguments(arguments_) {
969
1079
  let dryRun = false;
970
1080
  let json = false;
971
1081
  let keyName;
1082
+ let planHash;
972
1083
  const positionals = [];
973
1084
  for (let index = 0; index < rest.length; index += 1) {
974
1085
  const argument = rest[index];
@@ -1035,6 +1146,12 @@ function parseArguments(arguments_) {
1035
1146
  json = true;
1036
1147
  continue;
1037
1148
  }
1149
+ if (verb.planHash && argument === "--plan") {
1150
+ if (planHash !== undefined) throw usageError("cli.duplicate_option", "--plan may be supplied only once.");
1151
+ planHash = parsePlanOption(rest, index);
1152
+ index += 1;
1153
+ continue;
1154
+ }
1038
1155
  if (verb.keyName && argument === "--name") {
1039
1156
  if (keyName !== undefined) throw usageError("cli.duplicate_option", "--name may be supplied only once.");
1040
1157
  keyName = parseNameOption(rest, index);
@@ -1066,13 +1183,10 @@ function parseArguments(arguments_) {
1066
1183
  `${verb.tokens.join(" ")} requires exactly one of --staging or --production.`,
1067
1184
  );
1068
1185
  }
1069
- if (
1070
- verb.positionals === "fixturePath"
1071
- && positionals.length !== 1
1072
- ) {
1186
+ if (verb.positionals === "fixturePath" && (positionals.length > 1 || (init && positionals.length !== 1))) {
1073
1187
  throw usageError(
1074
1188
  "validate.fixture_path_invalid",
1075
- "validate requires exactly one fixture directory.",
1189
+ init ? "validate --init requires exactly one new fixture directory." : "validate accepts at most one directory.",
1076
1190
  { field: "fixturePath" },
1077
1191
  );
1078
1192
  }
@@ -1120,6 +1234,11 @@ function parseArguments(arguments_) {
1120
1234
  { field: "environmentSelector" },
1121
1235
  );
1122
1236
  }
1237
+ if (verb.planHash && planHash === undefined) {
1238
+ throw usageError("apply.plan_required", "apply requires --plan with the planHash that 'plan' reported.", {
1239
+ field: "planHash",
1240
+ });
1241
+ }
1123
1242
  if (quiet && verb.name === VERB_LOGIN) {
1124
1243
  // The approval URL and user code reach the operator only as progress
1125
1244
  // lines, and the JSON result is serialized only after polling ends — so a
@@ -1161,6 +1280,7 @@ function parseArguments(arguments_) {
1161
1280
  init,
1162
1281
  dryRun,
1163
1282
  keyName,
1283
+ ...(planHash === undefined ? {} : { planHash }),
1164
1284
  positionals: verb.positionals
1165
1285
  ? {
1166
1286
  key: verb.positionals,
@@ -1244,6 +1364,11 @@ export async function runCli({
1244
1364
  quiet: parsed.quiet,
1245
1365
  dryRun: parsed.dryRun,
1246
1366
  keyName: parsed.keyName,
1367
+ ...(parsed.planHash === undefined ? {} : { planHash: parsed.planHash }),
1368
+ // Only when given, like the other optional fields, so each verb keeps its default (TR00925).
1369
+ ...(parsed.deliveryUrl === undefined ? {} : { deliveryUrl: parsed.deliveryUrl }),
1370
+ ...(parsed.propagationWaitSeconds === undefined ? {} : { propagationWaitSeconds: parsed.propagationWaitSeconds }),
1371
+ ...(parsed.browser === undefined ? {} : { browser: parsed.browser }),
1247
1372
  capabilities: parsed.capabilities,
1248
1373
  surface: parsed.surface,
1249
1374
  surfaceCapabilities: parsed.surfaceCapabilities,
package/src/constants.js CHANGED
@@ -103,6 +103,12 @@ export const VERB_STAGING_REVIEW = "staging review";
103
103
  export const VERB_SITES = "sites";
104
104
  export const VERB_USE = "use";
105
105
  export const VERB_WHOAMI = "whoami";
106
+ export const VERB_PLAN = "plan";
107
+ export const VERB_APPLY = "apply";
108
+ export const VERB_PLACES_SEARCH = "places search";
109
+ export const VERB_PLACES_SELECT = "places select";
110
+ /** The identity `plan` reports and `apply --plan` requires. */
111
+ export const PLAN_HASH = /^sha256:[0-9a-f]{64}$/u;
106
112
  export const VERB_ENV = "env";
107
113
 
108
114
  // Which authoring verbs a site accepts (TR00790), as `use` records it in the