@taprootio/site-authoring 0.14.1 → 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.
- package/README.md +181 -16
- package/examples/riverbend-wellness/.taproot-site-media.json +23 -0
- package/examples/riverbend-wellness/pages/index.md +1 -17
- package/package.json +2 -2
- package/src/api.js +123 -1
- package/src/authors-contract.js +170 -0
- package/src/cli.js +355 -30
- package/src/constants.js +9 -0
- package/src/content/components.js +64 -6
- package/src/content/free-form-sections.js +21 -4
- package/src/content/link-warnings.js +109 -0
- package/src/content/markdown.js +161 -26
- package/src/content/tiptap-prosemirror.ts +66 -8
- package/src/content/validate-document.js +30 -6
- package/src/content/vocabulary.js +26 -0
- package/src/errors.js +22 -0
- package/src/espalier-shared.js +11 -0
- package/src/fixture-init.js +55 -3
- package/src/font-catalog.js +87 -0
- package/src/footer-contract.js +1 -1
- package/src/output.js +19 -1
- package/src/page-metadata.js +92 -0
- package/src/presentation-reference.js +21 -7
- package/src/problems.js +116 -0
- package/src/reference-help.js +157 -41
- package/src/session.js +13 -0
- package/src/staging-check.js +62 -0
- package/src/theme-proposals.js +393 -0
- package/src/theme-validation.js +31 -10
- package/src/typed-page-reference.js +97 -21
- package/src/typed-pages.js +196 -13
- package/src/verbs/approve.js +63 -14
- package/src/verbs/authors.js +167 -0
- package/src/verbs/deploy.js +5 -28
- package/src/verbs/index.js +22 -0
- package/src/verbs/media-upload.js +127 -30
- package/src/verbs/nav-push.js +7 -0
- package/src/verbs/pages-meta-set.js +303 -0
- package/src/verbs/pages-push.js +814 -224
- package/src/verbs/places.js +61 -0
- package/src/verbs/plan.js +595 -0
- package/src/verbs/pull.js +255 -20
- package/src/verbs/redirects-check.js +4 -10
- package/src/verbs/theme-push.js +68 -36
- package/src/verbs/validate.js +309 -217
- package/src/video-prepare.js +48 -12
- package/src/workspace-check.js +199 -0
- package/src/workspace-ledger.js +173 -0
- package/src/workspace.js +105 -10
package/src/verbs/approve.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import {
|
|
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(
|
|
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(
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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(
|
|
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
|
+
}
|
package/src/verbs/deploy.js
CHANGED
|
@@ -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 {
|
|
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:
|
|
422
|
+
nextStep: redirects.verified === true ? "deploy --production" : "redirects check",
|
|
446
423
|
});
|
|
447
424
|
});
|
|
448
425
|
}
|
package/src/verbs/index.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
|
+
VERB_APPLY,
|
|
2
3
|
VERB_APPROVE,
|
|
4
|
+
VERB_AUTHORS_ADD,
|
|
5
|
+
VERB_AUTHORS_LIST,
|
|
3
6
|
VERB_DELIVERY_CHECK,
|
|
4
7
|
VERB_DEPLOY,
|
|
5
8
|
VERB_ENV,
|
|
@@ -11,7 +14,11 @@ import {
|
|
|
11
14
|
VERB_LOGOUT,
|
|
12
15
|
VERB_MEDIA_UPLOAD,
|
|
13
16
|
VERB_NAV_PUSH,
|
|
17
|
+
VERB_PAGES_META_SET,
|
|
14
18
|
VERB_PAGES_PUSH,
|
|
19
|
+
VERB_PLACES_SEARCH,
|
|
20
|
+
VERB_PLACES_SELECT,
|
|
21
|
+
VERB_PLAN,
|
|
15
22
|
VERB_PREVIEW_PAGE,
|
|
16
23
|
VERB_PREVIEW_REVOKE,
|
|
17
24
|
VERB_PULL,
|
|
@@ -27,6 +34,7 @@ import {
|
|
|
27
34
|
VERB_WHOAMI,
|
|
28
35
|
} from "../constants.js";
|
|
29
36
|
import { approve } from "./approve.js";
|
|
37
|
+
import { authorsAdd, authorsList } from "./authors.js";
|
|
30
38
|
import { deliveryCheck } from "./delivery-check.js";
|
|
31
39
|
import { deploy } from "./deploy.js";
|
|
32
40
|
import { env } from "./env.js";
|
|
@@ -38,7 +46,10 @@ import { login } from "./login.js";
|
|
|
38
46
|
import { logout } from "./logout.js";
|
|
39
47
|
import { mediaUpload } from "./media-upload.js";
|
|
40
48
|
import { navPush } from "./nav-push.js";
|
|
49
|
+
import { pagesMetaSet } from "./pages-meta-set.js";
|
|
41
50
|
import { pagesPush } from "./pages-push.js";
|
|
51
|
+
import { placesSearch, placesSelect } from "./places.js";
|
|
52
|
+
import { apply, plan } from "./plan.js";
|
|
42
53
|
import { previewPage } from "./preview-page.js";
|
|
43
54
|
import { previewRevoke } from "./preview-revoke.js";
|
|
44
55
|
import { pull } from "./pull.js";
|
|
@@ -70,6 +81,7 @@ import { whoami } from "./whoami.js";
|
|
|
70
81
|
* deployTarget, // "staging" | "production" for `deploy`, else undefined
|
|
71
82
|
* quiet, // --quiet; suppresses progress, never the JSON result
|
|
72
83
|
* keyName, // --name; `login` only, undefined everywhere else
|
|
84
|
+
* planHash, // --plan; `apply` only: the plan it may run
|
|
73
85
|
* onProgress, // human progress sink (stderr, or a no-op when quiet)
|
|
74
86
|
* fetch, // injectable fetch, for tests
|
|
75
87
|
* signal, // optional AbortSignal
|
|
@@ -92,8 +104,11 @@ import { whoami } from "./whoami.js";
|
|
|
92
104
|
* // with none, the workspace's media/ directory is walked
|
|
93
105
|
* formKeys, // forms validate / push: narrow to these form keys;
|
|
94
106
|
* // with none, every file under forms/
|
|
107
|
+
* placeQuery, // places search: the words of the query
|
|
108
|
+
* placeSelection, // places select: [googlePlaceId, sessionToken]
|
|
95
109
|
* pagePaths, // approve: narrow to these page paths; with none, every
|
|
96
110
|
* // draft the workspace manifest tracks is staged
|
|
111
|
+
* authorHandle, // authors add: the handle of the site author to create
|
|
97
112
|
* pageId, // preview page: the one persisted draft UUID to render
|
|
98
113
|
* previewIds, // preview revoke: [page UUID, snapshot UUID]
|
|
99
114
|
* }
|
|
@@ -126,7 +141,14 @@ export const VERB_HANDLERS = Object.freeze({
|
|
|
126
141
|
[VERB_LOGOUT]: logout,
|
|
127
142
|
[VERB_PULL]: pull,
|
|
128
143
|
[VERB_PAGES_PUSH]: pagesPush,
|
|
144
|
+
[VERB_PAGES_META_SET]: pagesMetaSet,
|
|
145
|
+
[VERB_AUTHORS_LIST]: authorsList,
|
|
146
|
+
[VERB_AUTHORS_ADD]: authorsAdd,
|
|
129
147
|
[VERB_NAV_PUSH]: navPush,
|
|
148
|
+
[VERB_PLAN]: plan,
|
|
149
|
+
[VERB_PLACES_SEARCH]: placesSearch,
|
|
150
|
+
[VERB_PLACES_SELECT]: placesSelect,
|
|
151
|
+
[VERB_APPLY]: apply,
|
|
130
152
|
[VERB_REDIRECTS_CHECK]: redirectsCheck,
|
|
131
153
|
[VERB_REDIRECTS_PULL]: redirectsPull,
|
|
132
154
|
[VERB_REDIRECTS_PUSH]: redirectsPush,
|
|
@@ -17,10 +17,11 @@ import {
|
|
|
17
17
|
import { LIMITS, VERB_MEDIA_UPLOAD } from "../constants.js";
|
|
18
18
|
import { SiteAuthoringError, sanitizeDiagnostic } from "../errors.js";
|
|
19
19
|
import { contentHash, inspectImageBytes } from "../image-metadata.js";
|
|
20
|
+
import { CHECK_AREA, collectProblems, problemsOf } from "../problems.js";
|
|
20
21
|
import { ApiError } from "../transport.js";
|
|
21
22
|
import { sniffVideoContentType, VIDEO_EXTENSIONS, videoTooLarge } from "../video-metadata.js";
|
|
22
|
-
import { mp4Name, prepareVideoFile } from "../video-prepare.js";
|
|
23
|
-
import {
|
|
23
|
+
import { mp4Name, prepareVideoFile, videoAcceptance } from "../video-prepare.js";
|
|
24
|
+
import { boundedByBytes, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
24
25
|
import {
|
|
25
26
|
ensureWorkspaceRoot,
|
|
26
27
|
inspectWorkspaceEntry,
|
|
@@ -30,6 +31,7 @@ import {
|
|
|
30
31
|
openWorkspaceFileStream,
|
|
31
32
|
readMediaManifest,
|
|
32
33
|
readWorkspaceFile,
|
|
34
|
+
recordedAlt,
|
|
33
35
|
resolveWorkspacePath,
|
|
34
36
|
SAFE_MEDIA_SEGMENT,
|
|
35
37
|
walkWorkspaceFiles,
|
|
@@ -72,7 +74,8 @@ import {
|
|
|
72
74
|
* it for evidence of a pull that never happened.
|
|
73
75
|
*/
|
|
74
76
|
|
|
75
|
-
|
|
77
|
+
// Each list's share of the result's 64 KiB; the counts are always complete.
|
|
78
|
+
const ITEMS_BYTES = 24 * 1024;
|
|
76
79
|
const MAXIMUM_FILES = 500;
|
|
77
80
|
const MEDIA_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp", ...VIDEO_EXTENSIONS];
|
|
78
81
|
const MEDIA_WALK_OPTIONS = Object.freeze({
|
|
@@ -80,6 +83,118 @@ const MEDIA_WALK_OPTIONS = Object.freeze({
|
|
|
80
83
|
segmentDescription: "letters, digits, '.', '_', '-', and '@'",
|
|
81
84
|
});
|
|
82
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The files under `media/` an upload would still send (TR00823): an image
|
|
88
|
+
* whose bytes the media manifest does not record, and a video with no
|
|
89
|
+
* confirmed upload. Each is classified and checked against the limits the
|
|
90
|
+
* upload applies, so a plan does not call ready a file the upload would
|
|
91
|
+
* refuse, and carries an identity of its content, so a plan made before the
|
|
92
|
+
* file was replaced does not describe it. A recorded upload is done only when
|
|
93
|
+
* the site still holds it: an image processed (one still processing, or gone,
|
|
94
|
+
* is sent again; the upload deduplicates and waits; one that failed is a
|
|
95
|
+
* problem), a video in its library. `site` answers both:
|
|
96
|
+
* `imageStates` maps the recorded image ids to what the site holds, and
|
|
97
|
+
* `videos` is the video library as `listVideos` reads it. Read only.
|
|
98
|
+
*/
|
|
99
|
+
export async function pendingMediaFiles(workspaceDir, mediaManifest, site) {
|
|
100
|
+
const files = await walkWorkspaceFiles(workspaceDir, MEDIA_DIRECTORY, MEDIA_EXTENSIONS, MEDIA_WALK_OPTIONS);
|
|
101
|
+
const pending = [];
|
|
102
|
+
const problems = [];
|
|
103
|
+
const listedVideoIds = new Set(site.videos.videos.map((candidate) => candidate.videoId));
|
|
104
|
+
for (const file of files) {
|
|
105
|
+
const video = mediaManifest.videos?.[file];
|
|
106
|
+
const image = mediaManifest.media?.[file];
|
|
107
|
+
const entry = await collectProblems(problems, { area: CHECK_AREA.media, file }, async () => {
|
|
108
|
+
if (typeof image?.imageId === "string") {
|
|
109
|
+
const bytes = await readWorkspaceFile(workspaceDir, file, WORKSPACE_LIMITS.mediaBytes);
|
|
110
|
+
if (image.contentHash === contentHash(bytes)) {
|
|
111
|
+
const held = site.imageStates.get(image.imageId);
|
|
112
|
+
if (held === IMAGE_PROCESSING_STATE_FAILED) {
|
|
113
|
+
throw new SiteAuthoringError(
|
|
114
|
+
"media.processing_failed",
|
|
115
|
+
`Taproot failed to process '${file}' (image ${image.imageId}), so it cannot be published. Replace the `
|
|
116
|
+
+ "file with one that processes, or remove it and the references to it.",
|
|
117
|
+
{ field: file, status: IMAGE_PROCESSING_STATE_FAILED },
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (held === IMAGE_PROCESSING_STATE_COMPLETE) return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const { source, identity, bytes } = await mediaFileIdentity(workspaceDir, file);
|
|
124
|
+
// One that still matches its confirmed video upload is done.
|
|
125
|
+
if (
|
|
126
|
+
typeof video?.videoId === "string"
|
|
127
|
+
&& video.pendingConfirm !== true
|
|
128
|
+
&& video.byteLength === source.byteLength
|
|
129
|
+
&& video.modifiedMilliseconds === source.modifiedMilliseconds
|
|
130
|
+
) {
|
|
131
|
+
if (listedVideoIds.has(video.videoId)) return undefined;
|
|
132
|
+
// The same refusal the upload gives: a listing cut short cannot say the video is gone.
|
|
133
|
+
if (site.videos.truncated) throw videoLibraryUnverifiable(file, video.videoId, site.videos.videos.length);
|
|
134
|
+
}
|
|
135
|
+
if (sniffVideoContentType(source.header) !== undefined) {
|
|
136
|
+
if (source.byteLength > WORKSPACE_LIMITS.videoBytes) throw videoTooLarge(file, WORKSPACE_LIMITS.videoBytes);
|
|
137
|
+
const acceptance = await videoAcceptance({
|
|
138
|
+
filePath: resolveWorkspacePath(workspaceDir, file),
|
|
139
|
+
byteLength: source.byteLength,
|
|
140
|
+
});
|
|
141
|
+
if (!acceptance.accepted) {
|
|
142
|
+
throw new SiteAuthoringError(
|
|
143
|
+
"media.video_unsupported",
|
|
144
|
+
acceptance.videoCodec === "" && acceptance.audioCodec === ""
|
|
145
|
+
? `'${file}' could not be read as a video, so the upload would be refused.`
|
|
146
|
+
: `'${file}' is ${acceptance.videoCodec ? `${acceptance.videoCodec} video` : "no video"} with `
|
|
147
|
+
+ `${acceptance.audioCodec ? `${acceptance.audioCodec} audio` : "no audio"}. `
|
|
148
|
+
+ "Taproot accepts H.264 video with AAC audio or none: export it that way and upload it again.",
|
|
149
|
+
{ field: file },
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return { file, kind: "video", identity };
|
|
153
|
+
}
|
|
154
|
+
const { width, height } = inspectImageBytes(bytes, file);
|
|
155
|
+
return { file, kind: "image", identity, width, height };
|
|
156
|
+
});
|
|
157
|
+
if (entry !== undefined) pending.push(entry);
|
|
158
|
+
}
|
|
159
|
+
if (pending.length > MAXIMUM_FILES) {
|
|
160
|
+
problems.push(...problemsOf(tooManyFiles(pending.length), { area: CHECK_AREA.media }));
|
|
161
|
+
}
|
|
162
|
+
return { files: pending, problems };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* What a media file is, for telling a plan's file from a replaced one: a
|
|
167
|
+
* video by its size and modification time, as the upload records it; anything
|
|
168
|
+
* else by its bytes, which are returned too.
|
|
169
|
+
*/
|
|
170
|
+
async function mediaFileIdentity(workspaceDir, file) {
|
|
171
|
+
const source = await openWorkspaceFileStream(workspaceDir, file, Number.MAX_SAFE_INTEGER);
|
|
172
|
+
await source.close();
|
|
173
|
+
if (sniffVideoContentType(source.header) !== undefined) {
|
|
174
|
+
return { source, identity: `${source.byteLength}:${source.modifiedMilliseconds}` };
|
|
175
|
+
}
|
|
176
|
+
const bytes = await readWorkspaceFile(workspaceDir, file, WORKSPACE_LIMITS.mediaBytes);
|
|
177
|
+
return { source, identity: contentHash(bytes), bytes };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function videoLibraryUnverifiable(file, videoId, listed) {
|
|
181
|
+
return new SiteAuthoringError(
|
|
182
|
+
"media.video_library_unverifiable",
|
|
183
|
+
`'${file}' was uploaded as video ${videoId}, which is not among the first ${listed} videos of this `
|
|
184
|
+
+ "site's library, and the library is larger than one run can check. Remove the file's entry from "
|
|
185
|
+
+ `${MEDIA_MANIFEST_FILE_NAME} to upload it again, or leave it.`,
|
|
186
|
+
{ field: file },
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function tooManyFiles(count) {
|
|
191
|
+
return new SiteAuthoringError(
|
|
192
|
+
"media.too_many_files",
|
|
193
|
+
`This run would upload ${count} files, more than the bounded maximum of ${MAXIMUM_FILES}.`,
|
|
194
|
+
{ field: MEDIA_DIRECTORY },
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
83
198
|
function requireUploadCapability(response, fileName) {
|
|
84
199
|
if (
|
|
85
200
|
typeof response.presignedUrl !== "string"
|
|
@@ -339,13 +454,7 @@ async function uploadVideoFile({
|
|
|
339
454
|
// A listing cut short cannot say the video is gone, and uploading again
|
|
340
455
|
// would spend storage on a video that may well be there.
|
|
341
456
|
if (truncated) {
|
|
342
|
-
throw
|
|
343
|
-
"media.video_library_unverifiable",
|
|
344
|
-
`'${file}' was uploaded as video ${recorded.videoId}, which is not among the first ${byId.size} videos of this `
|
|
345
|
-
+ "site's library, and the library is larger than one run can check. Remove the file's entry from "
|
|
346
|
-
+ `${MEDIA_MANIFEST_FILE_NAME} to upload it again, or leave it.`,
|
|
347
|
-
{ field: file },
|
|
348
|
-
);
|
|
457
|
+
throw videoLibraryUnverifiable(file, recorded.videoId, byId.size);
|
|
349
458
|
}
|
|
350
459
|
}
|
|
351
460
|
}
|
|
@@ -441,13 +550,7 @@ export async function mediaUpload(invocation) {
|
|
|
441
550
|
{ field: selected.length > 0 ? normalizePositional(selected[0]) : MEDIA_DIRECTORY },
|
|
442
551
|
);
|
|
443
552
|
}
|
|
444
|
-
if (files.length > MAXIMUM_FILES)
|
|
445
|
-
throw new SiteAuthoringError(
|
|
446
|
-
"media.too_many_files",
|
|
447
|
-
`This run would upload ${files.length} files, more than the bounded maximum of ${MAXIMUM_FILES}.`,
|
|
448
|
-
{ field: MEDIA_DIRECTORY },
|
|
449
|
-
);
|
|
450
|
-
}
|
|
553
|
+
if (files.length > MAXIMUM_FILES) throw tooManyFiles(files.length);
|
|
451
554
|
|
|
452
555
|
const uploaded = [];
|
|
453
556
|
const uploadedVideos = [];
|
|
@@ -553,7 +656,7 @@ export async function mediaUpload(invocation) {
|
|
|
553
656
|
// Preserve the author-owned alt text. Delivery fields start with
|
|
554
657
|
// whatever confirm returned and are refreshed from the completed
|
|
555
658
|
// library record below.
|
|
556
|
-
alt:
|
|
659
|
+
alt: recordedAlt(mediaManifest.media[file]),
|
|
557
660
|
src: image.url,
|
|
558
661
|
urls: image.responsiveUrls,
|
|
559
662
|
deduplicated,
|
|
@@ -599,7 +702,9 @@ export async function mediaUpload(invocation) {
|
|
|
599
702
|
manifestEntry.urls = image.responsiveUrls;
|
|
600
703
|
}
|
|
601
704
|
await writeMediaManifest(config.workspaceDir, mediaManifest);
|
|
602
|
-
|
|
705
|
+
// The delivery URLs stay in the media manifest: a signed URL list per
|
|
706
|
+
// file is what pushed a bulk upload past the result's 64 KiB (TR01001).
|
|
707
|
+
const reported = boundedByBytes(
|
|
603
708
|
uploaded.map((entry) => ({
|
|
604
709
|
file: entry.file,
|
|
605
710
|
imageId: entry.imageId,
|
|
@@ -607,18 +712,10 @@ export async function mediaUpload(invocation) {
|
|
|
607
712
|
width: entry.width,
|
|
608
713
|
height: entry.height,
|
|
609
714
|
processingState: observed.get(entry.imageId)?.processingState ?? IMAGE_PROCESSING_STATE_COMPLETE,
|
|
610
|
-
media: {
|
|
611
|
-
imageId: entry.imageId,
|
|
612
|
-
src: observed.get(entry.imageId)?.url ?? "",
|
|
613
|
-
urls: observed.get(entry.imageId)?.responsiveUrls ?? [],
|
|
614
|
-
width: entry.width,
|
|
615
|
-
height: entry.height,
|
|
616
|
-
alt: mediaManifest.media[entry.file]?.alt ?? "",
|
|
617
|
-
},
|
|
618
715
|
})),
|
|
619
|
-
|
|
716
|
+
ITEMS_BYTES,
|
|
620
717
|
);
|
|
621
|
-
const reportedVideos =
|
|
718
|
+
const reportedVideos = boundedByBytes(
|
|
622
719
|
uploadedVideos.map((entry) => ({
|
|
623
720
|
file: entry.file,
|
|
624
721
|
videoId: entry.videoId,
|
|
@@ -635,7 +732,7 @@ export async function mediaUpload(invocation) {
|
|
|
635
732
|
// block for `.pm.json` sources.
|
|
636
733
|
component: videoComponent(entry.videoId),
|
|
637
734
|
})),
|
|
638
|
-
|
|
735
|
+
ITEMS_BYTES,
|
|
639
736
|
);
|
|
640
737
|
return successResult(VERB_MEDIA_UPLOAD, siteId, {
|
|
641
738
|
mediaManifestFile: MEDIA_MANIFEST_FILE_NAME,
|
package/src/verbs/nav-push.js
CHANGED
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
isContactOrWebUrl,
|
|
16
16
|
} from "../contact-url.js";
|
|
17
17
|
import { SiteAuthoringError } from "../errors.js";
|
|
18
|
+
import { stableJson } from "../footer-draft-hash.js";
|
|
19
|
+
import { planStale } from "../problems.js";
|
|
18
20
|
import { boundedList, openSession, successResult, warnIfExternalWritesPaused } from "../session.js";
|
|
19
21
|
import {
|
|
20
22
|
NAVIGATION_FILE_NAME,
|
|
@@ -304,6 +306,11 @@ export async function navPush(invocation) {
|
|
|
304
306
|
// small as the contract allows.
|
|
305
307
|
onProgress("Re-reading the live navigation tree immediately before replacing it.");
|
|
306
308
|
const liveItems = await getNavigation(client, siteId);
|
|
309
|
+
// Under apply, the tree replaced must be the one its plan read: a change
|
|
310
|
+
// made since was never reviewed, and this write would erase it.
|
|
311
|
+
if (invocation.plannedLiveNavigation !== undefined && stableJson(liveItems) !== invocation.plannedLiveNavigation) {
|
|
312
|
+
throw planStale("The site's navigation", NAVIGATION_FILE_NAME);
|
|
313
|
+
}
|
|
307
314
|
const liveIds = collectIds(liveItems);
|
|
308
315
|
const pushedIds = collectIds(navItems);
|
|
309
316
|
const removed = [...liveIds].filter((id) => !pushedIds.has(id)).sort();
|