@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,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
|
+
}
|
package/src/fixture-init.js
CHANGED
|
@@ -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:
|
|
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?.(
|
|
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: {
|
|
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
|
+
}
|
package/src/reference-help.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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.
|
|
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
|
|
975
|
-
+ "
|
|
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: ${
|
|
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(", ")
|
package/src/staging-check.js
CHANGED
|
@@ -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
|
-
|
|
413
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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;
|