@taprootio/site-authoring 0.15.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,109 @@
1
+ import { sanitizeDiagnostic } from "../errors.js";
2
+ import { boundedByBytes } from "../session.js";
3
+ import { contentDocuments } from "../typed-pages.js";
4
+ import { displayPagePath } from "../workspace.js";
5
+
6
+ /**
7
+ * Links the editor made on its own before TR01191: text such as "ASP.NET"
8
+ * whose address is just "http://ASP.NET". The address names the text and
9
+ * nothing else (no path, query or fragment), so the author almost certainly
10
+ * never chose it. Reported as warnings so an author can remove them.
11
+ */
12
+ export const LINK_AUTOLINKED = "content.link_autolinked";
13
+
14
+ // Progress names this many; the JSON list is bounded by bytes instead.
15
+ const MAXIMUM_PROGRESS_LINES = 20;
16
+
17
+ export function autolinkedLinks(document) {
18
+ const found = [];
19
+ (function walk(node, depth) {
20
+ if (depth > 64 || node === null || typeof node !== "object" || !Array.isArray(node.content)) return;
21
+ // A link split across runs (part of it bold, say) is one link, so adjacent
22
+ // text sharing one href is read as one label.
23
+ let run;
24
+ const close = () => {
25
+ if (run !== undefined && looksAutolinked(run.text, run.href)) found.push(run);
26
+ run = undefined;
27
+ };
28
+ for (const child of node.content) {
29
+ const href = child?.type === "text" && typeof child.text === "string" && Array.isArray(child.marks)
30
+ ? child.marks.find((mark) => mark?.type === "link")?.attrs?.href
31
+ : undefined;
32
+ if (typeof href !== "string") {
33
+ close();
34
+ walk(child, depth + 1);
35
+ } else if (run !== undefined && run.href === href) {
36
+ run.text += child.text;
37
+ } else {
38
+ close();
39
+ run = { text: child.text, href };
40
+ }
41
+ }
42
+ close();
43
+ })(document, 0);
44
+ return found;
45
+ }
46
+
47
+ function looksAutolinked(text, href) {
48
+ const label = text.trim();
49
+ if (label === "" || /^[a-z][a-z0-9+.-]*:/iu.test(label)) return false;
50
+ let url;
51
+ try {
52
+ url = new URL(href);
53
+ } catch {
54
+ return false;
55
+ }
56
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
57
+ if (url.pathname !== "/" || url.search !== "" || url.hash !== "" || url.username !== "" || url.port !== "") {
58
+ return false;
59
+ }
60
+ const written = href.replace(/^https?:\/\//iu, "").replace(/\/$/u, "");
61
+ return written.toLowerCase() === label.toLowerCase();
62
+ }
63
+
64
+ /**
65
+ * Links that look made by the editor's old autolinking ("ASP.NET" linked to
66
+ * http://ASP.NET), named so an author can remove them; never a refusal.
67
+ */
68
+ export function autolinkWarnings(planned, onProgress) {
69
+ const items = [];
70
+ for (const page of planned ?? []) {
71
+ for (const link of pageDocuments(page.document).flatMap((document) => autolinkedLinks(document))) {
72
+ const item = {
73
+ code: LINK_AUTOLINKED,
74
+ file: page.file,
75
+ path: displayPagePath(page.pagePath),
76
+ text: [...sanitizeDiagnostic(link.text, "")].slice(0, 200).join(""),
77
+ href: [...sanitizeDiagnostic(link.href, "")].slice(0, 300).join(""),
78
+ };
79
+ if (items.length < MAXIMUM_PROGRESS_LINES) {
80
+ onProgress(
81
+ `Warning: '${item.text}' in ${item.file} links to ${item.href}, which looks made by autolinking; `
82
+ + "remove the link if nobody chose it.",
83
+ );
84
+ }
85
+ items.push(item);
86
+ }
87
+ }
88
+ if (items.length > MAXIMUM_PROGRESS_LINES) {
89
+ onProgress(`Warning: ${items.length - MAXIMUM_PROGRESS_LINES} more autolink-shaped link(s); see linkWarnings.`);
90
+ }
91
+ const reported = boundedByBytes(items, 8 * 1024);
92
+ return items.length === 0
93
+ ? {}
94
+ : { linkWarnings: { total: items.length, items: reported.items, ...(reported.truncated ? { truncated: true } : {}) } };
95
+ }
96
+
97
+ /**
98
+ * Every ProseMirror document a page carries: a free-form body, or a typed
99
+ * page's body, introduction and recipe steps. A page that failed validation
100
+ * may have none.
101
+ */
102
+ function pageDocuments(document) {
103
+ if (document === null || typeof document !== "object") return [];
104
+ try {
105
+ return contentDocuments(document).map((entry) => entry.doc);
106
+ } catch {
107
+ return [];
108
+ }
109
+ }
@@ -15,6 +15,8 @@ import {
15
15
  MANIFEST_FILE_NAME,
16
16
  MANIFEST_VERSION,
17
17
  PAGE_WORKSPACE_MODE_EDITABLE,
18
+ normalizePagePath,
19
+ PAGE_WORKSPACE_MODE_METADATA_ONLY,
18
20
  readManifest,
19
21
  readMediaManifest,
20
22
  readWorkspaceJson,
@@ -143,10 +145,14 @@ export async function initializeFixture(invocation, validate) {
143
145
  status: entry.status,
144
146
  templateType: entry.templateType,
145
147
  hasDraft: entry.hasDraft,
146
- isGenerated: false,
148
+ isGenerated: entry.isGenerated === true,
147
149
  file,
148
150
  sourceFormat: "prosemirror",
149
151
  workspaceMode: "editable",
152
+ // What a generated page is, which its identity check needs offline.
153
+ ...(entry.generated !== null && typeof entry.generated === "object" && !Array.isArray(entry.generated)
154
+ ? { generated: entry.generated }
155
+ : {}),
150
156
  }));
