@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.
Files changed (57) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +239 -0
  3. package/bin/taproot-site.js +5 -0
  4. package/package.json +40 -0
  5. package/src/api.js +1507 -0
  6. package/src/appearance-contract.js +311 -0
  7. package/src/atomic-file.js +136 -0
  8. package/src/bounded-file.js +59 -0
  9. package/src/capabilities.js +29 -0
  10. package/src/cli.js +872 -0
  11. package/src/config.js +412 -0
  12. package/src/constants.js +173 -0
  13. package/src/content/components.js +870 -0
  14. package/src/content/free-form-section-registry.json +524 -0
  15. package/src/content/free-form-sections.js +582 -0
  16. package/src/content/index.js +29 -0
  17. package/src/content/inline-facts.js +97 -0
  18. package/src/content/markdown.js +1625 -0
  19. package/src/content/tiptap-prosemirror.ts +1266 -0
  20. package/src/content/validate-document.js +1079 -0
  21. package/src/content/vocabulary.js +397 -0
  22. package/src/credentials.js +544 -0
  23. package/src/errors.js +133 -0
  24. package/src/footer-contract.js +687 -0
  25. package/src/footer-draft-hash.js +203 -0
  26. package/src/footer-workspace.js +136 -0
  27. package/src/image-metadata.js +169 -0
  28. package/src/index.js +16 -0
  29. package/src/json-guard.js +111 -0
  30. package/src/output.js +157 -0
  31. package/src/presentation-reference.js +600 -0
  32. package/src/reference-help.js +927 -0
  33. package/src/session.js +316 -0
  34. package/src/settings-catalog.js +187 -0
  35. package/src/settings.js +157 -0
  36. package/src/theme-validation.js +344 -0
  37. package/src/transport.js +777 -0
  38. package/src/verbs/approve.js +178 -0
  39. package/src/verbs/deploy.js +327 -0
  40. package/src/verbs/env.js +89 -0
  41. package/src/verbs/footer-push.js +115 -0
  42. package/src/verbs/index.js +125 -0
  43. package/src/verbs/login.js +529 -0
  44. package/src/verbs/logout.js +39 -0
  45. package/src/verbs/media-upload.js +319 -0
  46. package/src/verbs/nav-push.js +333 -0
  47. package/src/verbs/pages-push.js +990 -0
  48. package/src/verbs/preview-page.js +107 -0
  49. package/src/verbs/preview-revoke.js +56 -0
  50. package/src/verbs/pull.js +727 -0
  51. package/src/verbs/sites.js +57 -0
  52. package/src/verbs/status.js +94 -0
  53. package/src/verbs/theme-push.js +197 -0
  54. package/src/verbs/use.js +129 -0
  55. package/src/verbs/validate.js +477 -0
  56. package/src/verbs/whoami.js +108 -0
  57. 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
+ }