@taprootio/site-authoring 0.1.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/LICENSE +15 -0
- package/README.md +239 -0
- package/bin/taproot-site.js +5 -0
- package/package.json +40 -0
- package/src/api.js +1507 -0
- package/src/appearance-contract.js +311 -0
- package/src/atomic-file.js +136 -0
- package/src/bounded-file.js +59 -0
- package/src/capabilities.js +29 -0
- package/src/cli.js +872 -0
- package/src/config.js +412 -0
- package/src/constants.js +173 -0
- package/src/content/components.js +870 -0
- package/src/content/free-form-section-registry.json +524 -0
- package/src/content/free-form-sections.js +582 -0
- package/src/content/index.js +29 -0
- package/src/content/inline-facts.js +97 -0
- package/src/content/markdown.js +1625 -0
- package/src/content/tiptap-prosemirror.ts +1266 -0
- package/src/content/validate-document.js +1079 -0
- package/src/content/vocabulary.js +397 -0
- package/src/credentials.js +544 -0
- package/src/errors.js +133 -0
- package/src/footer-contract.js +687 -0
- package/src/footer-draft-hash.js +203 -0
- package/src/footer-workspace.js +136 -0
- package/src/image-metadata.js +169 -0
- package/src/index.js +16 -0
- package/src/json-guard.js +111 -0
- package/src/output.js +157 -0
- package/src/presentation-reference.js +600 -0
- package/src/reference-help.js +927 -0
- package/src/session.js +316 -0
- package/src/settings-catalog.js +187 -0
- package/src/settings.js +157 -0
- package/src/theme-validation.js +344 -0
- package/src/transport.js +777 -0
- package/src/verbs/approve.js +178 -0
- package/src/verbs/deploy.js +327 -0
- package/src/verbs/env.js +89 -0
- package/src/verbs/footer-push.js +115 -0
- package/src/verbs/index.js +125 -0
- package/src/verbs/login.js +529 -0
- package/src/verbs/logout.js +39 -0
- package/src/verbs/media-upload.js +319 -0
- package/src/verbs/nav-push.js +333 -0
- package/src/verbs/pages-push.js +990 -0
- package/src/verbs/preview-page.js +107 -0
- package/src/verbs/preview-revoke.js +56 -0
- package/src/verbs/pull.js +727 -0
- package/src/verbs/sites.js +57 -0
- package/src/verbs/status.js +94 -0
- package/src/verbs/theme-push.js +197 -0
- package/src/verbs/use.js +129 -0
- package/src/verbs/validate.js +477 -0
- package/src/verbs/whoami.js +108 -0
- package/src/workspace.js +1085 -0
|
@@ -0,0 +1,990 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createPage,
|
|
3
|
+
freeFormTemplate,
|
|
4
|
+
listSitePages,
|
|
5
|
+
PAGE_STATUS_DELETED,
|
|
6
|
+
TEMPLATE_TYPE_FREE_FORM,
|
|
7
|
+
updatePage,
|
|
8
|
+
withRefusalGuidance,
|
|
9
|
+
} from "../api.js";
|
|
10
|
+
import { VERB_PAGES_PUSH } from "../constants.js";
|
|
11
|
+
import { SiteAuthoringError } from "../errors.js";
|
|
12
|
+
import { boundedList, openSession, successResult } from "../session.js";
|
|
13
|
+
import { SETTINGS_TYPE_TAPROOT_STYLES } from "../settings-catalog.js";
|
|
14
|
+
import {
|
|
15
|
+
hasNamedFreeFormSectionContext,
|
|
16
|
+
sharedThemeContextNames,
|
|
17
|
+
validateFreeFormSectionContexts,
|
|
18
|
+
} from "../content/free-form-sections.js";
|
|
19
|
+
import { CONTENT_ERROR_CODES } from "../content/vocabulary.js";
|
|
20
|
+
import {
|
|
21
|
+
MEDIA_MANIFEST_FILE_NAME,
|
|
22
|
+
normalizePagePath,
|
|
23
|
+
PAGE_SOURCE_EXTENSIONS,
|
|
24
|
+
PAGE_SOURCE_FORMAT_MARKDOWN,
|
|
25
|
+
PAGE_SOURCE_FORMAT_PROSEMIRROR,
|
|
26
|
+
pageSourceFormat,
|
|
27
|
+
pageSourceRegistry,
|
|
28
|
+
PAGES_DIRECTORY,
|
|
29
|
+
readManifest,
|
|
30
|
+
readMediaManifest,
|
|
31
|
+
readOnlySystem404Projections,
|
|
32
|
+
readWorkspaceFile,
|
|
33
|
+
readWorkspaceJson,
|
|
34
|
+
requireManifestSourceRegistry,
|
|
35
|
+
SAFE_SEGMENT,
|
|
36
|
+
SETTINGS_DIRECTORY,
|
|
37
|
+
SYSTEM_PAGE_NOT_FOUND_PATH,
|
|
38
|
+
walkWorkspaceFiles,
|
|
39
|
+
WORKSPACE_LIMITS,
|
|
40
|
+
workspaceContentHash,
|
|
41
|
+
writeManifest,
|
|
42
|
+
} from "../workspace.js";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `pages push` — create and update pages from the local workspace.
|
|
46
|
+
*
|
|
47
|
+
* The verb runs in two phases, and the split is the point of it. Phase one
|
|
48
|
+
* reads, converts, and *validates every document it will send* without touching
|
|
49
|
+
* the API; phase two sends. The server checks a free-form body only for
|
|
50
|
+
* presence, so a malformed document is accepted, stored, and then fails
|
|
51
|
+
* silently at render — which means a push that validated page three after
|
|
52
|
+
* publishing pages one and two would leave a half-broken site behind. Nothing
|
|
53
|
+
* is sent until everything has passed.
|
|
54
|
+
*
|
|
55
|
+
* **What "everything" means depends on the selection.** With no page path, it
|
|
56
|
+
* is the whole workspace: every authored source is resolved, converted, and
|
|
57
|
+
* validated, and that is the command whose job is to report a page nobody has
|
|
58
|
+
* touched since a contract changed. With page paths, it is those pages. A
|
|
59
|
+
* selection resolves to its source from metadata alone — a manifest entry or a
|
|
60
|
+
* front-matter block — before any document is converted, so an unrelated page
|
|
61
|
+
* carrying an obsolete component shape is never converted at all and cannot
|
|
62
|
+
* block a push it was never part of.
|
|
63
|
+
*
|
|
64
|
+
* Scoping the validation is not scoping the safety. Site binding, manifest
|
|
65
|
+
* integrity, the live create-or-update resolution, system-page rules,
|
|
66
|
+
* target-path uniqueness against the live site, media ownership, and two
|
|
67
|
+
* workspace files claiming one page path all still fail closed for the
|
|
68
|
+
* selection. What narrows is only the set of *other* pages whose bodies this
|
|
69
|
+
* run has an opinion about.
|
|
70
|
+
*
|
|
71
|
+
* Wire facts the body honors:
|
|
72
|
+
* - Home (path `""`) and `404` are seeded system pages: update-only, and their
|
|
73
|
+
* paths are immutable (`SystemPagePaths.IsPathChangeAllowed`).
|
|
74
|
+
* - A page's template type is immutable after creation, so an update against a
|
|
75
|
+
* page that is not FREE_FORM is refused rather than sent.
|
|
76
|
+
* - Creates are POST and updates are PATCH with a *whole* template, not a patch
|
|
77
|
+
* mask. Neither is replayed by the transport, so each is issued once and an
|
|
78
|
+
* ambiguous outcome surfaces as ambiguous.
|
|
79
|
+
*
|
|
80
|
+
* `rawHtml` renders verbatim and unsanitized. It is rejected unless the caller
|
|
81
|
+
* explicitly asks for it via `allowRawHtml`, which is exactly the thing an
|
|
82
|
+
* agent must not be able to emit by accident.
|
|
83
|
+
*
|
|
84
|
+
* Pull's system 404 is the deliberate exception to "every file is authored."
|
|
85
|
+
* Its exact bytes remain inspectable in the workspace but carry a manifest
|
|
86
|
+
* hash and read-only marker. An unchanged whole-workspace push skips it;
|
|
87
|
+
* changing, deleting, or replacing it is a refusal before any remote write.
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
const MAXIMUM_REPORTED = 200;
|
|
91
|
+
const FRONT_MATTER_FENCE = "---";
|
|
92
|
+
const FRONT_MATTER_KEYS = new Set(["title", "path", "description"]);
|
|
93
|
+
const FRONT_MATTER_ENTRY = /^([A-Za-z][A-Za-z0-9_]*)[ \t]*:[ \t]*(.*)$/u;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The refusals that mean "this file does not say which page it is".
|
|
97
|
+
*
|
|
98
|
+
* A targeted push records one of these against the file and carries on: a
|
|
99
|
+
* source that declares no page path cannot be the page the selection asked
|
|
100
|
+
* for, so blocking on it is the whole of residual R3. The list is explicit
|
|
101
|
+
* rather than "any error from the read", because a containment refusal, an
|
|
102
|
+
* oversized file, or an unreadable one is not a metadata fault and must still
|
|
103
|
+
* fail closed. A whole-workspace push reports every one of them as before.
|
|
104
|
+
*/
|
|
105
|
+
const RESOLUTION_METADATA_CODES = new Set([
|
|
106
|
+
"pages.front_matter_missing",
|
|
107
|
+
"pages.front_matter_unterminated",
|
|
108
|
+
"pages.front_matter_invalid",
|
|
109
|
+
"pages.front_matter_unknown",
|
|
110
|
+
"pages.front_matter_duplicate",
|
|
111
|
+
"pages.metadata_missing",
|
|
112
|
+
"pages.path_missing",
|
|
113
|
+
"pages.path_unsupported",
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
function documentError(code, message, field) {
|
|
117
|
+
return new SiteAuthoringError(code, message, { field });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function unquote(value) {
|
|
121
|
+
const trimmed = value.trim();
|
|
122
|
+
if (trimmed.length >= 2 && (trimmed.startsWith("\"") || trimmed.startsWith("'"))) {
|
|
123
|
+
const quote = trimmed[0];
|
|
124
|
+
if (trimmed.endsWith(quote)) return trimmed.slice(1, -1);
|
|
125
|
+
}
|
|
126
|
+
return trimmed;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A deliberately tiny front-matter reader: exactly the three keys the page
|
|
131
|
+
* contract needs, and a hard error on anything else. Silently dropping an
|
|
132
|
+
* unrecognized key is how authored metadata disappears without a trace, and the
|
|
133
|
+
* server would never notice.
|
|
134
|
+
*/
|
|
135
|
+
function parseFrontMatter(source, file) {
|
|
136
|
+
const lines = source.split(/\r?\n/u);
|
|
137
|
+
if (lines[0]?.trim() !== FRONT_MATTER_FENCE) {
|
|
138
|
+
throw documentError(
|
|
139
|
+
"pages.front_matter_missing",
|
|
140
|
+
`'${file}' must begin with a '---' front-matter block declaring at least a title and a path.`,
|
|
141
|
+
file,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const closing = lines.findIndex((line, index) => index > 0 && line.trim() === FRONT_MATTER_FENCE);
|
|
145
|
+
if (closing < 0) {
|
|
146
|
+
throw documentError("pages.front_matter_unterminated", `The front-matter block in '${file}' is not closed.`, file);
|
|
147
|
+
}
|
|
148
|
+
// The two faults above are structural: without a delimited block there is
|
|
149
|
+
// nothing here to trust, so they still throw. Everything below is one bad
|
|
150
|
+
// *entry* in a block whose other entries are perfectly readable, and the
|
|
151
|
+
// first of those is carried back rather than thrown — because a file that
|
|
152
|
+
// says `path: about` is a source for `about` even if the next line is
|
|
153
|
+
// nonsense, and dropping it would let a second source for that path be sent
|
|
154
|
+
// as though it were the only one. The caller raises the fault for any page
|
|
155
|
+
// it actually intends to send.
|
|
156
|
+
const values = new Map();
|
|
157
|
+
let fault;
|
|
158
|
+
const record = (candidate) => {
|
|
159
|
+
fault ??= candidate;
|
|
160
|
+
};
|
|
161
|
+
for (const line of lines.slice(1, closing)) {
|
|
162
|
+
if (line.trim() === "" || line.trimStart().startsWith("#")) continue;
|
|
163
|
+
const match = FRONT_MATTER_ENTRY.exec(line);
|
|
164
|
+
if (!match) {
|
|
165
|
+
record(documentError(
|
|
166
|
+
"pages.front_matter_invalid",
|
|
167
|
+
`'${file}' has a front-matter line that is not 'key: value'.`,
|
|
168
|
+
file,
|
|
169
|
+
));
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const [, key, rawValue] = match;
|
|
173
|
+
if (!FRONT_MATTER_KEYS.has(key)) {
|
|
174
|
+
record(documentError(
|
|
175
|
+
"pages.front_matter_unknown",
|
|
176
|
+
`'${file}' declares unsupported front-matter field '${key}'. `
|
|
177
|
+
+ `Supported fields: ${[...FRONT_MATTER_KEYS].join(", ")}.`,
|
|
178
|
+
key,
|
|
179
|
+
));
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const seen = values.get(key);
|
|
183
|
+
if (seen === undefined) {
|
|
184
|
+
values.set(key, [unquote(rawValue)]);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
record(documentError(
|
|
188
|
+
"pages.front_matter_duplicate",
|
|
189
|
+
`'${file}' declares front-matter field '${key}' twice.`,
|
|
190
|
+
key,
|
|
191
|
+
));
|
|
192
|
+
seen.push(unquote(rawValue));
|
|
193
|
+
}
|
|
194
|
+
// A repeated key is always a fault, but it is not always an *ambiguity*:
|
|
195
|
+
// the same value written twice says exactly one thing. Only genuinely
|
|
196
|
+
// differing values leave nothing to resolve, and those are handed back as
|
|
197
|
+
// candidates rather than dropped, because a file that says either `about`
|
|
198
|
+
// or `elsewhere` is a possible second source for both.
|
|
199
|
+
const fields = new Map();
|
|
200
|
+
const candidates = new Map();
|
|
201
|
+
for (const [key, seen] of values) {
|
|
202
|
+
const distinct = [...new Set(seen)];
|
|
203
|
+
if (distinct.length === 1) fields.set(key, distinct[0]);
|
|
204
|
+
else candidates.set(key, distinct);
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
fields,
|
|
208
|
+
candidates,
|
|
209
|
+
fault,
|
|
210
|
+
markdown: lines.slice(closing + 1).join("\n"),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const STYLES_FILE = `${SETTINGS_DIRECTORY}/taproot-styles.json`;
|
|
215
|
+
|
|
216
|
+
async function readSharedThemeContexts(workspaceDir, siteId) {
|
|
217
|
+
const document_ = await readWorkspaceJson(workspaceDir, STYLES_FILE, WORKSPACE_LIMITS.settingsBytes);
|
|
218
|
+
if (
|
|
219
|
+
document_ === null
|
|
220
|
+
|| typeof document_ !== "object"
|
|
221
|
+
|| Array.isArray(document_)
|
|
222
|
+
|| document_.settings === null
|
|
223
|
+
|| typeof document_.settings !== "object"
|
|
224
|
+
|| Array.isArray(document_.settings)
|
|
225
|
+
|| document_.settings.lightTheme === null
|
|
226
|
+
|| typeof document_.settings.lightTheme !== "object"
|
|
227
|
+
|| Array.isArray(document_.settings.lightTheme)
|
|
228
|
+
|| document_.settings.darkTheme === null
|
|
229
|
+
|| typeof document_.settings.darkTheme !== "object"
|
|
230
|
+
|| Array.isArray(document_.settings.darkTheme)
|
|
231
|
+
) {
|
|
232
|
+
throw documentError(
|
|
233
|
+
"pages.theme_settings_invalid",
|
|
234
|
+
`'${STYLES_FILE}' must be the complete Taproot styles document written by pull.`,
|
|
235
|
+
STYLES_FILE,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
if (document_.entityId !== siteId || document_.settingsType !== SETTINGS_TYPE_TAPROOT_STYLES) {
|
|
239
|
+
throw documentError(
|
|
240
|
+
"pages.theme_settings_site_mismatch",
|
|
241
|
+
`'${STYLES_FILE}' is not bound to site ${siteId} and ${SETTINGS_TYPE_TAPROOT_STYLES}. Run pull again.`,
|
|
242
|
+
STYLES_FILE,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return sharedThemeContextNames(document_.settings.lightTheme, document_.settings.darkTheme);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function unwrapProseMirrorDocument(parsed, file) {
|
|
249
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
250
|
+
if (parsed.type === "doc") return parsed;
|
|
251
|
+
if (parsed.body !== null && typeof parsed.body === "object" && parsed.body?.type === "doc") return parsed.body;
|
|
252
|
+
}
|
|
253
|
+
throw documentError(
|
|
254
|
+
"pages.document_shape",
|
|
255
|
+
`'${file}' is not a ProseMirror document: expected a top-level object with "type": "doc".`,
|
|
256
|
+
file,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The candidate keys a Markdown image reference could match in the media
|
|
262
|
+
* manifest. Authors write `./media/hero.png`, `media/hero.png`, or
|
|
263
|
+
* `/media/hero.png` for the same file, and a resolver that accepted only one
|
|
264
|
+
* spelling would refuse content that is plainly correct.
|
|
265
|
+
*/
|
|
266
|
+
function mediaReferenceCandidates(reference) {
|
|
267
|
+
if (typeof reference !== "string" || reference === "") return [];
|
|
268
|
+
const withoutQuery = reference.split("#")[0].split("?")[0];
|
|
269
|
+
const stripped = withoutQuery.replace(/^\.\//u, "").replace(/^\/+/u, "");
|
|
270
|
+
const candidates = new Set([reference, withoutQuery, stripped]);
|
|
271
|
+
if (!stripped.includes("/")) candidates.add(`media/${stripped}`);
|
|
272
|
+
return [...candidates];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function makeResolveImage(mediaManifest, file) {
|
|
276
|
+
return async (reference) => {
|
|
277
|
+
for (const candidate of mediaReferenceCandidates(reference)) {
|
|
278
|
+
const entry = mediaManifest.media[candidate];
|
|
279
|
+
if (entry !== null && typeof entry === "object" && typeof entry.imageId === "string" && entry.imageId !== "") {
|
|
280
|
+
return {
|
|
281
|
+
imageId: entry.imageId,
|
|
282
|
+
// `PageImageDeliveryRewriter` only replaces keys that are already
|
|
283
|
+
// present, so a `taprootImage` node missing `src` or `urls` is
|
|
284
|
+
// rewritten into nothing and renders blank. Both are handed back
|
|
285
|
+
// empty and present.
|
|
286
|
+
src: typeof entry.src === "string" ? entry.src : "",
|
|
287
|
+
urls: Array.isArray(entry.urls) ? entry.urls : [],
|
|
288
|
+
width: Number.isSafeInteger(entry.width) ? entry.width : 0,
|
|
289
|
+
height: Number.isSafeInteger(entry.height) ? entry.height : 0,
|
|
290
|
+
alt: typeof entry.alt === "string" ? entry.alt : "",
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
throw documentError(
|
|
295
|
+
"media.unresolved_reference",
|
|
296
|
+
`'${file}' references media '${reference}', which is not recorded in ${MEDIA_MANIFEST_FILE_NAME}. `
|
|
297
|
+
+ "Run 'taproot-site media upload' first.",
|
|
298
|
+
typeof reference === "string" ? reference : file,
|
|
299
|
+
);
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* S3 owns `src/content/`. It is imported dynamically so this build stays
|
|
305
|
+
* loadable — and every other verb stays usable — before that slice lands, and
|
|
306
|
+
* so tests can inject the seam directly.
|
|
307
|
+
*/
|
|
308
|
+
async function loadContentModule(injected) {
|
|
309
|
+
if (injected !== undefined) return injected;
|
|
310
|
+
try {
|
|
311
|
+
return await import("../content/index.js");
|
|
312
|
+
} catch {
|
|
313
|
+
throw new SiteAuthoringError(
|
|
314
|
+
"content.unavailable",
|
|
315
|
+
"The Markdown and ProseMirror content pipeline is not available in this build.",
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function requireContentFunctions(content) {
|
|
321
|
+
if (
|
|
322
|
+
content === null
|
|
323
|
+
|| typeof content !== "object"
|
|
324
|
+
|| typeof content.validateDocument !== "function"
|
|
325
|
+
|| typeof content.markdownToProseMirror !== "function"
|
|
326
|
+
) {
|
|
327
|
+
throw new SiteAuthoringError(
|
|
328
|
+
"content.contract_invalid",
|
|
329
|
+
"The content pipeline does not expose validateDocument and markdownToProseMirror.",
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
return content;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function assertValid(content, document_, file, allowRawHtml) {
|
|
336
|
+
const outcome = content.validateDocument(document_, { allowRawHtml });
|
|
337
|
+
const errors = outcome === null || typeof outcome !== "object" ? undefined : outcome.errors;
|
|
338
|
+
if (!Array.isArray(errors)) {
|
|
339
|
+
throw new SiteAuthoringError(
|
|
340
|
+
"content.contract_invalid",
|
|
341
|
+
"The content validator did not return a list of validation errors.",
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
if (errors.length === 0) return;
|
|
345
|
+
const first = errors[0] ?? {};
|
|
346
|
+
const location = typeof first.path === "string" && first.path !== "" ? first.path : "doc";
|
|
347
|
+
throw documentError(
|
|
348
|
+
first.code === CONTENT_ERROR_CODES.rawHtmlForbidden ? CONTENT_ERROR_CODES.rawHtmlForbidden : "pages.document_invalid",
|
|
349
|
+
`'${file}' is not a valid Taproot document: ${errors.length} problem(s), first at ${location} (${
|
|
350
|
+
typeof first.code === "string" ? first.code : "invalid"
|
|
351
|
+
}): ${typeof first.message === "string" ? first.message : "the node or mark is outside the accepted vocabulary."}`,
|
|
352
|
+
`${file}:${location}`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function systemPageKind(pagePath) {
|
|
357
|
+
if (pagePath === "") return "home";
|
|
358
|
+
return pagePath.toLowerCase() === SYSTEM_PAGE_NOT_FOUND_PATH ? "notFound" : undefined;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function requireText(value, code, message, field) {
|
|
362
|
+
if (typeof value !== "string" || value.trim() === "") throw documentError(code, message, field);
|
|
363
|
+
return value;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Reads one editable page source and resolves the metadata that says *which
|
|
368
|
+
* page it is* — no Markdown conversion, no media resolution, no document
|
|
369
|
+
* vocabulary.
|
|
370
|
+
*
|
|
371
|
+
* The split is what makes a targeted push targeted. Deciding whether a file
|
|
372
|
+
* claims the requested path costs a read and a front-matter parse; deciding
|
|
373
|
+
* whether its body is still valid costs the whole content pipeline, and that
|
|
374
|
+
* is where a page left on an obsolete component contract fails. Resolving
|
|
375
|
+
* identity separately means an unrelated stale page is never converted at all,
|
|
376
|
+
* so it cannot block a push that was never going to send it.
|
|
377
|
+
*/
|
|
378
|
+
export async function readWorkspacePageSource({ workspaceDir, file, manifestEntry }) {
|
|
379
|
+
const isMarkdown = pageSourceFormat(file) === PAGE_SOURCE_FORMAT_MARKDOWN;
|
|
380
|
+
const bytes = await readWorkspaceFile(workspaceDir, file, WORKSPACE_LIMITS.documentBytes);
|
|
381
|
+
const source = bytes.toString("utf8");
|
|
382
|
+
|
|
383
|
+
let markdown;
|
|
384
|
+
let fault;
|
|
385
|
+
let pathCandidates = [];
|
|
386
|
+
let title;
|
|
387
|
+
let declaredDescription;
|
|
388
|
+
let declaredPath;
|
|
389
|
+
if (isMarkdown) {
|
|
390
|
+
const frontMatter = parseFrontMatter(source, file);
|
|
391
|
+
title = frontMatter.fields.get("title") ?? manifestEntry?.title;
|
|
392
|
+
declaredDescription = frontMatter.fields.get("description") ?? manifestEntry?.description;
|
|
393
|
+
declaredPath = frontMatter.fields.has("path") ? frontMatter.fields.get("path") : manifestEntry?.path;
|
|
394
|
+
markdown = frontMatter.markdown;
|
|
395
|
+
fault = frontMatter.fault;
|
|
396
|
+
pathCandidates = frontMatter.candidates.get("path") ?? [];
|
|
397
|
+
} else {
|
|
398
|
+
if (manifestEntry === undefined) {
|
|
399
|
+
throw documentError(
|
|
400
|
+
"pages.metadata_missing",
|
|
401
|
+
`'${file}' is a raw ProseMirror document with no manifest entry, so its title and path are unknown. `
|
|
402
|
+
+ "Author it as Markdown with front-matter, or run 'taproot-site pull' first.",
|
|
403
|
+
file,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
title = manifestEntry.title;
|
|
407
|
+
declaredDescription = manifestEntry.description;
|
|
408
|
+
declaredPath = manifestEntry.path;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const pagePath = normalizePagePath(declaredPath);
|
|
412
|
+
if (pagePath === undefined) {
|
|
413
|
+
// Not one resolved page, but not nothing either: a file declaring two
|
|
414
|
+
// different paths is a candidate source for each of them, and a push that
|
|
415
|
+
// touches one of those paths must not proceed as though this file were
|
|
416
|
+
// absent. Anything that touches a claim fails closed on the fault below.
|
|
417
|
+
const claims = [...new Set(pathCandidates.map((value) => normalizePagePath(value)))]
|
|
418
|
+
.filter((value) => value !== undefined);
|
|
419
|
+
if (claims.length > 0) return { file, pagePath: undefined, pathClaims: claims, fault };
|
|
420
|
+
// Nothing readable said which page this is, so a deferred entry fault is
|
|
421
|
+
// the real reason resolution failed and is reported in preference to a
|
|
422
|
+
// generic "no path".
|
|
423
|
+
if (fault !== undefined) throw fault;
|
|
424
|
+
throw documentError(
|
|
425
|
+
"pages.path_missing",
|
|
426
|
+
`'${file}' does not declare a usable page path. `
|
|
427
|
+
+ "Add 'path:' to its front-matter (use an empty value for the home page).",
|
|
428
|
+
file,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
// The server enforces `\A[A-Za-z0-9][A-Za-z0-9._-]*\z` on every segment
|
|
432
|
+
// (`Validations.ValidatePagePath`). Checking it here is what keeps phase
|
|
433
|
+
// one's promise: without it `path: Hello World` passes validation and fails
|
|
434
|
+
// mid-phase-two, after earlier pages have already been created. Home ("")
|
|
435
|
+
// has no segments and is always legal.
|
|
436
|
+
if (pagePath !== "" && !pagePath.split("/").every((segment) => SAFE_SEGMENT.test(segment))) {
|
|
437
|
+
throw documentError(
|
|
438
|
+
"pages.path_unsupported",
|
|
439
|
+
`'${file}' declares page path '${pagePath}', but Taproot requires every path segment to match `
|
|
440
|
+
+ "[A-Za-z0-9][A-Za-z0-9._-]* — start with a letter or digit, and use only letters, digits, "
|
|
441
|
+
+ "'.', '_', and '-'.",
|
|
442
|
+
file,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
file,
|
|
448
|
+
pagePath,
|
|
449
|
+
title,
|
|
450
|
+
declaredDescription,
|
|
451
|
+
fault,
|
|
452
|
+
pathClaims: [],
|
|
453
|
+
sourceFormat: isMarkdown ? PAGE_SOURCE_FORMAT_MARKDOWN : PAGE_SOURCE_FORMAT_PROSEMIRROR,
|
|
454
|
+
sourceHash: workspaceContentHash(bytes),
|
|
455
|
+
markdown,
|
|
456
|
+
text: source,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* The metadata checks that are *not* part of deciding which page a source is.
|
|
462
|
+
*
|
|
463
|
+
* They are separate because a file that declares `path: about` is a source for
|
|
464
|
+
* `about` whether or not it also declares a title, and the duplicate-source
|
|
465
|
+
* rule has to see it either way. Folding this into path resolution let a
|
|
466
|
+
* titleless second source drop out of a targeted push as "unresolved", so the
|
|
467
|
+
* other `about` source was sent as though it were the only one — the guess
|
|
468
|
+
* one source per page exists to refuse.
|
|
469
|
+
*/
|
|
470
|
+
export function requireCompletePageMetadata(source) {
|
|
471
|
+
if (source.fault !== undefined) throw source.fault;
|
|
472
|
+
requireText(source.title, "pages.title_missing", `'${source.file}' does not declare a title.`, source.file);
|
|
473
|
+
return source;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Turns one resolved page source into the canonical ProseMirror document the
|
|
478
|
+
* wire carries — converting Markdown through the bounded subset and resolving
|
|
479
|
+
* its media, or unwrapping an authored document.
|
|
480
|
+
*/
|
|
481
|
+
export async function convertWorkspacePageSource({ source, mediaManifest, content: injectedContent }) {
|
|
482
|
+
const content = requireContentFunctions(await loadContentModule(injectedContent));
|
|
483
|
+
const { file } = source;
|
|
484
|
+
if (source.sourceFormat === PAGE_SOURCE_FORMAT_MARKDOWN) {
|
|
485
|
+
const converted = await content.markdownToProseMirror(source.markdown, {
|
|
486
|
+
resolveImage: makeResolveImage(mediaManifest, file),
|
|
487
|
+
});
|
|
488
|
+
return unwrapProseMirrorDocument(
|
|
489
|
+
converted === null || typeof converted !== "object" ? undefined : converted.doc,
|
|
490
|
+
file,
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
let parsed;
|
|
494
|
+
try {
|
|
495
|
+
parsed = JSON.parse(source.text);
|
|
496
|
+
} catch {
|
|
497
|
+
throw documentError("pages.document_shape", `'${file}' is not valid JSON.`, file);
|
|
498
|
+
}
|
|
499
|
+
return unwrapProseMirrorDocument(parsed, file);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Reads and converts one editable page source in a single step, for the
|
|
504
|
+
* offline fixture validator.
|
|
505
|
+
*
|
|
506
|
+
* Live push no longer takes this path. It resolves every source's identity
|
|
507
|
+
* first — `readWorkspacePageSource` for all of them, then one cross-file
|
|
508
|
+
* duplicate-path pass — and converts only what it is going to send, so a
|
|
509
|
+
* fixture's file-at-a-time walk and a push's selection no longer share an
|
|
510
|
+
* error order. A fixture binds every page it declares and validates them all,
|
|
511
|
+
* so the distinction costs it nothing.
|
|
512
|
+
*/
|
|
513
|
+
export async function validateWorkspacePageSource({
|
|
514
|
+
workspaceDir,
|
|
515
|
+
file,
|
|
516
|
+
manifestEntry,
|
|
517
|
+
mediaManifest,
|
|
518
|
+
content: injectedContent,
|
|
519
|
+
}) {
|
|
520
|
+
const source = requireCompletePageMetadata(await readWorkspacePageSource({ workspaceDir, file, manifestEntry }));
|
|
521
|
+
return {
|
|
522
|
+
...source,
|
|
523
|
+
document: await convertWorkspacePageSource({ source, mediaManifest, content: injectedContent }),
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Validates a parsed page document through the same content and named-context
|
|
529
|
+
* contracts used by live push. Offline fixtures invoke this directly after
|
|
530
|
+
* parsing; live push invokes it at its historical point after identity checks.
|
|
531
|
+
*/
|
|
532
|
+
export async function validateWorkspacePageDocument({
|
|
533
|
+
workspaceDir,
|
|
534
|
+
siteId,
|
|
535
|
+
file,
|
|
536
|
+
document: document_,
|
|
537
|
+
content: injectedContent,
|
|
538
|
+
allowRawHtml = false,
|
|
539
|
+
getSharedThemeContexts,
|
|
540
|
+
}) {
|
|
541
|
+
const content = requireContentFunctions(await loadContentModule(injectedContent));
|
|
542
|
+
assertValid(content, document_, file, allowRawHtml);
|
|
543
|
+
if (hasNamedFreeFormSectionContext(document_)) {
|
|
544
|
+
const contexts = typeof getSharedThemeContexts === "function"
|
|
545
|
+
? await getSharedThemeContexts()
|
|
546
|
+
: await readSharedThemeContexts(workspaceDir, siteId);
|
|
547
|
+
const contextErrors = validateFreeFormSectionContexts(document_, contexts).errors;
|
|
548
|
+
if (contextErrors.length > 0) {
|
|
549
|
+
const first = contextErrors[0];
|
|
550
|
+
throw documentError(
|
|
551
|
+
first.code,
|
|
552
|
+
`'${file}' has an invalid section context at ${first.path}: ${first.message}`,
|
|
553
|
+
`${file}:${first.path}`,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export async function pagesPush(invocation) {
|
|
560
|
+
const { client, config, siteId, onProgress } = await openSession(invocation);
|
|
561
|
+
const allowRawHtml = invocation.allowRawHtml === true;
|
|
562
|
+
// Both manifests are bound to the site before anything is planned or sent:
|
|
563
|
+
// every id in them is site-scoped, and a workspace pulled from another site
|
|
564
|
+
// reads as entirely valid until phase two is already writing.
|
|
565
|
+
const manifest = await readManifest(config.workspaceDir, siteId);
|
|
566
|
+
const mediaManifest = await readMediaManifest(config.workspaceDir, siteId);
|
|
567
|
+
const content = requireContentFunctions(await loadContentModule(invocation.content));
|
|
568
|
+
const requestedPaths = Array.isArray(invocation.pagePaths)
|
|
569
|
+
? new Set(invocation.pagePaths.map((value) => {
|
|
570
|
+
const normalized = normalizePagePath(value);
|
|
571
|
+
if (normalized === undefined) {
|
|
572
|
+
throw new SiteAuthoringError(
|
|
573
|
+
"pages.page_path_invalid",
|
|
574
|
+
`'${typeof value === "string" ? value : String(value)}' is not a usable page path.`,
|
|
575
|
+
{ field: typeof value === "string" ? value : undefined, exitCode: 2 },
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
return normalized;
|
|
579
|
+
}))
|
|
580
|
+
: undefined;
|
|
581
|
+
// Manifest integrity is never scoped: a registry that contradicts itself
|
|
582
|
+
// describes some other workspace, and a selection cannot make that safe.
|
|
583
|
+
requireManifestSourceRegistry(pageSourceRegistry(manifest));
|
|
584
|
+
const readOnlyProjections = readOnlySystem404Projections(manifest);
|
|
585
|
+
const targeted = requestedPaths !== undefined;
|
|
586
|
+
const readOnlyByFile = new Map(readOnlyProjections.map((entry) => [entry.file, entry]));
|
|
587
|
+
const readOnlyByPath = new Map(readOnlyProjections.map((entry) => [normalizePagePath(entry.path), entry]));
|
|
588
|
+
|
|
589
|
+
// Naming the projection in a selection is refused by name before anything is
|
|
590
|
+
// read. It is the one page this workspace may never send, and a selection is
|
|
591
|
+
// an explicit request rather than something to quietly drop.
|
|
592
|
+
if (targeted) {
|
|
593
|
+
const requestedReadOnly = readOnlyProjections.find((entry) =>
|
|
594
|
+
requestedPaths.has(normalizePagePath(entry.path) ?? entry.path));
|
|
595
|
+
if (requestedReadOnly !== undefined) {
|
|
596
|
+
const pagePath = normalizePagePath(requestedReadOnly.path) ?? requestedReadOnly.path;
|
|
597
|
+
throw new SiteAuthoringError(
|
|
598
|
+
"pages.page_read_only",
|
|
599
|
+
`Page path '${pagePath}' is the pulled read-only system 404 projection and cannot be pushed.`,
|
|
600
|
+
{ field: pagePath },
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// The shared list, so the set `pull` refuses to write over is exactly the set
|
|
606
|
+
// this walks: a file one counted and the other ignored would be a page pushed
|
|
607
|
+
// from a workspace nothing proved the ownership of.
|
|
608
|
+
const files = await walkWorkspaceFiles(config.workspaceDir, PAGES_DIRECTORY, PAGE_SOURCE_EXTENSIONS);
|
|
609
|
+
const editableFiles = files.filter((file) => !readOnlyByFile.has(file));
|
|
610
|
+
if (editableFiles.length === 0 && readOnlyProjections.length === 0) {
|
|
611
|
+
throw new SiteAuthoringError(
|
|
612
|
+
"pages.none_found",
|
|
613
|
+
`No Markdown or ProseMirror page files were found under '${PAGES_DIRECTORY}/' in the workspace.`,
|
|
614
|
+
{ field: PAGES_DIRECTORY },
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
// The projection's own integrity is a whole-workspace check. A targeted push
|
|
618
|
+
// has already refused to name it and cannot reach it any other way, so an
|
|
619
|
+
// edited projection is something `pages push` with no path reports — not
|
|
620
|
+
// something that blocks a different page from being sent.
|
|
621
|
+
const skippedReadOnly = [];
|
|
622
|
+
if (!targeted) {
|
|
623
|
+
for (const entry of readOnlyProjections) {
|
|
624
|
+
if (!files.includes(entry.file)) {
|
|
625
|
+
throw documentError(
|
|
626
|
+
"pages.read_only_missing",
|
|
627
|
+
`The pulled read-only system 404 projection '${entry.file}' is missing or was replaced. Run pull again; `
|
|
628
|
+
+ "author the system 404 through an owner-controlled surface.",
|
|
629
|
+
entry.file,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
const bytes = await readWorkspaceFile(config.workspaceDir, entry.file, WORKSPACE_LIMITS.documentBytes);
|
|
633
|
+
if (workspaceContentHash(bytes) !== entry.workspaceContentHash) {
|
|
634
|
+
throw documentError(
|
|
635
|
+
"pages.read_only_modified",
|
|
636
|
+
`The pulled read-only system 404 projection '${entry.file}' changed after pull. No page was pushed. `
|
|
637
|
+
+ "Restore it or pull again; author the system 404 through an owner-controlled surface.",
|
|
638
|
+
entry.file,
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
skippedReadOnly.push({
|
|
642
|
+
file: entry.file,
|
|
643
|
+
path: normalizePagePath(entry.path),
|
|
644
|
+
pageId: entry.pageId,
|
|
645
|
+
reason: entry.readOnlyReason,
|
|
646
|
+
});
|
|
647
|
+
onProgress(`Verified read-only system 404 projection '${entry.file}'; it will not be pushed.`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
let sharedThemeContexts;
|
|
651
|
+
const manifestByFile = new Map(
|
|
652
|
+
manifest.pages.filter((entry) => typeof entry?.file === "string").map((entry) => [entry.file, entry]),
|
|
653
|
+
);
|
|
654
|
+
// Keyed by identity as well as by file, because the two do not always agree.
|
|
655
|
+
// `pull` writes a `.pm.json` and records it under that name; the documented
|
|
656
|
+
// way to switch a page to Markdown is to delete it and author a `.md` beside
|
|
657
|
+
// it — which leaves the page's manifest entry reachable by `pageId` and by
|
|
658
|
+
// nothing else.
|
|
659
|
+
const manifestByPageId = new Map(
|
|
660
|
+
manifest.pages.filter((entry) => typeof entry?.pageId === "string").map((entry) => [entry.pageId, entry]),
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
return await withRefusalGuidance(onProgress, "push", async () => {
|
|
664
|
+
onProgress("Listing the site's pages to resolve creates from updates.");
|
|
665
|
+
const { pages: livePages, truncated } = await listSitePages(client, siteId, { onProgress });
|
|
666
|
+
if (truncated) {
|
|
667
|
+
// Every create-or-update decision below is made against this list. A
|
|
668
|
+
// partial one turns a tracked page into a create, which the server then
|
|
669
|
+
// refuses for a duplicate path — mid-phase-two, after earlier pages have
|
|
670
|
+
// already been written.
|
|
671
|
+
throw new SiteAuthoringError(
|
|
672
|
+
"pages.live_list_truncated",
|
|
673
|
+
"The site has more pages than this CLI can enumerate, so an update cannot be told from a create. "
|
|
674
|
+
+ "No page was pushed.",
|
|
675
|
+
{ field: PAGES_DIRECTORY },
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
const live = livePages.filter((summary) => summary.status !== PAGE_STATUS_DELETED);
|
|
679
|
+
const liveById = new Map(live.map((summary) => [summary.pageId, summary]));
|
|
680
|
+
const liveByPath = new Map(live.map((summary) => [normalizePagePath(summary.path) ?? summary.path, summary]));
|
|
681
|
+
// The projection's live identity is the other half of the whole-workspace
|
|
682
|
+
// read-only check above, and is scoped with it for the same reason.
|
|
683
|
+
if (!targeted) {
|
|
684
|
+
for (const projection of readOnlyProjections) {
|
|
685
|
+
const summary = liveById.get(projection.pageId);
|
|
686
|
+
if (
|
|
687
|
+
normalizePagePath(summary?.path)?.toLowerCase() !== SYSTEM_PAGE_NOT_FOUND_PATH
|
|
688
|
+
|| summary?.templateType !== TEMPLATE_TYPE_FREE_FORM
|
|
689
|
+
) {
|
|
690
|
+
const index = manifest.pages.indexOf(projection);
|
|
691
|
+
throw documentError(
|
|
692
|
+
"workspace.manifest_invalid",
|
|
693
|
+
`The read-only page projection at pages[${index}] does not identify the live free-form system 404. `
|
|
694
|
+
+ "No page was pushed; run 'taproot-site pull' again.",
|
|
695
|
+
`pages[${index}].pageId`,
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Resolution: which file is each page path's one authoritative source.
|
|
702
|
+
// This reads metadata only — a manifest entry, or a front-matter block —
|
|
703
|
+
// so no document is converted, no media reference is resolved, and no
|
|
704
|
+
// component contract is checked until the selection is known.
|
|
705
|
+
const sources = [];
|
|
706
|
+
const claimants = [];
|
|
707
|
+
const unresolved = [];
|
|
708
|
+
for (const file of editableFiles) {
|
|
709
|
+
const manifestEntry = manifestByFile.get(file);
|
|
710
|
+
try {
|
|
711
|
+
const source = await readWorkspacePageSource({ workspaceDir: config.workspaceDir, file, manifestEntry });
|
|
712
|
+
if (source.pagePath === undefined) {
|
|
713
|
+
// A file whose page identity is ambiguous. A whole-workspace push
|
|
714
|
+
// reports every authored page, so it is a refusal there; a targeted
|
|
715
|
+
// one refuses only if the selection touches a path it might be.
|
|
716
|
+
if (!targeted) throw source.fault;
|
|
717
|
+
claimants.push(source);
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
sources.push(source);
|
|
721
|
+
} catch (error) {
|
|
722
|
+
// A source whose metadata cannot be read declares no page path, so it
|
|
723
|
+
// cannot be the page a selection asked for. A whole-workspace push is
|
|
724
|
+
// the command that reports it; a targeted one names it and carries on.
|
|
725
|
+
// Anything that is not a metadata fault — a containment refusal, an
|
|
726
|
+
// oversized file, an unreadable one — still fails closed.
|
|
727
|
+
if (!targeted || !(error instanceof SiteAuthoringError) || !RESOLUTION_METADATA_CODES.has(error.code)) {
|
|
728
|
+
throw error;
|
|
729
|
+
}
|
|
730
|
+
unresolved.push({ file, code: error.code });
|
|
731
|
+
onProgress(`'${file}' declares no readable page path (${error.code}); it is outside this selection.`);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Two editable sources for one page path. Whichever push would touch that
|
|
736
|
+
// path refuses: choosing between them is exactly the ambiguity one source
|
|
737
|
+
// per page exists to remove, and it is also how the documented format
|
|
738
|
+
// change — remove the old source, author the other beside it — is proved
|
|
739
|
+
// to have actually removed the old one.
|
|
740
|
+
const sourcesByPath = new Map();
|
|
741
|
+
for (const source of sources) {
|
|
742
|
+
const existing = sourcesByPath.get(source.pagePath);
|
|
743
|
+
if (existing === undefined) {
|
|
744
|
+
sourcesByPath.set(source.pagePath, source);
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
if (!targeted || requestedPaths.has(source.pagePath)) {
|
|
748
|
+
throw documentError(
|
|
749
|
+
"pages.path_conflict",
|
|
750
|
+
`'${source.file}' and '${existing.file}' both claim page path '${source.pagePath}'.`,
|
|
751
|
+
source.pagePath,
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
onProgress(
|
|
755
|
+
`'${source.file}' and '${existing.file}' both claim page path '${source.pagePath}'; `
|
|
756
|
+
+ "neither is in this selection.",
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const selected = targeted ? sources.filter((source) => requestedPaths.has(source.pagePath)) : sources;
|
|
761
|
+
if (targeted) {
|
|
762
|
+
// Before deciding what the selection resolved to: a file that might be
|
|
763
|
+
// one of the selected pages makes the selection unprovable, so its own
|
|
764
|
+
// fault is what this push reports.
|
|
765
|
+
const claimant = claimants.find((source) => source.pathClaims.some((claim) => requestedPaths.has(claim)));
|
|
766
|
+
if (claimant !== undefined) throw claimant.fault;
|
|
767
|
+
}
|
|
768
|
+
if (targeted) {
|
|
769
|
+
const matched = new Set(selected.map((source) => source.pagePath));
|
|
770
|
+
const missing = [...requestedPaths].filter((pagePath) => !matched.has(pagePath)).sort();
|
|
771
|
+
if (missing.length > 0) {
|
|
772
|
+
// The homepage normalizes to the empty path, which the result emitters
|
|
773
|
+
// drop as falsy; name it by its documented '/' spelling so the stable
|
|
774
|
+
// error contract keeps a field for every unknown path.
|
|
775
|
+
throw new SiteAuthoringError(
|
|
776
|
+
"pages.page_not_found",
|
|
777
|
+
`No workspace page was found for page path '${missing[0] || "/"}'.`,
|
|
778
|
+
{ field: missing[0] || "/" },
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
onProgress(
|
|
782
|
+
`Selected ${selected.length} of ${sources.length} page source(s) by path; `
|
|
783
|
+
+ "only the selection is validated and sent.",
|
|
784
|
+
);
|
|
785
|
+
} else {
|
|
786
|
+
onProgress(`Validating every one of the ${sources.length} page source(s) in this workspace.`);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Phase one: convert and validate the selection. No mutation is sent until
|
|
790
|
+
// every document that will be sent has passed.
|
|
791
|
+
const planned = [];
|
|
792
|
+
for (const source of selected) {
|
|
793
|
+
const { file, pagePath, title, declaredDescription } = source;
|
|
794
|
+
onProgress(`Validating '${file}'.`);
|
|
795
|
+
const manifestEntry = manifestByFile.get(file);
|
|
796
|
+
requireCompletePageMetadata(source);
|
|
797
|
+
const document_ = await convertWorkspacePageSource({ source, mediaManifest, content });
|
|
798
|
+
if (readOnlyByPath.has(pagePath)) {
|
|
799
|
+
throw documentError(
|
|
800
|
+
"pages.system_page_read_only",
|
|
801
|
+
`'${file}' attempts to replace the read-only system 404 projection. No page was pushed. `
|
|
802
|
+
+ "Run pull again; author the system 404 through an owner-controlled surface.",
|
|
803
|
+
file,
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const target = manifestEntry?.pageId !== undefined
|
|
808
|
+
? liveById.get(manifestEntry.pageId)
|
|
809
|
+
: liveByPath.get(pagePath);
|
|
810
|
+
const workspaceSystemKind = systemPageKind(pagePath);
|
|
811
|
+
if (target === undefined) {
|
|
812
|
+
if (workspaceSystemKind !== undefined) {
|
|
813
|
+
throw documentError(
|
|
814
|
+
"pages.system_page_missing",
|
|
815
|
+
`'${file}' claims the system ${workspaceSystemKind === "home" ? "home" : "404"} page, `
|
|
816
|
+
+ "which is seeded by Taproot and can only be updated. No such page exists on this site.",
|
|
817
|
+
// The homepage's field carries its documented '/' spelling, the
|
|
818
|
+
// same normalization every not-found in this package now uses.
|
|
819
|
+
pagePath || "/",
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
} else {
|
|
823
|
+
const livePath = normalizePagePath(target.path) ?? target.path;
|
|
824
|
+
if (systemPageKind(livePath) !== undefined && livePath !== pagePath) {
|
|
825
|
+
throw documentError(
|
|
826
|
+
"pages.system_path_immutable",
|
|
827
|
+
`'${file}' would move the system ${systemPageKind(livePath) === "home" ? "home" : "404"} page `
|
|
828
|
+
+ `from '${livePath}' to '${pagePath}'. System page paths are immutable.`,
|
|
829
|
+
file,
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
if (target.templateType !== TEMPLATE_TYPE_FREE_FORM) {
|
|
833
|
+
throw documentError(
|
|
834
|
+
"pages.template_immutable",
|
|
835
|
+
`'${file}' targets page '${livePath}', whose template type is ${target.templateType}. `
|
|
836
|
+
+ "A page's template type is immutable after creation.",
|
|
837
|
+
file,
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// The duplicate-source check above only catches two workspace files
|
|
843
|
+
// fighting over one path. This catches the other half: a path some *live*
|
|
844
|
+
// page already holds. Renaming onto it, or creating at it, is a duplicate
|
|
845
|
+
// the server refuses — and without this it refuses mid-phase-two, after
|
|
846
|
+
// earlier pages have been written. A page matching itself is the ordinary
|
|
847
|
+
// update and is left alone.
|
|
848
|
+
const holder = liveByPath.get(pagePath);
|
|
849
|
+
if (holder !== undefined && holder.pageId !== target?.pageId) {
|
|
850
|
+
throw documentError(
|
|
851
|
+
"pages.path_taken",
|
|
852
|
+
`'${file}' claims page path '${pagePath}', which page ${holder.pageId} already holds on this site. `
|
|
853
|
+
+ "Choose another path, or point this file at that page by pulling first.",
|
|
854
|
+
file,
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// The file-keyed entry is gone whenever the file was renamed or replaced —
|
|
859
|
+
// which is exactly what the pull, delete the `.pm.json`, author a `.md`
|
|
860
|
+
// flow does. Falling back through the page's own identity keeps the
|
|
861
|
+
// fields the workspace never restated; without it the whole-object
|
|
862
|
+
// UpdatePage sends "" and false and silently clears both.
|
|
863
|
+
const targetEntry = target === undefined ? undefined : manifestByPageId.get(target.pageId);
|
|
864
|
+
const description = declaredDescription ?? targetEntry?.description ?? "";
|
|
865
|
+
|
|
866
|
+
await validateWorkspacePageDocument({
|
|
867
|
+
workspaceDir: config.workspaceDir,
|
|
868
|
+
siteId,
|
|
869
|
+
file,
|
|
870
|
+
document: document_,
|
|
871
|
+
content,
|
|
872
|
+
allowRawHtml,
|
|
873
|
+
getSharedThemeContexts: async () => {
|
|
874
|
+
sharedThemeContexts ??= await readSharedThemeContexts(config.workspaceDir, siteId);
|
|
875
|
+
return sharedThemeContexts;
|
|
876
|
+
},
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
planned.push({
|
|
880
|
+
file,
|
|
881
|
+
pagePath,
|
|
882
|
+
title,
|
|
883
|
+
description: typeof description === "string" ? description : "",
|
|
884
|
+
document: document_,
|
|
885
|
+
sourceFormat: source.sourceFormat,
|
|
886
|
+
sourceHash: source.sourceHash,
|
|
887
|
+
pageId: target?.pageId,
|
|
888
|
+
action: target === undefined ? "created" : "updated",
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Phase two: send. The manifest is written back even if a later page fails,
|
|
893
|
+
// so a retry updates the pages this run created rather than duplicating them.
|
|
894
|
+
const applied = [];
|
|
895
|
+
let manifestDirty = false;
|
|
896
|
+
try {
|
|
897
|
+
for (const page of planned) {
|
|
898
|
+
onProgress(`${page.action === "created" ? "Creating" : "Updating"} '${page.pagePath}' from '${page.file}'.`);
|
|
899
|
+
const template = freeFormTemplate(page.document);
|
|
900
|
+
const summary = page.action === "created"
|
|
901
|
+
? await createPage(client, {
|
|
902
|
+
siteId,
|
|
903
|
+
path: page.pagePath,
|
|
904
|
+
title: page.title,
|
|
905
|
+
shortDescription: page.description,
|
|
906
|
+
template,
|
|
907
|
+
})
|
|
908
|
+
: await updatePage(client, page.pageId, {
|
|
909
|
+
pageId: page.pageId,
|
|
910
|
+
path: page.pagePath,
|
|
911
|
+
title: page.title,
|
|
912
|
+
shortDescription: page.description,
|
|
913
|
+
template,
|
|
914
|
+
});
|
|
915
|
+
manifestDirty = true;
|
|
916
|
+
applied.push({
|
|
917
|
+
file: page.file,
|
|
918
|
+
path: summary.path,
|
|
919
|
+
pageId: summary.pageId,
|
|
920
|
+
action: page.action,
|
|
921
|
+
status: summary.status,
|
|
922
|
+
});
|
|
923
|
+
// `entry?.` because a hand-edited manifest can hold a null in the list,
|
|
924
|
+
// and a raw TypeError here would collapse to an opaque `site.failed`
|
|
925
|
+
// *after* pages had already been created — the one place in this verb
|
|
926
|
+
// where a crash costs more than a refusal.
|
|
927
|
+
const existing = manifest.pages.find((entry) => entry?.pageId === summary.pageId);
|
|
928
|
+
const record = existing ?? {};
|
|
929
|
+
Object.assign(record, {
|
|
930
|
+
pageId: summary.pageId,
|
|
931
|
+
resourceId: summary.resourceId || record.resourceId || "",
|
|
932
|
+
path: summary.path,
|
|
933
|
+
title: page.title,
|
|
934
|
+
description: page.description,
|
|
935
|
+
status: summary.status,
|
|
936
|
+
templateType: TEMPLATE_TYPE_FREE_FORM,
|
|
937
|
+
hasDraft: summary.hasDraft,
|
|
938
|
+
// The registry: this file, in this format, is now the page's one
|
|
939
|
+
// authoritative source, whatever the entry said before. That is what
|
|
940
|
+
// makes the documented format change — remove the old source, author
|
|
941
|
+
// the other beside it — stick after a single push.
|
|
942
|
+
file: page.file,
|
|
943
|
+
sourceFormat: page.sourceFormat,
|
|
944
|
+
// The page's remote body is now derived from exactly these bytes.
|
|
945
|
+
// Only the local half of the baseline is recorded: a read re-projects
|
|
946
|
+
// image delivery and re-serializes component data, so a hash of what
|
|
947
|
+
// was sent is not comparable with a hash of what a pull would read.
|
|
948
|
+
// The next pull establishes the remote half from what it actually
|
|
949
|
+
// reads, which is the only comparison that holds.
|
|
950
|
+
baseline: { sourceHash: page.sourceHash },
|
|
951
|
+
pendingApproval: true,
|
|
952
|
+
});
|
|
953
|
+
if (existing === undefined) manifest.pages.push(record);
|
|
954
|
+
}
|
|
955
|
+
} finally {
|
|
956
|
+
if (manifestDirty) await writeManifest(config.workspaceDir, manifest);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const reported = boundedList(applied, MAXIMUM_REPORTED);
|
|
960
|
+
const reportedUnresolved = boundedList(unresolved, MAXIMUM_REPORTED);
|
|
961
|
+
return successResult(VERB_PAGES_PUSH, siteId, {
|
|
962
|
+
allowRawHtml,
|
|
963
|
+
pages: {
|
|
964
|
+
total: planned.length,
|
|
965
|
+
created: applied.filter((entry) => entry.action === "created").length,
|
|
966
|
+
updated: applied.filter((entry) => entry.action === "updated").length,
|
|
967
|
+
skippedReadOnly: skippedReadOnly.length,
|
|
968
|
+
readOnlyItems: skippedReadOnly,
|
|
969
|
+
// What this run actually looked at, so automation can tell a targeted
|
|
970
|
+
// push from a whole-workspace one without inferring it from counts.
|
|
971
|
+
selection: targeted ? "targeted" : "workspace",
|
|
972
|
+
...(targeted ? { selectedPaths: [...requestedPaths].sort().map((pagePath) => pagePath || "/") } : {}),
|
|
973
|
+
discovered: editableFiles.length,
|
|
974
|
+
validated: planned.length,
|
|
975
|
+
...(unresolved.length > 0
|
|
976
|
+
? {
|
|
977
|
+
unresolved: unresolved.length,
|
|
978
|
+
unresolvedItems: reportedUnresolved.items,
|
|
979
|
+
...(reportedUnresolved.truncated ? { unresolvedItemsTruncated: true } : {}),
|
|
980
|
+
}
|
|
981
|
+
: {}),
|
|
982
|
+
items: reported.items,
|
|
983
|
+
...(reported.truncated ? { itemsTruncated: true } : {}),
|
|
984
|
+
},
|
|
985
|
+
// `pages push` writes drafts. Nothing reaches an audience until `approve`
|
|
986
|
+
// stages them and `deploy` publishes the site.
|
|
987
|
+
nextStep: "approve",
|
|
988
|
+
});
|
|
989
|
+
});
|
|
990
|
+
}
|