@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,167 @@
|
|
|
1
|
+
import { createSiteAuthor, listSiteAuthors, withRefusalGuidance } from "../api.js";
|
|
2
|
+
import {
|
|
3
|
+
AUTHOR_DISPLAY_NAME_MAXIMUM_LENGTH,
|
|
4
|
+
AUTHOR_HANDLE_MAXIMUM_LENGTH,
|
|
5
|
+
AUTHORS_FILE_NAME,
|
|
6
|
+
normalizeAuthorEmail,
|
|
7
|
+
normalizeAuthorHandle,
|
|
8
|
+
projectAuthorsForWorkspace,
|
|
9
|
+
} from "../authors-contract.js";
|
|
10
|
+
import { VERB_AUTHORS_ADD, VERB_AUTHORS_LIST } from "../constants.js";
|
|
11
|
+
import { sanitizeDiagnostic, SiteAuthoringError } from "../errors.js";
|
|
12
|
+
import { boundedList, openSession, successResult } from "../session.js";
|
|
13
|
+
import { ApiError } from "../transport.js";
|
|
14
|
+
import { ensureWorkspaceRoot, writeWorkspaceJson } from "../workspace.js";
|
|
15
|
+
|
|
16
|
+
const MAXIMUM_REPORTED = 200;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `authors list` — who a page can be credited to (TR01196): the site's authors
|
|
20
|
+
* without a Taproot account and the members who can create pages. It also
|
|
21
|
+
* rewrites `authors.json`, the copy `validate` and `pages push` check an
|
|
22
|
+
* author reference against when they run offline.
|
|
23
|
+
*/
|
|
24
|
+
export async function authorsList(invocation) {
|
|
25
|
+
const { client, config, siteId, onProgress } = await openSession(invocation);
|
|
26
|
+
const listing = await withRefusalGuidance(onProgress, "authors list", () => listSiteAuthors(client, siteId));
|
|
27
|
+
await writeAuthorsFile(config, siteId, listing);
|
|
28
|
+
|
|
29
|
+
onProgress(
|
|
30
|
+
listing.authors.length === 0
|
|
31
|
+
? "This site has no site authors without a Taproot account."
|
|
32
|
+
: `Site authors (handle: name):\n${
|
|
33
|
+
listing.authors.map((author) => ` ${author.handle}: ${author.displayName}`).join("\n")
|
|
34
|
+
}`,
|
|
35
|
+
);
|
|
36
|
+
onProgress(
|
|
37
|
+
listing.members.length === 0
|
|
38
|
+
? "No member can be named as an author."
|
|
39
|
+
: `Members who can be named (email: name):\n${
|
|
40
|
+
listing.members.map((member) => ` ${member.email}: ${member.displayName}`).join("\n")
|
|
41
|
+
}`,
|
|
42
|
+
);
|
|
43
|
+
const authors = boundedList(listing.authors, MAXIMUM_REPORTED);
|
|
44
|
+
const members = boundedList(listing.members, MAXIMUM_REPORTED);
|
|
45
|
+
return successResult(VERB_AUTHORS_LIST, siteId, {
|
|
46
|
+
authorsFile: AUTHORS_FILE_NAME,
|
|
47
|
+
authors: {
|
|
48
|
+
total: listing.authors.length,
|
|
49
|
+
items: authors.items,
|
|
50
|
+
...(authors.truncated ? { itemsTruncated: true } : {}),
|
|
51
|
+
},
|
|
52
|
+
members: {
|
|
53
|
+
total: listing.members.length,
|
|
54
|
+
items: members.items,
|
|
55
|
+
...(members.truncated ? { itemsTruncated: true } : {}),
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `authors add` — creates a site author, someone a page can be credited to who
|
|
62
|
+
* has no Taproot account, such as one of an imported blog's authors.
|
|
63
|
+
*/
|
|
64
|
+
export async function authorsAdd(invocation) {
|
|
65
|
+
const handle = normalizeAuthorHandle(invocation.authorHandle);
|
|
66
|
+
if (handle === undefined) {
|
|
67
|
+
throw new SiteAuthoringError(
|
|
68
|
+
"authors.handle_invalid",
|
|
69
|
+
`'${String(invocation.authorHandle ?? "").slice(0, 80)}' is not a handle. Use lowercase letters and digits `
|
|
70
|
+
+ `with single hyphens between them, at most ${AUTHOR_HANDLE_MAXIMUM_LENGTH} characters, such as jane-doe.`,
|
|
71
|
+
{ field: "handle", exitCode: 2 },
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
const displayName = typeof invocation.authorName === "string" ? invocation.authorName.trim() : "";
|
|
75
|
+
if (displayName === "" || [...displayName].length > AUTHOR_DISPLAY_NAME_MAXIMUM_LENGTH) {
|
|
76
|
+
throw new SiteAuthoringError(
|
|
77
|
+
"authors.name_invalid",
|
|
78
|
+
`--name must be the name readers see, 1 to ${AUTHOR_DISPLAY_NAME_MAXIMUM_LENGTH} characters.`,
|
|
79
|
+
{ field: "name", exitCode: 2 },
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
let email;
|
|
83
|
+
if (invocation.authorEmail !== undefined) {
|
|
84
|
+
email = normalizeAuthorEmail(invocation.authorEmail);
|
|
85
|
+
if (email === undefined) {
|
|
86
|
+
throw new SiteAuthoringError("authors.email_invalid", "--email must be an email address.", {
|
|
87
|
+
field: "email",
|
|
88
|
+
exitCode: 2,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const { client, config, siteId, onProgress } = await openSession(invocation);
|
|
94
|
+
// A handle that is already taken by the same person is a repeat of this
|
|
95
|
+
// command, not a mistake: a script that adds its authors again succeeds.
|
|
96
|
+
let existing = false;
|
|
97
|
+
let known;
|
|
98
|
+
const created = await withRefusalGuidance(onProgress, "authors add", async () => {
|
|
99
|
+
try {
|
|
100
|
+
return await createSiteAuthor(client, siteId, { handle, displayName, email });
|
|
101
|
+
} catch (error) {
|
|
102
|
+
const refusal = translateAuthorsRefusal(error, handle);
|
|
103
|
+
if (refusal?.code !== "authors.handle_taken") throw refusal;
|
|
104
|
+
known = await listSiteAuthors(client, siteId);
|
|
105
|
+
const held = known.authors.find((author) => author.handle === handle);
|
|
106
|
+
if (held === undefined) throw refusal;
|
|
107
|
+
if (held.displayName !== displayName) {
|
|
108
|
+
throw new SiteAuthoringError(
|
|
109
|
+
"authors.handle_taken",
|
|
110
|
+
`A site author with the handle '${handle}' already exists, named '${held.displayName}', not `
|
|
111
|
+
+ `'${displayName}'. Use another handle, or the existing author.`,
|
|
112
|
+
{ field: "handle" },
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
existing = true;
|
|
116
|
+
return held;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
onProgress(
|
|
120
|
+
existing
|
|
121
|
+
? `Site author '${created.handle}' (${created.displayName}) already exists; nothing was created.`
|
|
122
|
+
: `Created site author '${created.handle}' (${created.displayName}).`,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// The listing is read back rather than patched, so authors.json is exactly
|
|
126
|
+
// what the site holds. Created is created: a failed refresh does not undo it.
|
|
127
|
+
let refreshed = false;
|
|
128
|
+
try {
|
|
129
|
+
await writeAuthorsFile(config, siteId, known ?? await listSiteAuthors(client, siteId));
|
|
130
|
+
refreshed = true;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (!(error instanceof SiteAuthoringError)) throw error;
|
|
133
|
+
onProgress(`The author was created, but ${AUTHORS_FILE_NAME} could not be refreshed: run 'authors list'.`);
|
|
134
|
+
}
|
|
135
|
+
return successResult(VERB_AUTHORS_ADD, siteId, {
|
|
136
|
+
author: { handle: created.handle, displayName: created.displayName, hasEmail: created.hasEmail },
|
|
137
|
+
existing,
|
|
138
|
+
...(refreshed ? { authorsFile: AUTHORS_FILE_NAME } : {}),
|
|
139
|
+
nextStep: `Credit pages with 'author: ${created.handle}' in a page's front matter or `
|
|
140
|
+
+ `'pages meta set <path> --author ${created.handle}', then 'pages push'.`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function writeAuthorsFile(config, siteId, listing) {
|
|
145
|
+
await ensureWorkspaceRoot(config);
|
|
146
|
+
await writeWorkspaceJson(config.workspaceDir, AUTHORS_FILE_NAME, projectAuthorsForWorkspace(siteId, listing));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Gives the site's refusals of a new author the stable codes the CLI documents. */
|
|
150
|
+
function translateAuthorsRefusal(error, handle) {
|
|
151
|
+
if (!(error instanceof ApiError)) return error;
|
|
152
|
+
for (const [field, code, fallback] of [
|
|
153
|
+
["HandleTaken", "authors.handle_taken", `A site author with the handle '${handle}' already exists.`],
|
|
154
|
+
["Handle", "authors.handle_invalid", `'${handle}' cannot be used as a handle.`],
|
|
155
|
+
["DisplayName", "authors.name_invalid", "The name was refused."],
|
|
156
|
+
["Email", "authors.email_invalid", "The email address was refused."],
|
|
157
|
+
]) {
|
|
158
|
+
if (error.hasField(field)) {
|
|
159
|
+
return new SiteAuthoringError(
|
|
160
|
+
code,
|
|
161
|
+
sanitizeDiagnostic(error.descriptionFor(field) ?? fallback, fallback),
|
|
162
|
+
{ field: field.toLowerCase() },
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return error;
|
|
167
|
+
}
|
package/src/verbs/deploy.js
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
DEPLOYMENT_REQUEST_OUTCOME_COALESCED,
|
|
5
5
|
DEPLOYMENT_STATUS_COMPLETED,
|
|
6
6
|
deploySite,
|
|
7
|
+
describePageEstimate,
|
|
7
8
|
getDeploySelection,
|
|
8
9
|
getPublishingReadiness,
|
|
9
10
|
listDeployments,
|
|
@@ -22,7 +23,7 @@ import {
|
|
|
22
23
|
} from "../constants.js";
|
|
23
24
|
import { SiteAuthoringError } from "../errors.js";
|
|
24
25
|
import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
25
|
-
import {
|
|
26
|
+
import { inspectStagingPreview } from "../staging-check.js";
|
|
26
27
|
import { ApiError } from "../transport.js";
|
|
27
28
|
import { readManifest, writeManifest } from "../workspace.js";
|
|
28
29
|
|
|
@@ -67,9 +68,6 @@ const REDIRECT_ACTIVATION_NOTE =
|
|
|
67
68
|
*/
|
|
68
69
|
|
|
69
70
|
const MAXIMUM_REPORTED_BLOCKERS = 50;
|
|
70
|
-
// Staged page ids ride in the readiness query string, so a very large candidate
|
|
71
|
-
// is checked at site level instead of pushing an unbounded URL at the API.
|
|
72
|
-
const MAXIMUM_READINESS_PAGE_IDS = 100;
|
|
73
71
|
|
|
74
72
|
function usageError(code, message, field) {
|
|
75
73
|
return new SiteAuthoringError(code, message, { field, exitCode: 2 });
|
|
@@ -157,36 +155,13 @@ function reportReadiness(readiness) {
|
|
|
157
155
|
redirectsChanged: readiness.redirectsChanged,
|
|
158
156
|
formsChanged: readiness.formsChanged,
|
|
159
157
|
videosChanged: readiness.videosChanged,
|
|
158
|
+
// The pages this deployment generates and the allowance left: what staging is charged (TR01201).
|
|
159
|
+
...(readiness.pageEstimate === null ? {} : { pageEstimate: readiness.pageEstimate }),
|
|
160
160
|
blockers: blockers.items,
|
|
161
161
|
...(blockers.truncated ? { blockersTruncated: true } : {}),
|
|
162
162
|
};
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
async function inspectStagingPreview(client, siteId, onProgress, now) {
|
|
166
|
-
let checks;
|
|
167
|
-
try {
|
|
168
|
-
checks = await checkStagingRedirects(client, siteId, onProgress, now);
|
|
169
|
-
if (!checks.redirects.verified) {
|
|
170
|
-
onProgress(
|
|
171
|
-
"Warning: staging redirects are not all verified. Check the reported mismatches against the staged release; run 'taproot-site redirects check' again once the deployment has completed.",
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
} catch {
|
|
175
|
-
onProgress(
|
|
176
|
-
"Warning: the deployment completed, but authenticated staging redirect checks could not finish. Run 'taproot-site redirects check' again.",
|
|
177
|
-
);
|
|
178
|
-
checks = { routeCheck: "unresolved", redirects: { verified: false, covered: false } };
|
|
179
|
-
}
|
|
180
|
-
try {
|
|
181
|
-
// Mint after checks so the final single-use URL has never been consumed.
|
|
182
|
-
const handoff = await mintStagingPreviewHandoff(client, siteId, { now });
|
|
183
|
-
return { ...checks, ...handoff };
|
|
184
|
-
} catch {
|
|
185
|
-
onProgress("Warning: the deployment completed, but a staging handoff could not be minted.");
|
|
186
|
-
return { ...checks, url: "", warning: "Staging handoff unavailable; run redirects check to retry." };
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
165
|
const PRESENTATION_REVIEW_GUIDANCE = "Open the URL once in a browser, then switch the site's theme toggle between "
|
|
191
166
|
+ "light and dark to review both schemes; no page draft is needed to preview a presentation change.";
|
|
192
167
|
|
|
@@ -366,8 +341,10 @@ export async function deploy(invocation) {
|
|
|
366
341
|
);
|
|
367
342
|
const readiness = await getPublishingReadiness(client, siteId, {
|
|
368
343
|
...candidate,
|
|
369
|
-
stagedPageIds: stagedPageIds.length > MAXIMUM_READINESS_PAGE_IDS ? undefined : stagedPageIds,
|
|
370
344
|
useExactPageSelection: stagedPageIds.length === 0,
|
|
345
|
+
includePageEstimate: true,
|
|
346
|
+
// The estimate must recognize the request it is about to be followed by.
|
|
347
|
+
...(invocation.allowFailedPreview === true ? { allowFailedPreview: true } : {}),
|
|
371
348
|
});
|
|
372
349
|
if (readiness.blockers.length > 0) {
|
|
373
350
|
throw new SiteAuthoringError(
|
|
@@ -393,11 +370,29 @@ export async function deploy(invocation) {
|
|
|
393
370
|
if (!readiness.hasCandidateChanges) {
|
|
394
371
|
onProgress("Taproot reports this candidate contains no changes; deploying it anyway would be a no-op.");
|
|
395
372
|
}
|
|
373
|
+
// The plan is the charge: the same page count admission uses, shown before anything is sent.
|
|
374
|
+
if (readiness.pageEstimate !== null) {
|
|
375
|
+
onProgress(describePageEstimate(readiness.pageEstimate));
|
|
376
|
+
if (readiness.pageEstimate.exceedsAllowance) {
|
|
377
|
+
const estimate = readiness.pageEstimate;
|
|
378
|
+
throw new SiteAuthoringError(
|
|
379
|
+
"deploy.page_allowance_exceeded",
|
|
380
|
+
`This candidate generates ${estimate.plannedPageCount.toLocaleString("en-US")} ${
|
|
381
|
+
estimate.plannedPageCount === 1 ? "page" : "pages"
|
|
382
|
+
} but the site has ${(estimate.pagesGeneratedRemaining ?? 0).toLocaleString("en-US")} left for this billing `
|
|
383
|
+
+ `period${estimate.periodEnd === null ? "" : ` (it resets ${estimate.periodEnd})`}. Stage a smaller change, `
|
|
384
|
+
+ "or upgrade the site's plan for a larger allowance. Nothing was sent.",
|
|
385
|
+
{ field: "plannedPageCount" },
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
396
389
|
|
|
397
390
|
const created = await deploySite(client, siteId, {
|
|
398
391
|
siteId,
|
|
399
392
|
environment: DEPLOYMENT_ENVIRONMENT_STAGING,
|
|
400
393
|
...candidate,
|
|
394
|
+
// Confirms the number just shown: the server refuses if the plan has grown since.
|
|
395
|
+
...(readiness.pageEstimate === null ? {} : { expectedPageCount: readiness.pageEstimate.plannedPageCount }),
|
|
401
396
|
...(invocation.allowFailedPreview === true ? { allowFailedPreview: true } : {}),
|
|
402
397
|
});
|
|
403
398
|
onProgress(`Staging deployment ${created.id} accepted; waiting for it to complete.`);
|
|
@@ -428,7 +423,7 @@ export async function deploy(invocation) {
|
|
|
428
423
|
nextStep: stagingPreview.url === "" ? "staging review" : "deploy --production",
|
|
429
424
|
});
|
|
430
425
|
}
|
|
431
|
-
const stagingPreview = await inspectStagingPreview(client, siteId, onProgress, now);
|
|
426
|
+
const { routeCheck, redirects, stagingPreview } = await inspectStagingPreview(client, siteId, onProgress, now);
|
|
432
427
|
onProgress(REDIRECT_ACTIVATION_NOTE);
|
|
433
428
|
return successResult(VERB_DEPLOY, siteId, {
|
|
434
429
|
target,
|
|
@@ -441,8 +436,10 @@ export async function deploy(invocation) {
|
|
|
441
436
|
deployment: completed,
|
|
442
437
|
outcome: created.outcome,
|
|
443
438
|
readiness: reportReadiness(readiness),
|
|
439
|
+
routeCheck,
|
|
440
|
+
redirects,
|
|
444
441
|
stagingPreview,
|
|
445
|
-
nextStep:
|
|
442
|
+
nextStep: redirects.verified === true ? "deploy --production" : "redirects check",
|
|
446
443
|
});
|
|
447
444
|
});
|
|
448
445
|
}
|
package/src/verbs/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
VERB_APPLY,
|
|
3
3
|
VERB_APPROVE,
|
|
4
|
+
VERB_AUTHORS_ADD,
|
|
5
|
+
VERB_AUTHORS_LIST,
|
|
4
6
|
VERB_DELIVERY_CHECK,
|
|
5
7
|
VERB_DEPLOY,
|
|
6
8
|
VERB_ENV,
|
|
@@ -12,7 +14,11 @@ import {
|
|
|
12
14
|
VERB_LOGOUT,
|
|
13
15
|
VERB_MEDIA_UPLOAD,
|
|
14
16
|
VERB_NAV_PUSH,
|
|
17
|
+
VERB_PAGES_META_SET,
|
|
15
18
|
VERB_PAGES_PUSH,
|
|
19
|
+
VERB_PLACES_CATEGORY_CLEAR,
|
|
20
|
+
VERB_PLACES_CATEGORY_LIST,
|
|
21
|
+
VERB_PLACES_CATEGORY_SET,
|
|
16
22
|
VERB_PLACES_SEARCH,
|
|
17
23
|
VERB_PLACES_SELECT,
|
|
18
24
|
VERB_PLAN,
|
|
@@ -31,6 +37,7 @@ import {
|
|
|
31
37
|
VERB_WHOAMI,
|
|
32
38
|
} from "../constants.js";
|
|
33
39
|
import { approve } from "./approve.js";
|
|
40
|
+
import { authorsAdd, authorsList } from "./authors.js";
|
|
34
41
|
import { deliveryCheck } from "./delivery-check.js";
|
|
35
42
|
import { deploy } from "./deploy.js";
|
|
36
43
|
import { env } from "./env.js";
|
|
@@ -42,8 +49,9 @@ import { login } from "./login.js";
|
|
|
42
49
|
import { logout } from "./logout.js";
|
|
43
50
|
import { mediaUpload } from "./media-upload.js";
|
|
44
51
|
import { navPush } from "./nav-push.js";
|
|
52
|
+
import { pagesMetaSet } from "./pages-meta-set.js";
|
|
45
53
|
import { pagesPush } from "./pages-push.js";
|
|
46
|
-
import { placesSearch, placesSelect } from "./places.js";
|
|
54
|
+
import { placesCategoryClear, placesCategoryList, placesCategorySet, placesSearch, placesSelect } from "./places.js";
|
|
47
55
|
import { apply, plan } from "./plan.js";
|
|
48
56
|
import { previewPage } from "./preview-page.js";
|
|
49
57
|
import { previewRevoke } from "./preview-revoke.js";
|
|
@@ -101,8 +109,11 @@ import { whoami } from "./whoami.js";
|
|
|
101
109
|
* // with none, every file under forms/
|
|
102
110
|
* placeQuery, // places search: the words of the query
|
|
103
111
|
* placeSelection, // places select: [googlePlaceId, sessionToken]
|
|
112
|
+
* placeCategory, // places category set: [placeId, category]
|
|
113
|
+
* placeId, // places category clear: [placeId]
|
|
104
114
|
* pagePaths, // approve: narrow to these page paths; with none, every
|
|
105
115
|
* // draft the workspace manifest tracks is staged
|
|
116
|
+
* authorHandle, // authors add: the handle of the site author to create
|
|
106
117
|
* pageId, // preview page: the one persisted draft UUID to render
|
|
107
118
|
* previewIds, // preview revoke: [page UUID, snapshot UUID]
|
|
108
119
|
* }
|
|
@@ -135,10 +146,16 @@ export const VERB_HANDLERS = Object.freeze({
|
|
|
135
146
|
[VERB_LOGOUT]: logout,
|
|
136
147
|
[VERB_PULL]: pull,
|
|
137
148
|
[VERB_PAGES_PUSH]: pagesPush,
|
|
149
|
+
[VERB_PAGES_META_SET]: pagesMetaSet,
|
|
150
|
+
[VERB_AUTHORS_LIST]: authorsList,
|
|
151
|
+
[VERB_AUTHORS_ADD]: authorsAdd,
|
|
138
152
|
[VERB_NAV_PUSH]: navPush,
|
|
139
153
|
[VERB_PLAN]: plan,
|
|
140
154
|
[VERB_PLACES_SEARCH]: placesSearch,
|
|
141
155
|
[VERB_PLACES_SELECT]: placesSelect,
|
|
156
|
+
[VERB_PLACES_CATEGORY_SET]: placesCategorySet,
|
|
157
|
+
[VERB_PLACES_CATEGORY_CLEAR]: placesCategoryClear,
|
|
158
|
+
[VERB_PLACES_CATEGORY_LIST]: placesCategoryList,
|
|
142
159
|
[VERB_APPLY]: apply,
|
|
143
160
|
[VERB_REDIRECTS_CHECK]: redirectsCheck,
|
|
144
161
|
[VERB_REDIRECTS_PULL]: redirectsPull,
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AUTHORS_FILE_NAME,
|
|
3
|
+
authorConflictMessage,
|
|
4
|
+
authorKnown,
|
|
5
|
+
parseAuthorReference,
|
|
6
|
+
readAuthorsDocument,
|
|
7
|
+
requireAuthorReference,
|
|
8
|
+
} from "../authors-contract.js";
|
|
9
|
+
import { VERB_PAGES_META_SET } from "../constants.js";
|
|
10
|
+
import { SiteAuthoringError } from "../errors.js";
|
|
11
|
+
import { descriptionWarnings, PAGE_AUTHOR_UNVERIFIED, requireDescriptionLength } from "../page-metadata.js";
|
|
12
|
+
import { openAnonymousSession, successResult } from "../session.js";
|
|
13
|
+
import { isGeneratedTemplateType, TEMPLATE_GENERATED, typedDocumentFromJson } from "../typed-pages.js";
|
|
14
|
+
import {
|
|
15
|
+
displayPagePath,
|
|
16
|
+
normalizePagePath,
|
|
17
|
+
PAGE_SOURCE_FORMAT_MARKDOWN,
|
|
18
|
+
PAGE_WORKSPACE_MODE_EDITABLE,
|
|
19
|
+
readManifest,
|
|
20
|
+
readWorkspaceFile,
|
|
21
|
+
readWorkspaceJson,
|
|
22
|
+
workspaceFileExists,
|
|
23
|
+
WORKSPACE_LIMITS,
|
|
24
|
+
writeManifest,
|
|
25
|
+
writeWorkspaceJson,
|
|
26
|
+
} from "../workspace.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `pages meta set` — the supported way to change a .pm.json page's title,
|
|
30
|
+
* description or author, which live in the workspace manifest rather than the
|
|
31
|
+
* file (TR01194, TR01196). It edits the workspace only; `pages push` sends the
|
|
32
|
+
* change, since the page's content key covers all three.
|
|
33
|
+
*
|
|
34
|
+
* The author is the one field that can only be filled, never changed: pages are
|
|
35
|
+
* authorless by default and an author already on the page is never replaced.
|
|
36
|
+
*
|
|
37
|
+
* A generated page keeps its own title and description in its source document
|
|
38
|
+
* (`data.customTitle`, `data.customDescription`), because the manifest holds the
|
|
39
|
+
* ones the site reports; an empty value there means "use the generated default"
|
|
40
|
+
* (TR01195).
|
|
41
|
+
*/
|
|
42
|
+
export async function pagesMetaSet(invocation) {
|
|
43
|
+
const { config, onProgress } = await openAnonymousSession({ ...invocation, client: null });
|
|
44
|
+
if (config === undefined) {
|
|
45
|
+
throw new SiteAuthoringError(
|
|
46
|
+
"config.not_found",
|
|
47
|
+
"No taproot-site.json was found. pages meta set edits the workspace it names.",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
// The parser refuses this too; a handler called directly must not report
|
|
51
|
+
// "nothing changed" for a request that asked for nothing.
|
|
52
|
+
if (
|
|
53
|
+
invocation.metaTitle === undefined && invocation.metaDescription === undefined
|
|
54
|
+
&& invocation.metaAuthor === undefined
|
|
55
|
+
) {
|
|
56
|
+
throw new SiteAuthoringError(
|
|
57
|
+
"pages.meta_nothing_to_set",
|
|
58
|
+
"Name what to set: --title, --description, --author, or any of them.",
|
|
59
|
+
{ exitCode: 2 },
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
// Surrounding whitespace is never meant; the site would keep it otherwise.
|
|
63
|
+
const title = invocation.metaTitle?.trim();
|
|
64
|
+
const description = invocation.metaDescription?.trim();
|
|
65
|
+
// An empty --author clears one that was never sent; any other value is a reference.
|
|
66
|
+
const clearAuthor = invocation.metaAuthor === "";
|
|
67
|
+
const author = invocation.metaAuthor === undefined || clearAuthor
|
|
68
|
+
? undefined
|
|
69
|
+
: requireAuthorReference(invocation.metaAuthor, "author");
|
|
70
|
+
const requested = normalizePagePath(invocation.metaPagePath);
|
|
71
|
+
if (requested === undefined) {
|
|
72
|
+
throw new SiteAuthoringError("pages.meta_path_invalid", "The page path is not usable.", {
|
|
73
|
+
field: "pagePath",
|
|
74
|
+
exitCode: 2,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const manifest = await readManifest(config.workspaceDir, config.siteId);
|
|
78
|
+
const entry = manifest.pages.find((candidate) => normalizePagePath(candidate?.path) === requested);
|
|
79
|
+
const shown = displayPagePath(requested);
|
|
80
|
+
if (entry === undefined) {
|
|
81
|
+
throw new SiteAuthoringError(
|
|
82
|
+
"pages.meta_page_unknown",
|
|
83
|
+
`No page this workspace tracks is at '${shown}'. Run 'taproot-site pull' if it was created elsewhere.`,
|
|
84
|
+
{ field: shown },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (entry.workspaceMode !== PAGE_WORKSPACE_MODE_EDITABLE) {
|
|
88
|
+
throw new SiteAuthoringError(
|
|
89
|
+
"pages.meta_page_readonly",
|
|
90
|
+
`'${shown}' is not an editable page in this workspace, so its title, description and author cannot be set here.`,
|
|
91
|
+
{ field: shown },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (entry.sourceFormat === PAGE_SOURCE_FORMAT_MARKDOWN) {
|
|
95
|
+
throw new SiteAuthoringError(
|
|
96
|
+
"pages.meta_markdown",
|
|
97
|
+
`'${shown}' is authored in ${entry.file}, whose front matter holds its title, description and author; edit them there.`,
|
|
98
|
+
{ field: shown },
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
// The manifest entry outlives a deleted source, and pull re-creates the file
|
|
102
|
+
// from the site, which would silently drop an edit made here.
|
|
103
|
+
if (typeof entry.file !== "string" || !await workspaceFileExists(config.workspaceDir, entry.file)) {
|
|
104
|
+
throw new SiteAuthoringError(
|
|
105
|
+
"pages.meta_source_missing",
|
|
106
|
+
`'${shown}' has no source file in this workspace. Run 'taproot-site pull' to restore it first.`,
|
|
107
|
+
{ field: shown },
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (description !== undefined) requireDescriptionLength(description);
|
|
111
|
+
// A generated page's custom title and description are in its source, where
|
|
112
|
+
// pull already protects an edit like any other.
|
|
113
|
+
if (isGeneratedTemplateType(entry.templateType)) {
|
|
114
|
+
if (author !== undefined || clearAuthor) {
|
|
115
|
+
throw new SiteAuthoringError(
|
|
116
|
+
"pages.meta_author_generated",
|
|
117
|
+
`'${shown}' is a page Taproot generates, so it has no author to set.`,
|
|
118
|
+
{ field: shown },
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return await setGeneratedMetadata({ invocation, config, entry, shown, onProgress });
|
|
122
|
+
}
|
|
123
|
+
// pull tells a metadata edit from the site's values by the content key the
|
|
124
|
+
// last pull or push recorded. Without one the edit could not be protected,
|
|
125
|
+
// and the next pull would quietly overwrite it.
|
|
126
|
+
if (typeof entry.baseline?.sourceHash !== "string" || typeof entry.baseline?.contentKey !== "string") {
|
|
127
|
+
throw new SiteAuthoringError(
|
|
128
|
+
"pages.meta_unreconciled",
|
|
129
|
+
`'${shown}' has not been reconciled with the site yet, so an edit here could be lost to the next pull. Run `
|
|
130
|
+
+ "'taproot-site pull' (or push the page) first.",
|
|
131
|
+
{ field: shown },
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (title === "") {
|
|
135
|
+
throw new SiteAuthoringError(
|
|
136
|
+
"pages.meta_title_empty",
|
|
137
|
+
`'${shown}' needs a title. Only a generated page can clear its title, to use the default one.`,
|
|
138
|
+
{ field: "title", exitCode: 2 },
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let unverifiedAuthor = false;
|
|
143
|
+
if (clearAuthor) requireClearableAuthor({ entry, shown });
|
|
144
|
+
else if (author !== undefined) {
|
|
145
|
+
unverifiedAuthor = await requireAssignableAuthor({ config, entry, shown, author });
|
|
146
|
+
if (unverifiedAuthor) {
|
|
147
|
+
onProgress(
|
|
148
|
+
`Warning: '${author}' is not in ${AUTHORS_FILE_NAME}; it may have been added since. 'taproot-site authors list' `
|
|
149
|
+
+ "refreshes it, and 'pages push' checks the name against the site before sending.",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const next = {
|
|
155
|
+
title: title ?? entry.title,
|
|
156
|
+
description: description ?? entry.description ?? "",
|
|
157
|
+
author: clearAuthor ? "" : author ?? entry.author ?? "",
|
|
158
|
+
};
|
|
159
|
+
const changed = next.title !== entry.title
|
|
160
|
+
|| next.description !== (entry.description ?? "")
|
|
161
|
+
|| next.author !== (entry.author ?? "");
|
|
162
|
+
if (changed) {
|
|
163
|
+
entry.title = next.title;
|
|
164
|
+
entry.description = next.description;
|
|
165
|
+
if (next.author === "") delete entry.author;
|
|
166
|
+
else entry.author = next.author;
|
|
167
|
+
await writeManifest(config.workspaceDir, manifest);
|
|
168
|
+
onProgress(`Set the ${describeFields(invocation)} of '${shown}'. Run 'taproot-site pages push' to send it.`);
|
|
169
|
+
} else {
|
|
170
|
+
onProgress(`'${shown}' already has that ${describeFields(invocation)}; nothing changed.`);
|
|
171
|
+
}
|
|
172
|
+
return successResult(VERB_PAGES_META_SET, config.siteId, {
|
|
173
|
+
page: {
|
|
174
|
+
path: shown,
|
|
175
|
+
file: entry.file,
|
|
176
|
+
title: next.title,
|
|
177
|
+
description: next.description,
|
|
178
|
+
...(next.author === "" ? {} : { author: next.author }),
|
|
179
|
+
},
|
|
180
|
+
changed,
|
|
181
|
+
...descriptionWarnings([{ file: entry.file, pagePath: entry.path, description: next.description }], onProgress),
|
|
182
|
+
...(unverifiedAuthor
|
|
183
|
+
? {
|
|
184
|
+
authorWarnings: {
|
|
185
|
+
total: 1,
|
|
186
|
+
items: [{ code: PAGE_AUTHOR_UNVERIFIED, file: entry.file, path: shown, author: next.author }],
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
: {}),
|
|
190
|
+
...(changed ? { nextStep: "pages push" } : {}),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function describeFields(invocation) {
|
|
195
|
+
return [
|
|
196
|
+
invocation.metaTitle !== undefined && "title",
|
|
197
|
+
invocation.metaDescription !== undefined && "description",
|
|
198
|
+
invocation.metaAuthor !== undefined && "author",
|
|
199
|
+
]
|
|
200
|
+
.filter(Boolean)
|
|
201
|
+
.join(" and ");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Refuses an author the site would not take: a different one than the site
|
|
206
|
+
* holds (never replaced). One that `authors.json` lacks is only warned about,
|
|
207
|
+
* because this verb is offline and the file is as old as the last `pull` or
|
|
208
|
+
* `authors list`: the person may have been added since, and `pages push` asks
|
|
209
|
+
* the site before it sends anything. Answers whether the name is unverified.
|
|
210
|
+
*/
|
|
211
|
+
async function requireAssignableAuthor({ config, entry, shown, author }) {
|
|
212
|
+
const held = parseAuthorReference(entry.baseline?.author)?.value ?? "";
|
|
213
|
+
if (held !== "" && held !== author) {
|
|
214
|
+
throw new SiteAuthoringError(
|
|
215
|
+
"pages.author_conflict",
|
|
216
|
+
authorConflictMessage({ shown, held, wanted: author, markdown: false, file: entry.file }),
|
|
217
|
+
{ field: shown },
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (held === author) return false;
|
|
221
|
+
let recorded;
|
|
222
|
+
try {
|
|
223
|
+
if (await workspaceFileExists(config.workspaceDir, AUTHORS_FILE_NAME)) {
|
|
224
|
+
recorded = readAuthorsDocument(
|
|
225
|
+
await readWorkspaceJson(config.workspaceDir, AUTHORS_FILE_NAME, WORKSPACE_LIMITS.authorsBytes),
|
|
226
|
+
config.siteId,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
} catch {
|
|
230
|
+
recorded = undefined;
|
|
231
|
+
}
|
|
232
|
+
return authorKnown(recorded, author) === false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* `--author ""` drops an author this workspace has not sent yet. It never asks
|
|
237
|
+
* the site to remove one: once the site holds an author (the last pull or push
|
|
238
|
+
* recorded it), only the app changes that, so the request is refused rather
|
|
239
|
+
* than quietly leaving the author in place.
|
|
240
|
+
*/
|
|
241
|
+
function requireClearableAuthor({ entry, shown }) {
|
|
242
|
+
const held = parseAuthorReference(entry.baseline?.author)?.value ?? "";
|
|
243
|
+
if (held !== "") {
|
|
244
|
+
throw new SiteAuthoringError(
|
|
245
|
+
"pages.author_conflict",
|
|
246
|
+
`Page '${shown}' already has the author '${held}', and an author is never removed from the CLI. `
|
|
247
|
+
+ "To change or remove it, use the app.",
|
|
248
|
+
{ field: shown },
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Sets a generated page's custom title or description in its source document. */
|
|
254
|
+
async function setGeneratedMetadata({ invocation, config, entry, shown, onProgress }) {
|
|
255
|
+
let parsed;
|
|
256
|
+
try {
|
|
257
|
+
const bytes = await readWorkspaceFile(config.workspaceDir, entry.file, WORKSPACE_LIMITS.documentBytes);
|
|
258
|
+
parsed = JSON.parse(bytes.toString("utf8"));
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (error instanceof SiteAuthoringError) throw error;
|
|
261
|
+
throw new SiteAuthoringError("pages.document_shape", `'${entry.file}' is not valid JSON.`, { field: entry.file });
|
|
262
|
+
}
|
|
263
|
+
const document_ = typedDocumentFromJson(parsed, entry.file);
|
|
264
|
+
if (document_.template !== TEMPLATE_GENERATED) {
|
|
265
|
+
throw new SiteAuthoringError(
|
|
266
|
+
"pages.meta_page_readonly",
|
|
267
|
+
`'${entry.file}' is not a generated-page source, so '${shown}' cannot be set from it.`,
|
|
268
|
+
{ field: shown },
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
const data = {
|
|
272
|
+
...document_.data,
|
|
273
|
+
...(invocation.metaTitle === undefined ? {} : { customTitle: invocation.metaTitle.trim() }),
|
|
274
|
+
...(invocation.metaDescription === undefined ? {} : { customDescription: invocation.metaDescription.trim() }),
|
|
275
|
+
};
|
|
276
|
+
const next = typedDocumentFromJson({ ...document_, data }, entry.file);
|
|
277
|
+
const changed = next.data.customTitle !== document_.data.customTitle
|
|
278
|
+
|| next.data.customDescription !== document_.data.customDescription;
|
|
279
|
+
if (changed) {
|
|
280
|
+
await writeWorkspaceJson(config.workspaceDir, entry.file, next);
|
|
281
|
+
onProgress(
|
|
282
|
+
`Set the ${describeFields(invocation)} of '${shown}' in ${entry.file}. Run 'taproot-site pages push' to send it.`,
|
|
283
|
+
);
|
|
284
|
+
} else {
|
|
285
|
+
onProgress(`'${shown}' already has that ${describeFields(invocation)}; nothing changed.`);
|
|
286
|
+
}
|
|
287
|
+
return successResult(VERB_PAGES_META_SET, config.siteId, {
|
|
288
|
+
// The owner's own values; empty means the page uses its generated default.
|
|
289
|
+
page: {
|
|
290
|
+
path: shown,
|
|
291
|
+
file: entry.file,
|
|
292
|
+
title: next.data.customTitle,
|
|
293
|
+
description: next.data.customDescription,
|
|
294
|
+
generated: true,
|
|
295
|
+
},
|
|
296
|
+
changed,
|
|
297
|
+
...descriptionWarnings(
|
|
298
|
+
[{ file: entry.file, pagePath: entry.path, description: next.data.customDescription }],
|
|
299
|
+
onProgress,
|
|
300
|
+
),
|
|
301
|
+
...(changed ? { nextStep: "pages push" } : {}),
|
|
302
|
+
});
|
|
303
|
+
}
|