@taprootio/site-authoring 0.15.0 → 0.16.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.
@@ -1,8 +1,14 @@
1
- import { listSitePages, PAGE_STATUS_DELETED, publishDrafts, withRefusalGuidance } from "../api.js";
1
+ import {
2
+ listSitePages,
3
+ PAGE_STATUS_APPROVED,
4
+ PAGE_STATUS_DELETED,
5
+ publishDrafts,
6
+ withRefusalGuidance,
7
+ } from "../api.js";
2
8
  import { VERB_APPROVE } from "../constants.js";
3
9
  import { SiteAuthoringError } from "../errors.js";
4
- import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
5
- import { normalizePagePath, readManifest, writeManifest } from "../workspace.js";
10
+ import { boundedByBytes, boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
11
+ import { displayPagePath, normalizePagePath, readManifest, writeManifest } from "../workspace.js";
6
12
 
7
13
  /**
8
14
  * `approve` — publish the site's drafts.
@@ -19,6 +25,8 @@ import { normalizePagePath, readManifest, writeManifest } from "../workspace.js"
19
25
  */
20
26
 
21
27
  const MAXIMUM_REPORTED = 200;
28
+ // Room for the unknown-path list inside the 64 KiB result, beside the envelope.
29
+ const ALTERNATIVES_BYTES = 16 * 1024;
22
30
  // `PublishDrafts` resolves the credential from the first page id in the batch,
23
31
  // so batches stay one-site and bounded rather than unbounded and clever.
24
32
  const MAXIMUM_BATCH = 100;
@@ -71,31 +79,71 @@ export async function approve(invocation) {
71
79
  { field: "pages" },
72
80
  );
73
81
  }
82
+ const pathOf = (summary) => normalizePagePath(summary.path) ?? summary.path;
74
83
  const candidates = pages.filter((summary) =>
75
84
  summary.status !== PAGE_STATUS_DELETED
76
85
  && summary.hasDraft
77
86
  && manifestByPageId.has(summary.pageId)
78
- && (requestedPaths === undefined || requestedPaths.has(normalizePagePath(summary.path) ?? summary.path)));
87
+ && (requestedPaths === undefined || requestedPaths.has(pathOf(summary))));
79
88
 
