@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.
- package/README.md +171 -11
- package/package.json +1 -1
- package/src/api.js +194 -11
- package/src/authors-contract.js +170 -0
- package/src/cli.js +252 -12
- package/src/constants.js +19 -0
- package/src/content/link-warnings.js +109 -0
- package/src/fixture-init.js +55 -3
- package/src/page-metadata.js +92 -0
- package/src/reference-help.js +58 -8
- package/src/staging-check.js +62 -0
- package/src/transport.js +73 -12
- package/src/typed-page-reference.js +89 -19
- package/src/typed-pages.js +165 -4
- package/src/verbs/approve.js +63 -14
- package/src/verbs/authors.js +167 -0
- package/src/verbs/deploy.js +29 -32
- package/src/verbs/index.js +18 -1
- package/src/verbs/media-upload.js +0 -1
- package/src/verbs/pages-meta-set.js +303 -0
- package/src/verbs/pages-push.js +419 -36
- package/src/verbs/places.js +72 -2
- package/src/verbs/plan.js +11 -1
- package/src/verbs/pull.js +255 -20
- package/src/verbs/redirects-check.js +4 -10
- package/src/verbs/status.js +1 -4
- package/src/verbs/validate.js +47 -2
- package/src/workspace.js +58 -6
|
@@ -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
|
|
113
|
-
+ "
|
|
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
|
|
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(
|
|
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;
|