151
157
  add(file, sanitize(page.document));
152
158
  }
@@ -194,6 +200,44 @@ export async function initializeFixture(invocation, validate) {
194
200
  ),
195
201
  );
196
202
  }
203
+ // Navigation and footer links may point at pages the fixture has no source
204
+ // for, such as generated section pages; they travel as metadata-only entries
205
+ // so the fixture validates as the site does (TR01198).
206
+ const editablePageCount = pages.length;
207
+ const linkedResourceIds = new Set();
208
+ (function collect(value) {
209
+ if (Array.isArray(value)) value.forEach(collect);
210
+ else if (value && typeof value === "object") {
211
+ for (const [key, child] of Object.entries(value)) {
212
+ if ((key === "resourceId" || key === "pageResourceId") && typeof child === "string") {
213
+ linkedResourceIds.add(child);
214
+ } else collect(child);
215
+ }
216
+ }
217
+ })([...documents.values()]);
218
+ for (const entry of source.pages) {
219
+ if (entry.workspaceMode !== PAGE_WORKSPACE_MODE_METADATA_ONLY) continue;
220
+ if (!isCanonicalUuid(entry.pageId) || !isCanonicalUuid(entry.resourceId)) continue;
221
+ if (!linkedResourceIds.has(identity(entry.resourceId))) continue;
222
+ const linkedPath = normalizePagePath(entry.path);
223
+ if (linkedPath === undefined || linkedPath === "") {
224
+ refuse(
225
+ `A page that is linked to has no usable path of its own (${JSON.stringify(entry.path ?? null)}); pull again.`,
226
+ "pages",
227
+ );
228
+ }
229
+ pages.push(sanitize({
230
+ pageId: entry.pageId,
231
+ resourceId: entry.resourceId,
232
+ path: linkedPath,
233
+ title: typeof entry.title === "string" ? entry.title : "",
234
+ status: entry.status,
235
+ templateType: entry.templateType,
236
+ hasDraft: entry.hasDraft === true,
237
+ isGenerated: entry.isGenerated === true,
238
+ workspaceMode: PAGE_WORKSPACE_MODE_METADATA_ONLY,
239
+ }));
240
+ }
197
241
  const redirects = documents.get("redirects.json");
198
242
  // A fixture hash is a local placeholder, not authority to mutate the original site.
199
243
  const revision = createHash("sha256").update(JSON.stringify(redirects.entries)).digest("hex");