89
+ // A requested page with nothing to approve is reported, not refused, so
90
+ // one already-approved path does not stop the rest of the batch. A path
91
+ // that names no page this workspace tracks is still refused by name.
92
+ const skipped = [];
80
93
  if (requestedPaths !== undefined) {
81
- const matched = new Set(candidates.map((summary) => normalizePagePath(summary.path) ?? summary.path));
82
- const missing = [...requestedPaths].filter((value) => !matched.has(value)).sort();
83
- if (missing.length > 0) {
84
- // The homepage normalizes to the empty path, which the result
85
- // emitters drop as falsy; name it by its documented '/' spelling so
86
- // the stable error contract keeps a field for every unknown path.
94
+ const matched = new Set(candidates.map(pathOf));
95
+ const unknown = [];
96
+ for (const requested of [...requestedPaths].filter((value) => !matched.has(value)).sort()) {
97
+ const summary = pages.find((page) => page.status !== PAGE_STATUS_DELETED && pathOf(page) === requested);
98
+ if (summary === undefined || !manifestByPageId.has(summary.pageId)) {
99
+ unknown.push(displayPagePath(requested));
100
+ continue;
101
+ }
102
+ skipped.push({
103
+ pageId: summary.pageId,
104
+ path: displayPagePath(requested),
105
+ status: summary.status,
106
+ reason: summary.status === PAGE_STATUS_APPROVED ? "already_approved" : "no_pending_draft",
107
+ });
108
+ }
109
+ if (unknown.length > 0) {
110
+ // Every path is named on progress, since the message and the
111
+ // alternatives list are both bounded.
112
+ for (const value of unknown) onProgress(`No page this workspace tracks is at '${value}'.`);
113
+ const shown = unknown.slice(0, 5).map((value) => `'${value}'`).join(", ");
114
+ const listed = boundedByBytes(unknown.slice(0, 100), ALTERNATIVES_BYTES).items;
87
115
  throw new SiteAuthoringError(
88
116
  "approve.page_not_found",
89
- `No approvable draft was found for page path '${missing[0] || "/"}'.`,
90
- { field: missing[0] || "/" },
117
+ `${unknown.length} requested path(s) name no page this workspace tracks. The first ${listed.length} are `
118
+ + "in alternatives and every one is on progress output. Nothing was approved. Run 'taproot-site pull' if "
119
+ + `a page was created elsewhere. ${shown}${unknown.length > 5 ? ", …" : ""}`,
120
+ { field: unknown[0], alternatives: listed },
91
121
  );
92
122
  }
123
+ for (const item of skipped) {
124
+ onProgress(`${item.path} has no pending draft (${item.reason.replaceAll("_", " ")}); skipping it.`);
125
+ }
93
126
  }
127
+ const reportedSkipped = boundedList(skipped, MAXIMUM_REPORTED);
128
+ const skippedResult = skipped.length > 0
129
+ ? {
130
+ skipped: {
131
+ total: skipped.length,
132
+ items: reportedSkipped.items,
133
+ ...(reportedSkipped.truncated ? { itemsTruncated: true } : {}),
134
+ },
135
+ }
136
+ : {};
94
137
 
95
138
  if (candidates.length === 0) {
96
- onProgress("No page in this workspace is carrying a draft; there is nothing to approve.");
139
+ onProgress(
140
+ skipped.length > 0
141
+ ? "Nothing to approve: the requested page(s) have no pending draft."
142
+ : "No page in this workspace is carrying a draft; there is nothing to approve.",
143
+ );
97
144
  return successResult(VERB_APPROVE, siteId, {
98
145
  approved: { total: 0, items: [] },
146
+ ...skippedResult,
99
147
  stagedNotDeployed: true,
100
148
  nextStep: "deploy --staging",
101
149
  });
@@ -110,7 +158,7 @@ export async function approve(invocation) {
110
158
  const summaries = await publishDrafts(client, batch.map((summary) => summary.pageId));
111
159
  manifestDirty = true;
112
160
  for (const summary of summaries) {
113
- approved.push({ pageId: summary.pageId, path: summary.path, status: summary.status });
161
+ approved.push({ pageId: summary.pageId, path: displayPagePath(summary.path), status: summary.status });
114
162
  const entry = manifestByPageId.get(summary.pageId);
115
163
  if (entry !== undefined) {
116
164
  entry.status = summary.status;
@@ -131,6 +179,7 @@ export async function approve(invocation) {
131
179
  items: reported.items,
132
180
  ...(reported.truncated ? { itemsTruncated: true } : {}),
133
181
  },
182
+ ...skippedResult,
134
183
  // Said explicitly so a caller cannot read "approve" as "live".
135
184
  stagedNotDeployed: true,
136
185
  nextStep: "deploy --staging",
@@ -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
+ }
@@ -22,7 +22,7 @@ import {
22
22
  } from "../constants.js";
23
23
  import { SiteAuthoringError } from "../errors.js";
24
24
  import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
25
- import { checkStagingRedirects } from "../staging-check.js";
25
+ import { inspectStagingPreview } from "../staging-check.js";
26
26
  import { ApiError } from "../transport.js";
27
27
  import { readManifest, writeManifest } from "../workspace.js";
28
28
 
@@ -162,31 +162,6 @@ function reportReadiness(readiness) {
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
 
@@ -428,7 +403,7 @@ export async function deploy(invocation) {
428
403
  nextStep: stagingPreview.url === "" ? "staging review" : "deploy --production",
429
404
  });
430
405
  }
431
- const stagingPreview = await inspectStagingPreview(client, siteId, onProgress, now);
406
+ const { routeCheck, redirects, stagingPreview } = await inspectStagingPreview(client, siteId, onProgress, now);
432
407
  onProgress(REDIRECT_ACTIVATION_NOTE);
433
408
  return successResult(VERB_DEPLOY, siteId, {
434
409
  target,
@@ -441,8 +416,10 @@ export async function deploy(invocation) {
441
416
  deployment: completed,
442
417
  outcome: created.outcome,
443
418
  readiness: reportReadiness(readiness),
419
+ routeCheck,
420
+ redirects,
444
421
  stagingPreview,
445
- nextStep: stagingPreview.redirects?.verified === true ? "deploy --production" : "redirects check",
422
+ nextStep: redirects.verified === true ? "deploy --production" : "redirects check",
446
423
  });
447
424
  });
448
425
  }
@@ -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,6 +14,7 @@ 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,
16
19
  VERB_PLACES_SEARCH,
17
20
  VERB_PLACES_SELECT,
@@ -31,6 +34,7 @@ import {
31
34
  VERB_WHOAMI,
32
35
  } from "../constants.js";
33
36
  import { approve } from "./approve.js";
37
+ import { authorsAdd, authorsList } from "./authors.js";
34
38
  import { deliveryCheck } from "./delivery-check.js";
35
39
  import { deploy } from "./deploy.js";
36
40
  import { env } from "./env.js";
@@ -42,6 +46,7 @@ import { login } from "./login.js";
42
46
  import { logout } from "./logout.js";
43
47
  import { mediaUpload } from "./media-upload.js";
44
48
  import { navPush } from "./nav-push.js";
49
+ import { pagesMetaSet } from "./pages-meta-set.js";
45
50
  import { pagesPush } from "./pages-push.js";
46
51
  import { placesSearch, placesSelect } from "./places.js";
47
52
  import { apply, plan } from "./plan.js";
@@ -103,6 +108,7 @@ import { whoami } from "./whoami.js";
103
108
  * placeSelection, // places select: [googlePlaceId, sessionToken]
104
109
  * pagePaths, // approve: narrow to these page paths; with none, every
105
110
  * // draft the workspace manifest tracks is staged
111
+ * authorHandle, // authors add: the handle of the site author to create
106
112
  * pageId, // preview page: the one persisted draft UUID to render
107
113
  * previewIds, // preview revoke: [page UUID, snapshot UUID]
108
114
  * }
@@ -135,6 +141,9 @@ export const VERB_HANDLERS = Object.freeze({
135
141
  [VERB_LOGOUT]: logout,
136
142
  [VERB_PULL]: pull,
137
143
  [VERB_PAGES_PUSH]: pagesPush,
144
+ [VERB_PAGES_META_SET]: pagesMetaSet,
145
+ [VERB_AUTHORS_LIST]: authorsList,
146
+ [VERB_AUTHORS_ADD]: authorsAdd,
138
147
  [VERB_NAV_PUSH]: navPush,
139
148
  [VERB_PLAN]: plan,
140
149
  [VERB_PLACES_SEARCH]: placesSearch,