@taprootio/site-authoring 0.15.0 → 0.18.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.
@@ -0,0 +1,170 @@
1
+ import { SiteAuthoringError } from "./errors.js";
2
+
3
+ /**
4
+ * Who a page can be credited to (TR01196).
5
+ *
6
+ * A page's author is one of two kinds of person, named by one string:
7
+ *
8
+ * - a site member who can create pages, by email address; or
9
+ * - a site author without a Taproot account, by handle. Handles are a
10
+ * lowercase slug the site fixes when the author is created, so a page keeps
11
+ * pointing at the same person if their display name changes.
12
+ *
13
+ * The two spellings cannot collide: a handle never contains '@'. The site
14
+ * folds case on both (an address is stored lowercase, a handle is lowercase),
15
+ * so this module does too, and compares only folded values.
16
+ */
17
+
18
+ export const AUTHORS_FILE_NAME = "authors.json";
19
+
20
+ export const AUTHOR_HANDLE_MAXIMUM_LENGTH = 64;
21
+ export const AUTHOR_DISPLAY_NAME_MAXIMUM_LENGTH = 256;
22
+ export const AUTHOR_EMAIL_MAXIMUM_LENGTH = 320;
23
+
24
+ const HANDLE = /^[a-z0-9]+(-[a-z0-9]+)*$/u;
25
+ // Deliberately loose: the site canonicalizes and decides. This only tells an
26
+ // address from a handle and refuses text that cannot be one.
27
+ const EMAIL = /^[^\s@<>()]+@[^\s@<>()]+\.[^\s@<>()]+$/u;
28
+
29
+ export const AUTHOR_KIND_HANDLE = "handle";
30
+ export const AUTHOR_KIND_EMAIL = "email";
31
+
32
+ /** The folded handle, or undefined when the text is not a handle. */
33
+ export function normalizeAuthorHandle(value) {
34
+ if (typeof value !== "string") return undefined;
35
+ const handle = value.trim().toLowerCase();
36
+ return handle.length > 0 && handle.length <= AUTHOR_HANDLE_MAXIMUM_LENGTH && HANDLE.test(handle)
37
+ ? handle
38
+ : undefined;
39
+ }
40
+
41
+ /** The folded address, or undefined when the text is not an email address. */
42
+ export function normalizeAuthorEmail(value) {
43
+ if (typeof value !== "string") return undefined;
44
+ const email = value.trim().toLowerCase();
45
+ return email.length <= AUTHOR_EMAIL_MAXIMUM_LENGTH && EMAIL.test(email) ? email : undefined;
46
+ }
47
+
48
+ /**
49
+ * What an `author` reference names: `{ kind, value }` with the folded value, or
50
+ * undefined for text that is neither a handle nor an email address.
51
+ */
52
+ export function parseAuthorReference(value) {
53
+ if (typeof value !== "string") return undefined;
54
+ if (value.includes("@")) {
55
+ const email = normalizeAuthorEmail(value);
56
+ return email === undefined ? undefined : { kind: AUTHOR_KIND_EMAIL, value: email };
57
+ }
58
+ const handle = normalizeAuthorHandle(value);
59
+ return handle === undefined ? undefined : { kind: AUTHOR_KIND_HANDLE, value: handle };
60
+ }
61
+
62
+ /** The folded reference, or a refusal that names where it was written. */
63
+ export function requireAuthorReference(value, field, code = "pages.author_invalid") {
64
+ const reference = parseAuthorReference(value);
65
+ if (reference === undefined) {
66
+ throw new SiteAuthoringError(
67
+ code,
68
+ `'${typeof value === "string" ? value.slice(0, 80) : ""}' is not an author reference. Name a site author by `
69
+ + "handle (lowercase letters, digits and hyphens, such as jane-doe) or a site member by email address.",
70
+ { field },
71
+ );
72
+ }
73
+ return reference.value;
74
+ }
75
+
76
+ function boundedText(value, maximum) {
77
+ return typeof value === "string" && value.length <= maximum ? value : undefined;
78
+ }
79
+
80
+ /**
81
+ * Reads the site's author listing, refusing a response that does not have the
82
+ * shape this CLI relies on. A member's address is only present for a caller
83
+ * allowed to see it; the CLI's credential is, so an absent one is dropped.
84
+ */
85
+ export function normalizeAuthorListing(value) {
86
+ const listing = value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
87
+ const authors = [];
88
+ for (const raw of Array.isArray(listing.authors) ? listing.authors : []) {
89
+ const handle = normalizeAuthorHandle(raw?.handle);
90
+ if (handle === undefined) continue;
91
+ authors.push({
92
+ handle,
93
+ displayName: boundedText(raw.displayName, AUTHOR_DISPLAY_NAME_MAXIMUM_LENGTH) ?? "",
94
+ hasEmail: raw.hasEmail === true || (typeof raw.email === "string" && raw.email !== ""),
95
+ });
96
+ }
97
+ const members = [];
98
+ for (const raw of Array.isArray(listing.members) ? listing.members : []) {
99
+ const email = normalizeAuthorEmail(raw?.email);
100
+ if (email === undefined) continue;
101
+ members.push({
102
+ email,
103
+ displayName: boundedText(raw.displayName, AUTHOR_DISPLAY_NAME_MAXIMUM_LENGTH) ?? "",
104
+ });
105
+ }
106
+ return { authors, members };
107
+ }
108
+
109
+ /**
110
+ * The workspace file `pull` and `authors list` write. Site authors carry no
111
+ * address: it is private to the site and nothing local needs it. Members carry
112
+ * theirs, because that is how a page names them.
113
+ */
114
+ export function projectAuthorsForWorkspace(siteId, listing) {
115
+ return {
116
+ siteId,
117
+ authors: listing.authors.map(({ handle, displayName }) => ({ handle, displayName })),
118
+ members: listing.members.map(({ email, displayName }) => ({ email, displayName })),
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Reads `authors.json` back. Anything unreadable or bound to another site is
124
+ * treated as absent: it can only ever make a reference unprovable offline,
125
+ * never turn a valid one into a refusal.
126
+ */
127
+ export function readAuthorsDocument(value, siteId) {
128
+ if (value === null || typeof value !== "object" || Array.isArray(value) || value.siteId !== siteId) {
129
+ return undefined;
130
+ }
131
+ const handles = new Set();
132
+ for (const author of Array.isArray(value.authors) ? value.authors : []) {
133
+ const handle = normalizeAuthorHandle(author?.handle);
134
+ if (handle !== undefined) handles.add(handle);
135
+ }
136
+ const emails = new Set();
137
+ for (const member of Array.isArray(value.members) ? value.members : []) {
138
+ const email = normalizeAuthorEmail(member?.email);
139
+ if (email !== undefined) emails.add(email);
140
+ }
141
+ return { handles, emails };
142
+ }
143
+
144
+ /**
145
+ * Whether a folded reference names someone the recorded listing knows, or
146
+ * `undefined` when there is no listing to ask.
147
+ */
148
+ export function authorKnown(authors, reference) {
149
+ if (authors === undefined) return undefined;
150
+ const parsed = parseAuthorReference(reference);
151
+ if (parsed === undefined) return false;
152
+ return parsed.kind === AUTHOR_KIND_EMAIL ? authors.emails.has(parsed.value) : authors.handles.has(parsed.value);
153
+ }
154
+
155
+ /**
156
+ * The refusal text for a source that names a different author than the site
157
+ * holds. The way out depends on where the author is written: a `.pm.json`
158
+ * page keeps it in the workspace manifest, edited with `pages meta set`; a
159
+ * Markdown page keeps it in its front matter (TR01196).
160
+ */
161
+ export function authorConflictMessage({ shown, file, held, wanted, markdown }) {
162
+ const lead = `Page '${shown}' already has the author '${held}'`
163
+ + (wanted === undefined ? "" : `, and '${file}' names '${wanted}'`)
164
+ + ". An author is never replaced from the CLI";
165
+ return markdown
166
+ ? `${lead}: set 'author: ${held}' in the front matter, or remove the line, to match the site. To credit someone `
167
+ + "else, change it in the app."
168
+ : `${lead}: run 'taproot-site pages meta set ${shown} --author ${held}' to match the site. To credit someone `
169
+ + "else, change it in the app.";
170
+ }
package/src/cli.js CHANGED
@@ -17,6 +17,8 @@ import {
17
17
  SURFACE_STANDARD,
18
18
  VERB_APPLY,
19
19
  VERB_APPROVE,
20
+ VERB_AUTHORS_ADD,
21
+ VERB_AUTHORS_LIST,
20
22
  VERB_DELIVERY_CHECK,
21
23
  VERB_DEPLOY,
22
24
  VERB_STAGING_REVIEW,
@@ -30,7 +32,11 @@ import {
30
32
  VERB_LOGOUT,
31
33
  VERB_MEDIA_UPLOAD,
32
34
  VERB_NAV_PUSH,
35
+ VERB_PAGES_META_SET,
33
36
  VERB_PAGES_PUSH,
37
+ VERB_PLACES_CATEGORY_CLEAR,
38
+ VERB_PLACES_CATEGORY_LIST,
39
+ VERB_PLACES_CATEGORY_SET,
34
40
  VERB_PLACES_SEARCH,
35
41
  VERB_PLACES_SELECT,
36
42
  VERB_PLAN,
@@ -109,8 +115,10 @@ const VERBS = Object.freeze([
109
115
  + "is either an offline fixture (manifest.fixture.json) or a pulled workspace. Every page source, the theme, "
110
116
  + "appearance, header, brand, footer, navigation, redirects, and forms are checked with the validators the "
111
117
  + "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 "
118
+ + "rather than one per run. A pulled workspace is checked offline against the pages (generated ones included, "
119
+ + "so navigation to a section or tag page resolves), images, and baselines its manifests recorded at the pull. "
120
+ + "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. This proves local structure and semantics only. It does not prove "
114
122
  + "authorization, live site ownership, concurrency, persisted round trips, or rendering: 'plan' checks a "
115
123
  + "workspace against the live site, and 'pages push --dry-run' does that for pages alone. "
116
124
  + `See '${CLI_BINARY_NAME} help fixture' for the fixture manifest contract and for the path of the complete `
@@ -228,7 +236,64 @@ const VERBS = Object.freeze([
228
236
  + "site's version under '.taproot-site-state/', and leaves you to either push the local source or delete it "
229
237
  + "and pull again. A locally edited '.pm.json' is kept rather than overwritten for the same reason. "
230
238
  + "First revisions adopted without a completed body comparison are counted in pages.revisionsRecordedWithoutBodyComparison "
231
- + "and announced on the human channel. New downloads and compared bodies are excluded.",
239
+ + "and announced on the human channel. New downloads and compared bodies are excluded. "
240
+ + "A page Taproot generates (a tag, an archive, a place, a folder) is pulled as an update-only '.pm.json' "
241
+ + "(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.",
243
+ },
244
+ {
245
+ name: VERB_PAGES_META_SET,
246
+ tokens: ["pages", "meta", "set"],
247
+ offline: true,
248
+ // Offline, but it reads taproot-site.json and edits the workspace manifest.
249
+ readsLocalState: true,
250
+ writesWorkspace: true,
251
+ metaOptions: true,
252
+ positionals: "metaPagePath",
253
+ summary: "Set a tracked page's title, description or author in the workspace; 'pages push' sends it.",
254
+ note: "Names one page by its path ('/' for the homepage) and sets --title, --description, --author, or any of "
255
+ + "them, in the workspace's record of that page. A .pm.json page keeps its title, description and author "
256
+ + "there, so this is the supported way to change them; a Markdown page keeps them in its front matter, which "
257
+ + "is edited instead. --author names a site author by handle or a member by email address (see 'authors "
258
+ + "list'); it only fills a page that has no author, and a page that already has a different one is refused "
259
+ + "(pages.author_conflict). A name missing from authors.json is only warned about (authors.json can be older "
260
+ + "than the site's list); 'pages push' checks it against the site. --author \"\" drops an author that has not "
261
+ + "been sent; it never removes the site's. "
262
+ + "A generated page keeps its own in its source (data.customTitle and data.customDescription), which this "
263
+ + "edits; an empty --title or --description there clears the custom value so the page uses its generated one. "
264
+ + "A description longer than 160 characters is warned about (search results cut it off) and one longer than "
265
+ + "1000 is refused. Nothing is sent: run 'pages push', then 'approve'.",
266
+ },
267
+ {
268
+ name: VERB_AUTHORS_LIST,
269
+ surface: SURFACE_STANDARD,
270
+ // Naming an author is part of writing a page, and the site gates both the
271
+ // list and the creation on the permission that creates pages (TR01196).
272
+ capabilities: [CAPABILITY_CONTENT],
273
+ tokens: ["authors", "list"],
274
+ summary: "List the site authors and the members a page can be credited to; refreshes authors.json.",
275
+ note: "A page's author is a site author without a Taproot account (named by handle, such as jane-doe) or a site "
276
+ + "member who can create pages (named by email address). Pages are authorless unless a source names one, and "
277
+ + "an author already on a page is never replaced. The result lists authors { handle, displayName, hasEmail } "
278
+ + "and members { email, displayName }, and rewrites authors.json, which the offline checks ('validate', "
279
+ + "'pages meta set') read to warn about a name it lacks. authors.json holds members' email addresses: keep it "
280
+ + "out of a public repository. "
281
+ + "A site author's own address is private and is never listed or written.",
282
+ },
283
+ {
284
+ name: VERB_AUTHORS_ADD,
285
+ surface: SURFACE_STANDARD,
286
+ capabilities: [CAPABILITY_CONTENT],
287
+ tokens: ["authors", "add"],
288
+ positionals: "authorHandle",
289
+ authorOptions: true,
290
+ summary: "Create a site author, someone a page can be credited to who has no Taproot account.",
291
+ note: "Give the handle (lowercase letters, digits and single hyphens, at most 64 characters; it never changes) "
292
+ + "and --name, the name readers see on the byline. --email is optional: it is kept private, never published, "
293
+ + "and lets the person be linked to a Taproot account later. Then credit pages to the author with "
294
+ + "'author: <handle>' in a page's front matter or 'pages meta set <path> --author <handle>'. A handle the site "
295
+ + "already has under another name is refused (authors.handle_taken); under the same name it succeeds with "
296
+ + "existing: true. authors.json is updated with the new author.",
232
297
  },
233
298
  {
234
299
  name: VERB_PAGES_PUSH,
@@ -240,6 +305,10 @@ const VERBS = Object.freeze([
240
305
  positionals: "pagePaths",
241
306
  note:
242
307
  "Positional page paths narrow the push to those pages; with none, every workspace page is validated and sent. "
308
+ + "A link in a page being sent whose address is only http(s):// plus its own text is reported in linkWarnings "
309
+ + "(content.link_autolinked) without refusing. "
310
+ + "A page is created without an author unless its source names one ('author:' in the front matter, or 'pages "
311
+ + "meta set --author'); the pages created that way are listed in pages.authorless with how to credit them. "
243
312
  + "The homepage is recorded with an empty path, so address it as '/'. "
244
313
  + "A selected path resolves to its one authoritative source from metadata alone, and that page is then validated "
245
314
  + "exactly as a whole push would validate it — site binding, manifest integrity, live create-or-update "
@@ -253,6 +322,10 @@ const VERBS = Object.freeze([
253
322
  + "error.problems with its file, code, and field. --dry-run does all of that against the live site, reports "
254
323
  + "which pages would be created, updated, or left unchanged, and sends nothing; it exits 1 when there are "
255
324
  + "problems. "
325
+ + "A generated page's source is update-only: a push refuses to create one (pages.generated_create), move it "
326
+ + "(pages.generated_move), or change what it is (pages.generated_identity). A generated page you did not edit "
327
+ + "never blocks a whole-workspace push, and one the site no longer has is reported as pages.staleGeneratedSources "
328
+ + "and skipped (naming its path refuses with pages.generated_create). "
256
329
  + "See 'taproot-site help page free-form' for the stable manifest and error contract.",
257
330
  },
258
331
  {
@@ -280,6 +353,39 @@ const VERBS = Object.freeze([
280
353
  + "once for every site, so selecting it again returns the same placeId. Use it as placeId in a place review, "
281
354
  + "or as place in an article or album.",
282
355
  },
356
+ {
357
+ name: VERB_PLACES_CATEGORY_SET,
358
+ surface: SURFACE_STANDARD,
359
+ // Changes what the site's place reviews publish, so it needs page editing.
360
+ capabilities: [CAPABILITY_CONTENT],
361
+ tokens: ["places", "category", "set"],
362
+ positionals: "placeCategory",
363
+ summary: "Set this site's category for a place, from the closed category list.",
364
+ note: "Takes the placeId from 'places select' (or a page's front matter), then one category from "
365
+ + "'places category list', which prints the closed list; a name outside it is refused, and capitalization is "
366
+ + "ignored. Use it when Google's own category for a place is not the one this site wants, such as a park filed "
367
+ + "under Other. The category is this site's alone and applies to every review of that place here: the place "
368
+ + "itself and other sites do not change. Place-review bylines and the /places/<category> pages use it from the "
369
+ + "next deployment, which a changed category alone is enough to start. Undo it with 'places category clear'.",
370
+ },
371
+ {
372
+ name: VERB_PLACES_CATEGORY_CLEAR,
373
+ surface: SURFACE_STANDARD,
374
+ capabilities: [CAPABILITY_CONTENT],
375
+ tokens: ["places", "category", "clear"],
376
+ positionals: "placeId",
377
+ summary: "Remove this site's category for a place, so the place's own category applies again.",
378
+ note: "Takes the placeId. Clearing a place this site never set succeeds and changes nothing. Pages use the "
379
+ + "place's own category from the next deployment.",
380
+ },
381
+ {
382
+ name: VERB_PLACES_CATEGORY_LIST,
383
+ surface: SURFACE_STANDARD,
384
+ capabilities: [CAPABILITY_CONTENT],
385
+ tokens: ["places", "category", "list"],
386
+ summary: "List the place categories 'places category set' accepts.",
387
+ note: "The list is closed: a category outside it is refused.",
388
+ },
283
389
  {
284
390
  name: VERB_PLAN,
285
391
  surface: SURFACE_STANDARD,
@@ -330,9 +436,9 @@ const VERBS = Object.freeze([
330
436
  capabilities: [CAPABILITY_CONTENT, CAPABILITY_DEPLOYMENTS],
331
437
  tokens: ["redirects", "check"],
332
438
  summary: "Check the current redirect map through the authenticated staging edge.",
333
- note: "Reports path, HTTP status and Location without following redirects. A redirect is live only with the release "
334
- + "that carries it, so this reads the staged release. "
335
- + "The result includes a fresh single-use staging handoff; keep it private.",
439
+ note: "Reports each entry's path, HTTP status and Location in redirects.items, and redirects.verified, without "
440
+ + "following redirects. A redirect is live only with the release that carries it, so this reads the staged release. "
441
+ + "The result includes a fresh single-use staging handoff in stagingPreview.url; keep it private.",
336
442
  },
337
443
  {
338
444
  name: VERB_REDIRECTS_PULL,
@@ -457,7 +563,10 @@ const VERBS = Object.freeze([
457
563
  positionals: "pagePaths",
458
564
  note: "Positional arguments narrow the selection to those page paths;"
459
565
  + " with none, every draft the workspace manifest tracks is staged."
460
- + " The homepage is recorded with an empty path, so address it as '/'.",
566
+ + " The homepage is recorded with an empty path, so address it as '/'."
567
+ + " A named page with no pending draft (already approved, or published with no new draft) is listed in"
568
+ + " skipped { total, items: [{ pageId, path, status, reason }] } and the rest are approved; a path that names"
569
+ + " no page this workspace tracks refuses the whole call.",
461
570
  },
462
571
  {
463
572
  name: VERB_DEPLOY,
@@ -479,7 +588,8 @@ const VERBS = Object.freeze([
479
588
  note: "deploy --staging returns a single-use review handoff in stagingPreview.url, on a managed Docs site too, "
480
589
  + "where it stages settings only: open the URL once, then switch the site's theme toggle to review both "
481
590
  + "schemes before deploy --production. If no handoff could be minted, stagingPreview.reason names why and "
482
- + "stagingPreview.recovery the next step; 'staging review' mints another.",
591
+ + "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').",
483
593
  },
484
594
  {
485
595
  name: VERB_PREVIEW_PAGE,
@@ -639,7 +749,7 @@ upgrade. Once a run has recorded a newer release, help, validate, and whoami
639
749
  refuse the same way offline. --version and --help always answer.
640
750
 
641
751
  Verbs:
642
- ${VERBS.map((verb) => ` ${verb.tokens.join(" ").padEnd(14)} ${verb.summary}`).join("\n")}
752
+ ${VERBS.map((verb) => ` ${verb.tokens.join(" ").padEnd(20)} ${verb.summary}`).join("\n")}
643
753
 
644
754
  Configuration:
645
755
  Site verbs read taproot-site.json, found by walking up from the current
@@ -682,6 +792,14 @@ function verbHelp(verb) {
682
792
  ? " <query...>"
683
793
  : verb.positionals === "placeSelection"
684
794
  ? " <google-place-id> <session-token>"
795
+ : verb.positionals === "placeCategory"
796
+ ? " <place-id> <category>"
797
+ : verb.positionals === "placeId"
798
+ ? " <place-id>"
799
+ : verb.positionals === "metaPagePath"
800
+ ? " <page-path> [--title <text>] [--description <text>] [--author <handle-or-email>]"
801
+ : verb.positionals === "authorHandle"
802
+ ? " <handle> --name <display-name> [--email <address>]"
685
803
  : verb.positionals
686
804
  ? ` [${verb.positionals === "paths" ? "path" : "page-path"}...]`
687
805
  : "";
@@ -695,6 +813,15 @@ function verbHelp(verb) {
695
813
  --production Promote the completed staging deployment to production.
696
814
  --allow-failed-preview Explicitly override the matching candidate's failed preview.`
697
815
  : "";
816
+ const metaOptions = verb.metaOptions
817
+ ? `\n --title <text> The page's title.
818
+ --description <text> The page's description for search results and link previews; '' clears it.
819
+ --author <ref> Credit a page that has no author: a site author's handle or a member's email address.`
820
+ : "";
821
+ const authorOptions = verb.authorOptions
822
+ ? `\n --name <text> The name readers see on the byline (required).
823
+ --email <address> A private address, kept to link the person to an account later (optional).`
824
+ : "";
698
825
  const jsonOption = verb.json
699
826
  ? "\n --json Emit the stable JSON contract (operational output is always JSON)."
700
827
  : "";
@@ -733,6 +860,9 @@ function verbHelp(verb) {
733
860
  : selfContained
734
861
  ? "This offline verb uses no credential and reads no configuration, and performs no network request and no "
735
862
  + `write.${upgradeGate}`
863
+ : verb.writesWorkspace
864
+ ? "This verb edits only the local workspace: it reads the configuration, uses no credential, and makes no "
865
+ + "network request."
736
866
  : verb.offline
737
867
  ? "This verb answers entirely from local state — the stored sign-in and the configuration — and makes no "
738
868
  + `network request and no write.${upgradeGate}`
@@ -773,7 +903,7 @@ function verbHelp(verb) {
773
903
  ${verb.summary}
774
904
  ${boundary}
775
905
 
776
- ${options}${targetOption}${deliveryTargetOption}${jsonOption}${dryRunOption}${planOption}${nameOption}${note}
906
+ ${options}${targetOption}${deliveryTargetOption}${metaOptions}${authorOptions}${jsonOption}${dryRunOption}${planOption}${nameOption}${note}
777
907
  `;
778
908
  }
779
909
 
@@ -1005,6 +1135,45 @@ function parsePlanOption(arguments_, index) {
1005
1135
  return candidate;
1006
1136
  }
1007
1137
 
1138
+ /**
1139
+ * The text after --title or --description. Either may be empty, which clears a
1140
+ * generated page's custom value; a page with its own title refuses an empty one.
1141
+ */
1142
+ function parseMetaOption(arguments_, index, option) {
1143
+ const candidate = arguments_[index + 1];
1144
+ if (
1145
+ typeof candidate !== "string"
1146
+ || Buffer.byteLength(candidate, "utf8") > 8 * 1024
1147
+ || hasAsciiControl(candidate)
1148
+ ) {
1149
+ throw usageError(
1150
+ "cli.meta_option",
1151
+ option === "--title"
1152
+ ? "--title requires one printable title (an empty one clears a generated page's custom title)."
1153
+ : "--description requires one printable description (an empty one clears it).",
1154
+ { field: option.slice(2) },
1155
+ );
1156
+ }
1157
+ return candidate;
1158
+ }
1159
+
1160
+ /** The text after --author, --name (authors add) or --email: one printable value, bounded. */
1161
+ function parseAuthorOption(arguments_, index, option, { allowEmpty = false } = {}) {
1162
+ const candidate = arguments_[index + 1];
1163
+ if (
1164
+ typeof candidate !== "string"
1165
+ || (candidate.length === 0 && !allowEmpty)
1166
+ || Buffer.byteLength(candidate, "utf8") > 2 * 1024
1167
+ || hasAsciiControl(candidate)
1168
+ || candidate.startsWith("--")
1169
+ ) {
1170
+ throw usageError("cli.author_option", `${option} requires exactly one printable value.`, {
1171
+ field: option.slice(2),
1172
+ });
1173
+ }
1174
+ return candidate;
1175
+ }
1176
+
1008
1177
  function parseNameOption(arguments_, index) {
1009
1178
  const candidate = arguments_[index + 1];
1010
1179
  if (
@@ -1080,6 +1249,11 @@ function parseArguments(arguments_) {
1080
1249
  let json = false;
1081
1250
  let keyName;
1082
1251
  let planHash;
1252
+ let metaTitle;
1253
+ let metaDescription;
1254
+ let metaAuthor;
1255
+ let authorName;
1256
+ let authorEmail;
1083
1257
  const positionals = [];
1084
1258
  for (let index = 0; index < rest.length; index += 1) {
1085
1259
  const argument = rest[index];
@@ -1152,6 +1326,39 @@ function parseArguments(arguments_) {
1152
1326
  index += 1;
1153
1327
  continue;
1154
1328
  }
1329
+ if (verb.metaOptions && (argument === "--title" || argument === "--description")) {
1330
+ const value = parseMetaOption(rest, index, argument);
1331
+ if (argument === "--title") {
1332
+ if (metaTitle !== undefined) throw usageError("cli.duplicate_option", "--title may be supplied only once.");
1333
+ metaTitle = value;
1334
+ } else {
1335
+ if (metaDescription !== undefined) {
1336
+ throw usageError("cli.duplicate_option", "--description may be supplied only once.");
1337
+ }
1338
+ metaDescription = value;
1339
+ }
1340
+ index += 1;
1341
+ continue;
1342
+ }
1343
+ if (verb.metaOptions && argument === "--author") {
1344
+ if (metaAuthor !== undefined) throw usageError("cli.duplicate_option", "--author may be supplied only once.");
1345
+ // An empty value clears a pending author that was never sent.
1346
+ metaAuthor = parseAuthorOption(rest, index, argument, { allowEmpty: true });
1347
+ index += 1;
1348
+ continue;
1349
+ }
1350
+ if (verb.authorOptions && (argument === "--name" || argument === "--email")) {
1351
+ const value = parseAuthorOption(rest, index, argument);
1352
+ if (argument === "--name") {
1353
+ if (authorName !== undefined) throw usageError("cli.duplicate_option", "--name may be supplied only once.");
1354
+ authorName = value;
1355
+ } else {
1356
+ if (authorEmail !== undefined) throw usageError("cli.duplicate_option", "--email may be supplied only once.");
1357
+ authorEmail = value;
1358
+ }
1359
+ index += 1;
1360
+ continue;
1361
+ }
1155
1362
  if (verb.keyName && argument === "--name") {
1156
1363
  if (keyName !== undefined) throw usageError("cli.duplicate_option", "--name may be supplied only once.");
1157
1364
  keyName = parseNameOption(rest, index);
@@ -1234,6 +1441,28 @@ function parseArguments(arguments_) {
1234
1441
  { field: "environmentSelector" },
1235
1442
  );
1236
1443
  }
1444
+ if (verb.positionals === "metaPagePath" && positionals.length !== 1) {
1445
+ throw usageError("pages.meta_path_invalid", "pages meta set names exactly one page path ('/' for the homepage).", {
1446
+ field: "pagePath",
1447
+ });
1448
+ }
1449
+ if (verb.metaOptions && metaTitle === undefined && metaDescription === undefined && metaAuthor === undefined) {
1450
+ throw usageError(
1451
+ "pages.meta_nothing_to_set",
1452
+ "pages meta set needs --title, --description, --author, or any of them.",
1453
+ { field: "title" },
1454
+ );
1455
+ }
1456
+ if (verb.positionals === "authorHandle" && positionals.length !== 1) {
1457
+ throw usageError("authors.handle_missing", "authors add needs exactly one handle, such as jane-doe.", {
1458
+ field: "handle",
1459
+ });
1460
+ }
1461
+ if (verb.authorOptions && authorName === undefined) {
1462
+ throw usageError("authors.name_missing", "authors add needs --name, the name readers see on the byline.", {
1463
+ field: "name",
1464
+ });
1465
+ }
1237
1466
  if (verb.planHash && planHash === undefined) {
1238
1467
  throw usageError("apply.plan_required", "apply requires --plan with the planHash that 'plan' reported.", {
1239
1468
  field: "planHash",
@@ -1281,12 +1510,18 @@ function parseArguments(arguments_) {
1281
1510
  dryRun,
1282
1511
  keyName,
1283
1512
  ...(planHash === undefined ? {} : { planHash }),
1513
+ ...(metaTitle === undefined ? {} : { metaTitle }),
1514
+ ...(metaDescription === undefined ? {} : { metaDescription }),
1515
+ ...(metaAuthor === undefined ? {} : { metaAuthor }),
1516
+ ...(authorName === undefined ? {} : { authorName }),
1517
+ ...(authorEmail === undefined ? {} : { authorEmail }),
1284
1518
  positionals: verb.positionals
1285
1519
  ? {
1286
1520
  key: verb.positionals,
1287
1521
  values: positionals,
1288
- scalar: verb.positionals === "pageSelector" || verb.positionals === "fixturePath"
1289
- || verb.positionals === "siteSelector" || verb.positionals === "environmentSelector",
1522
+ scalar: verb.positionals === "pageSelector" || verb.positionals === "fixturePath" || verb.positionals === "metaPagePath"
1523
+ || verb.positionals === "siteSelector" || verb.positionals === "environmentSelector"
1524
+ || verb.positionals === "authorHandle",
1290
1525
  }
1291
1526
  : undefined,
1292
1527
  };
@@ -1369,6 +1604,11 @@ export async function runCli({
1369
1604
  ...(parsed.deliveryUrl === undefined ? {} : { deliveryUrl: parsed.deliveryUrl }),
1370
1605
  ...(parsed.propagationWaitSeconds === undefined ? {} : { propagationWaitSeconds: parsed.propagationWaitSeconds }),
1371
1606
  ...(parsed.browser === undefined ? {} : { browser: parsed.browser }),
1607
+ ...(parsed.metaTitle === undefined ? {} : { metaTitle: parsed.metaTitle }),
1608
+ ...(parsed.metaDescription === undefined ? {} : { metaDescription: parsed.metaDescription }),
1609
+ ...(parsed.metaAuthor === undefined ? {} : { metaAuthor: parsed.metaAuthor }),
1610
+ ...(parsed.authorName === undefined ? {} : { authorName: parsed.authorName }),
1611
+ ...(parsed.authorEmail === undefined ? {} : { authorEmail: parsed.authorEmail }),
1372
1612
  capabilities: parsed.capabilities,
1373
1613
  surface: parsed.surface,
1374
1614
  surfaceCapabilities: parsed.surfaceCapabilities,
package/src/constants.js CHANGED
@@ -83,6 +83,9 @@ export const VERB_VALIDATE = "validate";
83
83
  export const VERB_LOGIN = "login";
84
84
  export const VERB_LOGOUT = "logout";
85
85
  export const VERB_PAGES_PUSH = "pages push";
86
+ export const VERB_PAGES_META_SET = "pages meta set";
87
+ export const VERB_AUTHORS_LIST = "authors list";
88
+ export const VERB_AUTHORS_ADD = "authors add";
86
89
  export const VERB_NAV_PUSH = "nav push";
87
90
  export const VERB_REDIRECTS_CHECK = "redirects check";
88
91
  export const VERB_REDIRECTS_PULL = "redirects pull";
@@ -107,6 +110,9 @@ export const VERB_PLAN = "plan";
107
110
  export const VERB_APPLY = "apply";
108
111
  export const VERB_PLACES_SEARCH = "places search";
109
112
  export const VERB_PLACES_SELECT = "places select";
113
+ export const VERB_PLACES_CATEGORY_SET = "places category set";
114
+ export const VERB_PLACES_CATEGORY_CLEAR = "places category clear";
115
+ export const VERB_PLACES_CATEGORY_LIST = "places category list";
110
116
  /** The identity `plan` reports and `apply --plan` requires. */
111
117
  export const PLAN_HASH = /^sha256:[0-9a-f]{64}$/u;
112
118
  export const VERB_ENV = "env";
@@ -201,6 +207,19 @@ export const CAPABILITY_REFUSAL_REASON = "SITE_AUTHORING_CAPABILITY_MISSING";
201
207
  */
202
208
  export const CAPABILITY_REFUSAL_FIELD = "GrantedCapabilities";
203
209
 
210
+ /**
211
+ * The `google.rpc.ErrorInfo` reasons a deploy carries when the site has hit a
212
+ * commercial ceiling that no retry can clear (TR00445): the deployments-per-
213
+ * billing-period allowance, or the bandwidth pause. Wire identities matched
214
+ * exactly, within {@link SITE_DEPLOYMENT_REFUSAL_DOMAIN}. The refusal is
215
+ * classified `plan_limit`, the same class as the page-count `UpgradePrompt`
216
+ * field, because the right next move is the same: upgrade, or wait for the
217
+ * reset the server names in `resets_at`.
218
+ */
219
+ export const DEPLOYMENT_ALLOWANCE_REFUSAL_REASON = "DEPLOYMENT_PERIOD_ALLOWANCE_EXCEEDED";
220
+ export const BANDWIDTH_ALLOWANCE_REFUSAL_REASON = "BANDWIDTH_ALLOWANCE_EXCEEDED";
221
+ export const SITE_DEPLOYMENT_REFUSAL_DOMAIN = "sites.taproot.io";
222
+
204
223
  // gRPC `ResourceExhausted`. Transcoded responses carry the numeric code in the
205
224
  // body; the HTTP mapping is 429.
206
225
  export const GRPC_RESOURCE_EXHAUSTED = 8;