@@ -242,10 +286,18 @@ export async function initializeFixture(invocation, validate) {
242
286
  try {
243
287
  for (const [file, value] of documents) await writeWorkspaceJson(destination, file, value);
244
288
  const result = await validate({ ...invocation, init: false, cwd, fixturePath: destination });
245
- invocation.onProgress?.(`Initialized a sanitized fixture with ${pages.length} editable page(s).`);
289
+ invocation.onProgress?.(
290
+ `Initialized a sanitized fixture with ${editablePageCount} editable page(s)`
291
+ + `${pages.length > editablePageCount ? ` and ${pages.length - editablePageCount} linked page(s)` : ""}.`,
292
+ );
246
293
  return {
247
294
  ...result,
248
- initialized: { directory: destination, pages: pages.length, skippedPages: source.pages.length - pages.length },
295
+ initialized: {
296
+ directory: destination,
297
+ pages: editablePageCount,
298
+ linkedPages: pages.length - editablePageCount,
299
+ skippedPages: source.pages.length - pages.length,
300
+ },
249
301
  };
250
302
  } catch (error) {
251
303
  await rm(destination, { recursive: true, force: true });
@@ -0,0 +1,92 @@
1
+ import { SiteAuthoringError } from "./errors.js";
2
+ import { boundedByBytes } from "./session.js";
3
+ import { displayPagePath } from "./workspace.js";
4
+
5
+ /**
6
+ * A page description is what search results and link previews show. Past about
7
+ * 160 characters search engines cut it off, so it is warned about; past 1000 it
8
+ * is refused, here and by the API (TR01194).
9
+ */
10
+ export const DESCRIPTION_WARN_LENGTH = 160;
11
+ export const DESCRIPTION_MAXIMUM_LENGTH = 1000;
12
+ export const PAGE_DESCRIPTION_LONG = "pages.description_long";
13
+
14
+ const length = (value) => [...value].length;
15
+
16
+ /** Refuses a description the API would refuse. */
17
+ export function requireDescriptionLength(description, field = "description") {
18
+ if (typeof description === "string" && length(description) > DESCRIPTION_MAXIMUM_LENGTH) {
19
+ throw new SiteAuthoringError(
20
+ "pages.description_too_long",
21
+ `The description is ${length(description)} characters; it may be at most ${DESCRIPTION_MAXIMUM_LENGTH} `
22
+ + `(search results show about ${DESCRIPTION_WARN_LENGTH}).`,
23
+ { field },
24
+ );
25
+ }
26
+ }
27
+
28
+ /** Warnings for descriptions search results will cut off, named per page. */
29
+ export function descriptionWarnings(pages, onProgress) {
30
+ const items = [];
31
+ for (const page of pages ?? []) {
32
+ const description = page.declaredDescription ?? page.description;
33
+ if (typeof description !== "string" || length(description) <= DESCRIPTION_WARN_LENGTH) continue;
34
+ const item = {
35
+ code: PAGE_DESCRIPTION_LONG,
36
+ file: page.file,
37
+ path: displayPagePath(page.pagePath ?? page.path),
38
+ length: length(description),
39
+ };
40
+ onProgress(
41
+ `Warning: ${item.file}'s description is ${item.length} characters; search results show about `
42
+ + `${DESCRIPTION_WARN_LENGTH}.`,
43
+ );
44
+ items.push(item);
45
+ }
46
+ const reported = boundedByBytes(items, 8 * 1024);
47
+ return items.length === 0
48
+ ? {}
49
+ : {
50
+ descriptionWarnings: {
51
+ total: items.length,
52
+ items: reported.items,
53
+ ...(reported.truncated ? { truncated: true } : {}),
54
+ },
55
+ };
56
+ }
57
+
58
+ export const PAGE_AUTHOR_UNVERIFIED = "pages.author_unverified";
59
+
60
+ /**
61
+ * Warnings for authors an offline run could not confirm (TR01196). The only
62
+ * evidence offline is `authors.json`, which is as old as the last `pull` or
63
+ * `authors list`, so a name missing from it may simply be newer; it is flagged
64
+ * rather than refused, and an online run settles it against the site.
65
+ */
66
+ export function authorWarnings(pages, onProgress) {
67
+ const items = [];
68
+ for (const page of pages ?? []) {
69
+ if (page.authorUnverified !== true) continue;
70
+ const item = {
71
+ code: PAGE_AUTHOR_UNVERIFIED,
72
+ file: page.file,
73
+ path: displayPagePath(page.pagePath ?? page.path),
74
+ author: page.author,
75
+ };
76
+ onProgress(
77
+ `Warning: ${item.file} names author '${item.author}', who is not in authors.json; it may have been added `
78
+ + "since. 'taproot-site authors list' refreshes it, and 'pages push' checks the name against the site.",
79
+ );
80
+ items.push(item);
81
+ }
82
+ const reported = boundedByBytes(items, 8 * 1024);
83
+ return items.length === 0
84
+ ? {}
85
+ : {
86
+ authorWarnings: {
87
+ total: items.length,
88
+ items: reported.items,
89
+ ...(reported.truncated ? { truncated: true } : {}),
90
+ },
91
+ };
92
+ }
@@ -64,7 +64,12 @@ import {
64
64
  REDIRECTS_FILE_NAME,
65
65
  } from "./redirects-contract.js";
66
66
  import { SETTINGS_GROUPS } from "./settings-catalog.js";
67
- import { formatTypedPageReference, getTypedPageReference, TYPED_PAGE_TYPES } from "./typed-page-reference.js";
67
+ import {
68
+ formatTypedPageReference,
69
+ getTypedPageReference,
70
+ MARKDOWN_PAGE_TYPES,
71
+ TYPED_PAGE_TYPES,
72
+ } from "./typed-page-reference.js";
68
73
  import { PAGE_TEMPLATES } from "./typed-pages.js";
69
74
  import {
70
75
  INTERNAL_PAGE_BASELINE_DIRECTORY,
@@ -72,6 +77,7 @@ import {
72
77
  NAVIGATION_FILE_NAME,
73
78
  PAGE_SOURCE_EXTENSIONS,
74
79
  PAGE_WORKSPACE_MODE_EDITABLE,
80
+ PAGE_WORKSPACE_MODE_METADATA_ONLY,
75
81
  PAGES_DIRECTORY,
76
82
  pageSourceFormat,
77
83
  SETTINGS_DIRECTORY,
@@ -80,7 +86,7 @@ import {
80
86
 
81
87
  export { getAppearanceReference, getFooterReference, getThemeReference };
82
88
 
83
- export const REFERENCE_VERSION = 34;
89
+ export const REFERENCE_VERSION = 36;
84
90
 
85
91
  // The date-limit examples `help forms` prints; tests run them through the form validator.
86
92
  export const FORM_DATE_WINDOW_FIELD = Object.freeze({
@@ -672,10 +678,16 @@ const WORKFLOW_REFERENCES = Object.freeze({
672
678
  "Run 'redirects check' after staging, or read deploy --staging's automatic check. The Node CLI consumes "
673
679
  + "a separate staging handoff and checks the current map at the real edge, using an in-memory cookie and "
674
680
  + "manual redirects. It reports each path, HTTP status and Location without following targets.",
681
+ "The result carries routeCheck (resolved or unresolved) and redirects: { revision, revisionUnchanged, "
682
+ + "verified, total, checked, matched, failed, items: [{ path, status, location, expectedStatus, matches, "
683
+ + "error? }], itemsTruncated? }, at the top level of both redirects check and deploy --staging. An entry "
684
+ + "whose request failed carries error: \"request_failed\". A check that cannot finish at all reports "
685
+ + "verified: false, an empty items list and redirects.error { code, message }.",
675
686
  "verified is true only if every entry matched and the map revision stayed unchanged during the check. "
676
687
  + "The check proves the current map at staging, not a historical deployment. A mismatch means the staged "
677
688
  + "release does not carry the entry (yet); re-run redirects check after the deployment completes, before promoting. Requests are bounded to 90 seconds, eight at a time; "
678
- + "large JSON lists are truncated explicitly with failed entries first. Human output reports every entry.",
689
+ + "large JSON lists are truncated explicitly with failed entries first (redirects.itemsTruncated). Progress "
690
+ + "output reports every entry on stderr; --quiet hides it.",
679
691
  "stagingPreview.url is a fresh, single-use two-minute handoff, minted after checks. It is emitted only "
680
692
  + "in final JSON, excluded from progress and GITHUB_OUTPUT; keep it private. The staging cookie lasts "
681
693
  + "five minutes and remains subject to current site/host/credential permission.",
@@ -937,7 +949,9 @@ const WORKFLOW_REFERENCES = Object.freeze({
937
949
  + `sourceFormat, and workspaceMode ${PAGE_WORKSPACE_MODE_EDITABLE}. Album images and the cover image must be `
938
950
  + "listed in fixture.imageIds. "
939
951
  + "status, hasDraft, isGenerated, and description are recorded by pull and not read: the page key set "
940
- + "stays open so a fixture from a newer pull is not refused. The homepage's path is the empty string.",
952
+ + "stays open so a fixture from a newer pull is not refused. The homepage's path is the empty string. "
953
+ + `An entry with workspaceMode ${PAGE_WORKSPACE_MODE_METADATA_ONLY} names a page the fixture has no source for, `
954
+ + "such as a generated section page navigation links to: pageId, resourceId and path, and no file.",
941
955
  `file lives under '${PAGES_DIRECTORY}/' and ends in ${PAGE_SOURCE_EXTENSIONS.join(" or ")}; sourceFormat must be `
942
956
  + `the format that extension implies (${
943
957
  PAGE_SOURCE_EXTENSIONS.map((extension) => `${extension} is '${pageSourceFormat(`page${extension}`)}'`).join(
@@ -971,8 +985,9 @@ const WORKFLOW_REFERENCES = Object.freeze({
971
985
  "validate reads the fixture and writes nothing to it. Copy the directory somewhere writable before editing it.",
972
986
  "validate --init <new-directory> exports the current pulled workspace as a validated version-7 fixture with "
973
987
  + "appearance and footer metadata. Run from the workspace or its configured project; --config may select the source.",
974
- "Initialization keeps editable pages of every template as workspace documents and reports excluded metadata-only pages. "
975
- + "References to excluded pages must be resolved before the fixture can validate. It never copies credentials, "
988
+ "Initialization keeps editable pages of every template as workspace documents. A metadata-only page that "
989
+ + "navigation, the footer or a page links to is kept as a metadata-only entry (initialized.linkedPages); other "
990
+ + "metadata-only pages are left out (initialized.skippedPages). It never copies credentials, "
976
991
  + "internal reconciliation state, or deployment receipts. UUIDs and HTTP(S) origins are replaced with deterministic "
977
992
  + "fixture identities and example.test origins; URL credentials, queries and fragments are removed. Authored prose is retained.",
978
993
  "The destination must be outside the source workspace and must not exist; its parent must already exist without symlinks. Exports are bounded to 64 MiB. "
@@ -1518,6 +1533,31 @@ const FREE_FORM_REFERENCE = Object.freeze({
1518
1533
  + "refuses before changing anything under pages/ and preserves the site's version as internal state. A "
1519
1534
  + "locally edited .pm.json is kept rather than overwritten, and only collides when the site changed too.",
1520
1535
  conflictError: "pages.pull_conflict",
1536
+ metadata:
1537
+ "A .pm.json page's title, path and description live in the manifest, not the file. Change the title or "
1538
+ + "description with pages meta set <path> --title/--description; a Markdown page keeps them in its front "
1539
+ + "matter. pull keeps such an edit until pages push sends it, and a site edit to the same page is a "
1540
+ + "pages.pull_conflict. A description over 160 characters is warned about (search results cut it off); "
1541
+ + "over 1000 is refused.",
1542
+ authorship:
1543
+ "Pages are authorless by default: a page created without an author publishes with no byline, and pages push "
1544
+ + "lists the pages it created that way in pages.authorless. Name an author with author: in the front matter "
1545
+ + "(every template) or, for a .pm.json page, pages meta set <path> --author. An author is a site author "
1546
+ + "without a Taproot account, named by handle (jane-doe), or a site member who can create pages, named by "
1547
+ + "email address. authors list shows who can be named and rewrites authors.json; authors add <handle> "
1548
+ + "--name <name> [--email <address>] creates a site author, whose address stays private. A site author's "
1549
+ + "name appears on the byline and in structured data, with no profile link or follow button. authors add "
1550
+ + "again with the same handle and name succeeds (existing: true); another name for a taken handle is "
1551
+ + "authors.handle_taken. Only the CLI assigns authors, and only to a page that has none. authors.json is as old "
1552
+ + "as the last pull or authors list, so offline commands (validate, pages meta set) only warn "
1553
+ + "(pages.author_unverified) about a name it lacks; pages push and plan read the site's current list once on a "
1554
+ + "miss and refuse only a name the site lacks. The CLI only fills a page that has no author: a different one "
1555
+ + "already on the page is pages.author_conflict, which names the way out (pages meta set <path> --author "
1556
+ + "<the site's author> for a .pm.json page, the author: line for Markdown, or pages meta set <path> --author "
1557
+ + "\"\" to drop an author that was never sent), naming the author a page already has changes nothing, and a "
1558
+ + "name that is unknown or belongs to a member who cannot create pages is pages.author_unknown either way. "
1559
+ + "Text that is neither a handle nor an address is pages.author_invalid.",
1560
+ authorshipErrors: Object.freeze(["pages.author_invalid", "pages.author_unknown", "pages.author_conflict"]),
1521
1561
  conflictDetail:
1522
1562
  "A reported conflict names the JSON paths at which the site's document differs from the copy this workspace "
1523
1563
  + "last reconciled with, in error.differences, which carries three states. Absent: the two documents were "
@@ -1569,13 +1609,23 @@ const FREE_FORM_REFERENCE = Object.freeze({
1569
1609
  inheritedForTracked: true,
1570
1610
  defaultForNew: "",
1571
1611
  }),
1612
+ Object.freeze({
1613
+ name: "author",
1614
+ type: "string",
1615
+ requiredForNew: false,
1616
+ inheritedForTracked: true,
1617
+ defaultForNew: "",
1618
+ description:
1619
+ "A site author's handle (jane-doe) or a member's email address. Omit it for an authorless page; it "
1620
+ + "only fills a page that has none, and never replaces one.",
1621
+ }),
1572
1622
  Object.freeze({
1573
1623
  name: "template",
1574
1624
  type: "string",
1575
1625
  requiredForNew: false,
1576
1626
  inheritedForTracked: false,
1577
1627
  defaultForNew: "free-form",
1578
- description: `Other templates: ${TYPED_PAGE_TYPES.join(", ")}; see '${CLI_BINARY_NAME} help pages'.`,
1628
+ description: `Other templates: ${MARKDOWN_PAGE_TYPES.join(", ")}; see '${CLI_BINARY_NAME} help pages'.`,
1579
1629
  }),
1580
1630
  ]),
1581
1631
  }),
@@ -1866,7 +1916,7 @@ export function formatReferenceResult(result) {
1866
1916
  page.workspace.formats.map((format) => ` ${format.extension.padEnd(9)} ${format.purpose}`).join("\n")
1867
1917
  }\n\nOne source per page:\n rule ${sourceRule.rule}\n manifest fields ${
1868
1918
  sourceRule.manifestFields.join(", ")
1869
- }\n pull ${sourceRule.pull}\n internal state ${sourceRule.internalState}\n format change ${sourceRule.formatChange} (${sourceRule.formatChangeError})\n renamed source ${sourceRule.renamedSource} (${sourceRule.renamedSourceError})\n conflicts ${sourceRule.conflict} (${sourceRule.conflictError})\n conflict detail ${sourceRule.conflictDetail}\n push conflicts ${sourceRule.pushConflict} (${sourceRule.pushConflictError})\n revision ${sourceRule.revisionSource}\n recovery ${sourceRule.conflictRecovery}\n push selection ${sourceRule.pushSelection}\n\nSystem pages: ${page.workspace.systemPages}\n\nMarkdown front matter:\n${
1919
+ }\n pull ${sourceRule.pull}\n internal state ${sourceRule.internalState}\n format change ${sourceRule.formatChange} (${sourceRule.formatChangeError})\n renamed source ${sourceRule.renamedSource} (${sourceRule.renamedSourceError})\n conflicts ${sourceRule.conflict} (${sourceRule.conflictError})\n conflict detail ${sourceRule.conflictDetail}\n push conflicts ${sourceRule.pushConflict} (${sourceRule.pushConflictError})\n revision ${sourceRule.revisionSource}\n recovery ${sourceRule.conflictRecovery}\n push selection ${sourceRule.pushSelection}\n authors ${sourceRule.authorship} (${sourceRule.authorshipErrors.join(", ")})\n\nSystem pages: ${page.workspace.systemPages}\n\nMarkdown front matter:\n${
1870
1920
  markdownFormat.metadata.map(formatMetadata).join("\n")
1871
1921
  }\n\nDocument root: { "type": "doc", "content": [...] }\nSupported nodes: ${
1872
1922
  page.document.nodes.join(", ")
@@ -152,3 +152,65 @@ export async function checkStagingRedirects(client, siteId, onProgress, now) {
152
152
  },
153
153
  };
154
154
  }
155
+
156
+ /**
157
+ * The staging redirect check and a fresh preview handoff, as redirects check and
158
+ * deploy --staging report them. A check that cannot finish keeps the same shape,
159
+ * with the reason.
160
+ */
161
+ export async function inspectStagingPreview(client, siteId, onProgress, now) {
162
+ let routeCheck;
163
+ let redirects;
164
+ let stagingUrl;
165
+ try {
166
+ ({ routeCheck, redirects, stagingUrl } = await checkStagingRedirects(client, siteId, onProgress, now));
167
+ if (!redirects.verified) {
168
+ onProgress(
169
+ "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.",
170
+ );
171
+ }
172
+ } catch (error) {
173
+ onProgress(
174
+ "Warning: the authenticated staging redirect check could not finish. Run 'taproot-site redirects check' again.",
175
+ );
176
+ routeCheck = "unresolved";
177
+ redirects = {
178
+ total: 0,
179
+ checked: 0,
180
+ matched: 0,
181
+ failed: 0,
182
+ verified: false,
183
+ items: [],
184
+ error: redirectCheckError(error),
185
+ };
186
+ }
187
+ try {
188
+ // Mint after checks so the final single-use URL has never been consumed.
189
+ const stagingPreview = await mintStagingPreviewHandoff(client, siteId, { now });
190
+ return { routeCheck, redirects, stagingPreview };
191
+ } catch {
192
+ onProgress("Warning: a staging handoff could not be minted.");
193
+ return {
194
+ routeCheck,
195
+ redirects,
196
+ stagingPreview: {
197
+ url: "",
198
+ ...(stagingUrl ? { stagingUrl } : {}),
199
+ warning: "Staging handoff unavailable; run redirects check to retry.",
200
+ },
201
+ };
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Why the automatic redirect check could not finish. Only this CLI's own
207
+ * errors carry their code and message; anything else (a timeout, a network
208
+ * failure) is named, never echoed, since its text is not ours to publish.
209
+ */
210
+ function redirectCheckError(error) {
211
+ if (error instanceof SiteAuthoringError) {
212
+ return { code: error.code, message: sanitizeDiagnostic(error.message, "The redirect check could not finish.") };
213
+ }
214
+ const name = typeof error?.name === "string" && /^[A-Za-z]{1,40}$/u.test(error.name) ? error.name : "Error";
215
+ return { code: "redirects.check_failed", message: `The redirect check could not finish (${name}).` };
216
+ }
package/src/transport.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import {
2
+ BANDWIDTH_ALLOWANCE_REFUSAL_REASON,
2
3
  CAPABILITY_REFUSAL_FIELD,
3
4
  CAPABILITY_REFUSAL_REASON,
4
5
  CLI_NAME,
5
6
  CLI_UPGRADE_REFUSAL_FIELD,
6
7
  CLI_VERSION,
7
8
  CREDENTIAL_REFUSAL_FIELD,
9
+ DEPLOYMENT_ALLOWANCE_REFUSAL_REASON,
8
10
  GRPC_RESOURCE_EXHAUSTED,
9
11
  GRPC_UNAUTHENTICATED,
10
12
  HTTP_TOO_MANY_REQUESTS,
@@ -21,6 +23,7 @@ import {
21
23
  REFUSAL_THROTTLED,
22
24
  REFUSAL_UNCLASSIFIED,
23
25
  ROLLOUT_REFUSAL_FIELD,
26
+ SITE_DEPLOYMENT_REFUSAL_DOMAIN,
24
27
  } from "./constants.js";
25
28
  import { Readable } from "node:stream";
26
29
 
@@ -409,15 +412,10 @@ function capabilityNames(value) {
409
412
  * keys on it rather than on `@type`, which a future packing could reshape.
410
413
  */
411
414
  export function capabilityRefusal(value) {
412
- const queue = [{ value, depth: 0 }];
413
- let work = 0;
414
- while (queue.length > 0 && work < 1_000) {
415
- const current = queue.shift();
416
- work += 1;
417
- if (!current || current.depth > 8 || current.value === null || typeof current.value !== "object") continue;
418
- const metadata = current.value.metadata;
415
+ return findErrorInfo(value, (detail) => {
416
+ const metadata = detail.metadata;
419
417
  if (
420
- current.value.reason === CAPABILITY_REFUSAL_REASON
418
+ detail.reason === CAPABILITY_REFUSAL_REASON
421
419
  && metadata !== null
422
420
  && typeof metadata === "object"
423
421
  && !Array.isArray(metadata)
@@ -434,6 +432,61 @@ export function capabilityRefusal(value) {
434
432
  });
435
433
  }
436
434
  }
435
+ return undefined;
436
+ });
437
+ }
438
+
439
+ /**
440
+ * The pages-generated allowance or bandwidth-pause refusal the deploy API attaches
441
+ * as a `google.rpc.ErrorInfo` detail (TR00445, TR01201), or undefined. Keyed on the
442
+ * namespaced domain and reason, never the message sentence. `resetsAt` is the
443
+ * server's `resets_at` re-serialized as an ISO-8601 UTC instant, or undefined
444
+ * when it is absent (a licence that grants no deployments has no period) or
445
+ * does not parse. `requestedPages` and `remainingPages` are present on the
446
+ * allowance refusal only.
447
+ */
448
+ export function planLimitRefusal(value) {
449
+ return findErrorInfo(value, (detail) => {
450
+ if (
451
+ detail.domain !== SITE_DEPLOYMENT_REFUSAL_DOMAIN
452
+ || (detail.reason !== DEPLOYMENT_ALLOWANCE_REFUSAL_REASON && detail.reason !== BANDWIDTH_ALLOWANCE_REFUSAL_REASON)
453
+ ) {
454
+ return undefined;
455
+ }
456
+ const metadata = detail.metadata !== null && typeof detail.metadata === "object" ? detail.metadata : {};
457
+ const resetsAt = typeof metadata.resets_at === "string" ? new Date(metadata.resets_at) : undefined;
458
+ return Object.freeze({
459
+ reason: detail.reason,
460
+ resetsAt: resetsAt !== undefined && !Number.isNaN(resetsAt.getTime()) ? resetsAt.toISOString() : undefined,
461
+ // The pages the refused request needed and the period had left (TR01201).
462
+ requestedPages: pageCount(metadata.requested_pages),
463
+ remainingPages: pageCount(metadata.remaining_pages),
464
+ });
465
+ });
466
+ }
467
+
468
+ /** A page count the server reported as a decimal string, or undefined. */
469
+ function pageCount(value) {
470
+ const count = typeof value === "string" && /^[0-9]{1,9}$/u.test(value) ? Number(value) : undefined;
471
+ return Number.isSafeInteger(count) ? count : undefined;
472
+ }
473
+
474
+ /**
475
+ * The first object in `value`, breadth first, for which `match` returns a
476
+ * result. Transcoded `details[]` entries are `Any`-packed and nested, so one
477
+ * pinned path through them would silently empty every extractor on a contract
478
+ * change; the walk is bounded in depth and in work so a hostile body cannot
479
+ * stall it.
480
+ */
481
+ function findErrorInfo(value, match) {
482
+ const queue = [{ value, depth: 0 }];
483
+ let work = 0;
484
+ while (queue.length > 0 && work < 1_000) {
485
+ const current = queue.shift();
486
+ work += 1;
487
+ if (!current || current.depth > 8 || current.value === null || typeof current.value !== "object") continue;
488
+ const found = match(current.value);
489
+ if (found !== undefined) return found;
437
490
  for (const child of Object.values(current.value)) {
438
491
  if (child !== null && typeof child === "object") queue.push({ value: child, depth: current.depth + 1 });
439
492
  }
@@ -459,6 +512,7 @@ export class ApiError extends SiteAuthoringError {
459
512
  const grpcCode = Number.isSafeInteger(body?.code) ? body.code : undefined;
460
513
  const fields = fieldViolations(body);
461
514
  const capability = capabilityRefusal(body);
515
+ const planLimit = planLimitRefusal(body);
462
516
  // Only the known upgrade refusal may supply a human message. Other server
463
517
  // errors can contain internal diagnostics and retain their generic output.
464
518
  const upgradeMessage = fields.includes(CLI_UPGRADE_REFUSAL_FIELD)
@@ -472,6 +526,10 @@ export class ApiError extends SiteAuthoringError {
472
526
  "api.request_rejected",
473
527
  upgradeMessage || (fields.length > 0
474
528
  ? `Taproot rejected the request field '${fields[0]}'.`
529
+ : planLimit !== undefined
530
+ ? planLimit.reason === BANDWIDTH_ALLOWANCE_REFUSAL_REASON
531
+ ? "Taproot paused deployments: the site used more than its monthly bandwidth for two months in a row."
532
+ : "Taproot refused the request: it generates more pages than the site's allowance for this billing period has left."
475
533
  : capability !== undefined
476
534
  ? `Taproot refused the request: this credential does not carry a capability granting '${
477
535
  capability.permission
@@ -491,6 +549,7 @@ export class ApiError extends SiteAuthoringError {
491
549
  this.fields = fields;
492
550
  this.violationDescriptions = fieldDescriptions(body);
493
551
  this.capability = capability;
552
+ this.planLimit = planLimit;
494
553
  // The `retry-after` trailer (TR00839's deployment throttle, or any other
495
554
  // ResourceExhausted refusal that sets it) transcodes to an ordinary
496
555
  // response header. Undefined when absent or unparseable — callers must
@@ -545,10 +604,12 @@ export class ApiError extends SiteAuthoringError {
545
604
  * left it unclassified: no `refusal` in the JSON result, and nothing said
546
605
  * on the human channel. The field arrives only from the in-transaction
547
606
  * re-validation, which does attach `ExternalApiKey`.
548
- * - `plan_limit` (field `UpgradePrompt`) — a commercial ceiling, surfaced at
607
+ * - `plan_limit` (field `UpgradePrompt`, or `ErrorInfo` reason
608
+ * `DEPLOYMENT_PERIOD_ALLOWANCE_EXCEEDED` / `BANDWIDTH_ALLOWANCE_EXCEEDED`
609
+ * with the server's `resets_at`) — a commercial ceiling, surfaced at
549
610
  * deploy time because no pre-flight read of the published-page quota
550
- * exists on the contract. **Upgrade** (or publish fewer pages); the CLI
551
- * never invents the numeric limit.
611
+ * exists on the contract. **Upgrade** (or publish fewer pages, or wait for
612
+ * the named reset); the CLI never invents the numeric limit.
552
613
  * - `throttled` (gRPC `ResourceExhausted` / HTTP 429) — the per-key
553
614
  * throttle. **Back off** and retry with delay.
554
615
  * - `capability_missing` (`ErrorInfo` reason `SITE_AUTHORING_CAPABILITY_MISSING`)
@@ -568,7 +629,7 @@ export class ApiError extends SiteAuthoringError {
568
629
  if (this.hasField(CLI_UPGRADE_REFUSAL_FIELD)) return REFUSAL_CLI_OUTDATED;
569
630
  if (this.hasField(ROLLOUT_REFUSAL_FIELD)) return REFUSAL_PLATFORM_PAUSED;
570
631
  if (this.hasField(CREDENTIAL_REFUSAL_FIELD)) return REFUSAL_CREDENTIAL_REJECTED;
571
- if (this.hasField(PLAN_LIMIT_REFUSAL_FIELD)) return REFUSAL_PLAN_LIMIT;
632
+ if (this.hasField(PLAN_LIMIT_REFUSAL_FIELD) || this.planLimit !== undefined) return REFUSAL_PLAN_LIMIT;
572
633
  // The named detail ranks with the fields and above the status mappings, for
573
634
  // the same reason: it is the specific statement about this refusal.
574
635
  if (this.capability !== undefined) return REFUSAL_CAPABILITY_MISSING;