@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,727 @@
1
+ import {
2
+ getNavigation,
3
+ getPage,
4
+ getSettingsGroup,
5
+ listSitePages,
6
+ PAGE_STATUS_DELETED,
7
+ PAGE_STATUS_DRAFT,
8
+ PAGE_STATUS_UNKNOWN,
9
+ TEMPLATE_TYPE_FREE_FORM,
10
+ withRefusalGuidance,
11
+ } from "../api.js";
12
+ import { REFUSAL_UNCLASSIFIED, VERB_PULL } from "../constants.js";
13
+ import { SiteAuthoringError } from "../errors.js";
14
+ import { appearanceManifestEntry, footerManifestEntry } from "../footer-workspace.js";
15
+ import { boundedList, openSession, successResult } from "../session.js";
16
+ import {
17
+ projectSettingsGroup,
18
+ SETTINGS_GROUPS,
19
+ SETTINGS_TYPE_BRAND,
20
+ SETTINGS_TYPE_SITE_HEADER,
21
+ SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES,
22
+ SETTINGS_TYPE_TAPROOT_STYLES,
23
+ } from "../settings-catalog.js";
24
+ import { ApiError } from "../transport.js";
25
+ import {
26
+ canonicalDocumentHash,
27
+ deleteWorkspaceFile,
28
+ ensureWorkspaceRoot,
29
+ inspectArtifactSiteBinding,
30
+ inspectManifestSiteBinding,
31
+ internalPageBaselineFile,
32
+ MANIFEST_FILE_NAME,
33
+ MANIFEST_VERSION,
34
+ MEDIA_MANIFEST_FILE_NAME,
35
+ NAVIGATION_FILE_NAME,
36
+ normalizePagePath,
37
+ PAGE_READ_ONLY_REASON_SYSTEM_404,
38
+ PAGE_SOURCE_EXTENSIONS,
39
+ PAGE_SOURCE_FORMAT_MARKDOWN,
40
+ PAGE_WORKSPACE_MODE_EDITABLE,
41
+ PAGE_WORKSPACE_MODE_METADATA_ONLY,
42
+ PAGE_WORKSPACE_MODE_READ_ONLY,
43
+ pageSourceFormat,
44
+ pageSourceRegistry,
45
+ PAGES_DIRECTORY,
46
+ readWorkspaceFile,
47
+ readWorkspaceJson,
48
+ SETTINGS_DIRECTORY,
49
+ SYSTEM_PAGE_NOT_FOUND_PATH,
50
+ walkWorkspaceFiles,
51
+ workspaceContentHash,
52
+ workspaceFileExists,
53
+ workspaceFileNameForPage,
54
+ WORKSPACE_LIMITS,
55
+ writeManifest,
56
+ writeWorkspaceFile,
57
+ writeWorkspaceJson,
58
+ } from "../workspace.js";
59
+
60
+ /**
61
+ * `pull` — snapshot pages, navigation, and settings into the local workspace.
62
+ *
63
+ * The snapshot is what makes the push verbs safe. Without it, `pages push`
64
+ * cannot tell a new page from an edit of an existing one, and `nav push` cannot
65
+ * report what a whole-tree replace is about to remove. So `pull` writes a
66
+ * manifest recording the `pageId`, `resourceId`, path, and status of every page
67
+ * it saw, alongside the FREE_FORM bodies as `.pm.json` and the readable
68
+ * settings groups as JSON.
69
+ *
70
+ * Everything is written beneath `config.workspaceDir`, which `loadSiteConfig`
71
+ * has already proved resolves inside the configuration directory, and every
72
+ * individual write is re-checked for containment. Server-supplied page paths
73
+ * decide file *names* but never file *locations*: a path that is not plainly
74
+ * safe — or that two pages would map to the same name — falls back to the page
75
+ * id, and the manifest, not the file name, remains the authority on which page
76
+ * a file is.
77
+ *
78
+ * **One source per page.** A page whose manifest entry already names a source
79
+ * file that is still on disk keeps it. Pull refreshes remote state around that
80
+ * file; it never mints a second editable one beside it. Writing
81
+ * `pages/about.pm.json` next to an authored `pages/about.md` is what made the
82
+ * safest agent loop — pull, edit, push one path — self-conflicting: the next
83
+ * push saw two sources for one path and refused, every single pull.
84
+ *
85
+ * Markdown is deliberately one-way, so for a Markdown-tracked page the remote
86
+ * body has nowhere in `pages/` to go. It is kept as internal state instead
87
+ * (see `internalPageBaselineFile`), together with the hashes that let the next
88
+ * pull tell an unchanged remote from one that moved underneath the author. A
89
+ * remote body that changed cannot be expressed as Markdown at all, so pull
90
+ * reports it as a conflict rather than pretending the workspace is current —
91
+ * and reports it *before* writing anything under `pages/`, so a refused pull
92
+ * leaves the workspace exactly as it found it.
93
+ */
94
+
95
+ const MAXIMUM_REPORTED = 200;
96
+ // A settings group the credential's envelope does not reach is a narrower
97
+ // snapshot, not a failed pull. Anything the transport classified as a refusal
98
+ // is a different matter and always propagates.
99
+ const UNAVAILABLE_SETTINGS_STATUSES = new Set([403, 404]);
100
+ const APPEARANCE_SETTINGS_TYPES = Object.freeze([
101
+ SETTINGS_TYPE_TAPROOT_STYLES,
102
+ SETTINGS_TYPE_BRAND,
103
+ SETTINGS_TYPE_SITE_HEADER,
104
+ ]);
105
+
106
+ function isSystemNotFound(pagePath) {
107
+ return normalizePagePath(pagePath)?.toLowerCase() === SYSTEM_PAGE_NOT_FOUND_PATH;
108
+ }
109
+
110
+ /**
111
+ * The refusal for a remote body this workspace cannot represent.
112
+ *
113
+ * It names both revisions and both ways out, because the alternative — pulling
114
+ * anyway — is a workspace that quietly claims to be current and a next push
115
+ * that reverts whatever the site gained. Every conflicted page is listed in
116
+ * `alternatives` so an agent does not have to re-run the pull once per page.
117
+ */
118
+ function pullConflict(conflicts) {
119
+ const first = conflicts[0];
120
+ const selector = first.pagePath || "/";
121
+ const remaining = conflicts.length - 1;
122
+ return new SiteAuthoringError(
123
+ "pages.pull_conflict",
124
+ `Page '${selector}' changed on the site${first.localChanged ? " and in this workspace" : ""} since the last `
125
+ + `pull, and '${first.file}' cannot be rewritten from the site's document. Nothing under '${PAGES_DIRECTORY}/' `
126
+ + `was changed, and the site's version is preserved at '${first.baselineFile}'. Either run `
127
+ + `'taproot-site pages push ${selector}' to make the site match '${first.file}', or delete '${first.file}' `
128
+ + `and pull again to adopt the site's version as this page's source.`
129
+ + (remaining > 0 ? ` ${remaining} other tracked page(s) are in the same state.` : ""),
130
+ { field: first.file, alternatives: conflicts.map((entry) => entry.file) },
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Whether a workspace file still holds exactly the site's document.
136
+ *
137
+ * Compared the way `remoteHash` is computed — canonically — and deliberately
138
+ * not byte for byte. `FreeFormData.body` is a `google.protobuf.Struct`, so two
139
+ * reads of an unchanged page may serialize their members in different orders;
140
+ * a byte comparison would read that as an authored edit, and the *next* remote
141
+ * change would then raise a conflict naming a local edit that never happened,
142
+ * whose offered remedy is to push the untouched file over the real one.
143
+ *
144
+ * A file that cannot be parsed is certainly not what pull wrote, so it counts
145
+ * as authored and is kept rather than refreshed over.
146
+ */
147
+ function holdsRemoteDocument(sourceBytes, remoteHash) {
148
+ try {
149
+ return canonicalDocumentHash(JSON.parse(sourceBytes.toString("utf8"))) === remoteHash;
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ function bodyStatusFor(summary) {
156
+ if (summary.hasDraft) return PAGE_STATUS_DRAFT;
157
+ if (summary.status !== PAGE_STATUS_UNKNOWN) return summary.status;
158
+ throw new SiteAuthoringError(
159
+ "api.page_status",
160
+ `Taproot returned page '${summary.pageId}' without a readable status, so its body cannot be requested.`,
161
+ { field: summary.pageId },
162
+ );
163
+ }
164
+
165
+ /**
166
+ * Assigns one workspace file to every free-form page, before a single byte is
167
+ * written.
168
+ *
169
+ * A page that already has a tracked source keeps it, whatever format it is in.
170
+ * That is the whole of the one-source rule as pull sees it, and it is settled
171
+ * here rather than at write time so the names a pull is about to use are known
172
+ * before any of them is claimed.
173
+ *
174
+ * For the rest, two different pages can prefer the same file name — the home
175
+ * page (path `""`) and a page whose path is literally `index` both want
176
+ * `pages/index.pm.json` — and a later write would silently clobber the earlier
177
+ * one, leaving one page's body on disk under the other's name and only one of
178
+ * the two reachable from `pages push`. So names are settled up front against a
179
+ * claimed set, with each page's own `pages/<pageId>.pm.json` reserved as a
180
+ * fallback nothing else may take. That guarantees a unique file per page,
181
+ * because page ids are unique.
182
+ *
183
+ * The order is deliberate rather than the server's: the seeded home page claims
184
+ * `index` first — its path is immutable, so it is the one name that can never
185
+ * move — and everything else settles in page-id order. The same site therefore
186
+ * produces the same file names on every pull, whatever order the listing
187
+ * arrives in. The manifest remains the authority on which page a file is.
188
+ */
189
+ function assignPageFiles(pages, tracked) {
190
+ const reserved = new Set(pages.map((summary) => `${PAGES_DIRECTORY}/${summary.pageId}.pm.json`));
191
+ const claimed = new Set();
192
+ const assigned = new Map();
193
+ for (const summary of pages) {
194
+ const source = tracked.get(summary.pageId);
195
+ if (source === undefined) continue;
196
+ claimed.add(source.file);
197
+ assigned.set(summary.pageId, source.file);
198
+ }
199
+ const homeFirst = [...pages].sort((left, right) => {
200
+ const leftRank = normalizePagePath(left.path) === "" ? 0 : 1;
201
+ const rightRank = normalizePagePath(right.path) === "" ? 0 : 1;
202
+ if (leftRank !== rightRank) return leftRank - rightRank;
203
+ if (left.pageId === right.pageId) return 0;
204
+ return left.pageId < right.pageId ? -1 : 1;
205
+ });
206
+ for (const summary of homeFirst) {
207
+ if (assigned.has(summary.pageId)) continue;
208
+ const fallback = `${PAGES_DIRECTORY}/${summary.pageId}.pm.json`;
209
+ const preferred = `${PAGES_DIRECTORY}/${workspaceFileNameForPage(summary.path, summary.pageId)}.pm.json`;
210
+ const available = preferred === fallback || (!claimed.has(preferred) && !reserved.has(preferred));
211
+ const file = available ? preferred : fallback;
212
+ // The page-id fallback is unique by construction, so the only way it is
213
+ // already taken is a tracked source that was renamed onto another page's
214
+ // reserved name by hand. Guessing which page owns it is exactly the
215
+ // ambiguity this task exists to remove.
216
+ if (claimed.has(file)) {
217
+ throw new SiteAuthoringError(
218
+ "pages.source_conflict",
219
+ `Page ${summary.pageId} has no free workspace file: '${file}' is already the tracked source of another `
220
+ + "page. Rename or remove that file, then run 'taproot-site pull' again.",
221
+ { field: file },
222
+ );
223
+ }
224
+ claimed.add(file);
225
+ assigned.set(summary.pageId, file);
226
+ }
227
+ return assigned;
228
+ }
229
+
230
+ /**
231
+ * The manifest entries for pages whose tracked source is still on disk.
232
+ *
233
+ * A tracked file that is gone is not an error: removing it and authoring the
234
+ * other format beside it is the documented way to change a page's source
235
+ * format, and this is the half of that transition pull sees. The page falls
236
+ * back to an ordinary pulled `.pm.json`, and `pages push` re-registers
237
+ * whichever source actually claims the path.
238
+ */
239
+ async function resolveTrackedSources(workspaceDir, pages, registry, onProgress) {
240
+ const tracked = new Map();
241
+ for (const summary of pages) {
242
+ // The system 404 is never authored locally: pull owns its bytes and
243
+ // records their hash, and every other verb refuses to send them back.
244
+ // Tracking it would offer to keep local edits to a file whose whole
245
+ // contract is that local edits are a refusal.
246
+ if (isSystemNotFound(summary.path)) continue;
247
+ const entry = registry.get(summary.pageId);
248
+ if (entry === undefined) continue;
249
+ // One file cannot be two pages' source. Keeping either entry would hash
250
+ // and refresh one file against two unrelated baselines and let a later
251
+ // push send it to whichever page the manifest lookup happened to resolve;
252
+ // ignoring both sends each page back through the collision-safe naming
253
+ // below, which is what rewrites the ambiguity out of the manifest.
254
+ if (entry.duplicateSource || entry.duplicateIdentity) {
255
+ // Only the manifest is rewritten here. These files are authored content,
256
+ // so pull will not delete them — and one still carrying front matter for
257
+ // an affected path becomes a second source for it the moment the
258
+ // reassigned documents land beside it. The two ambiguities are opposite
259
+ // relationships, so they get their own wording: naming a page's extra
260
+ // sources as "the source of more than one page" would send the author
261
+ // looking for the wrong thing.
262
+ onProgress(
263
+ entry.duplicateIdentity
264
+ ? `Page ${summary.pageId} is recorded with more than one source (${entry.sourceFiles.join(", ")}); this `
265
+ + "pull gives it a single fresh file instead. None of those files is tracked any more: delete the ones "
266
+ + "you do not want, or the next push refuses with pages.path_conflict."
267
+ : `'${entry.file}' is recorded as the source of more than one page; this pull assigns each of them its own `
268
+ + `file instead. '${entry.file}' is no longer tracked: delete it, or the next push refuses with `
269
+ + "pages.path_conflict.",
270
+ );
271
+ continue;
272
+ }
273
+ if (!await workspaceFileExists(workspaceDir, entry.file)) {
274
+ onProgress(`Tracked source '${entry.file}' is gone; page '${summary.path}' will be pulled as a document.`);
275
+ continue;
276
+ }
277
+ if (entry.baselineDiscarded) {
278
+ onProgress(
279
+ `The recorded baseline for '${entry.file}' is unreadable; this pull re-establishes it from the site.`,
280
+ );
281
+ }
282
+ tracked.set(summary.pageId, entry);
283
+ }
284
+ return tracked;
285
+ }
286
+
287
+ /**
288
+ * Reads the previous manifest's source registry, tolerating everything a
289
+ * strict read would refuse.
290
+ *
291
+ * `pull` is the verb that repairs a workspace, so refusing here would strand
292
+ * one. It is also the only verb that reads a manifest it is about to replace:
293
+ * a version-4 manifest still records the `pageId → file` mapping this needs,
294
+ * and honoring it is what repairs an existing workspace in one pull instead of
295
+ * by hand. Every other verb keeps refusing an old manifest outright — the site
296
+ * binding is checked before this runs, so a foreign manifest never reaches it.
297
+ */
298
+ async function readSourceRegistry(workspaceDir, siteId) {
299
+ if (!await workspaceFileExists(workspaceDir, MANIFEST_FILE_NAME)) return new Map();
300
+ let parsed;
301
+ try {
302
+ parsed = await readWorkspaceJson(workspaceDir, MANIFEST_FILE_NAME, WORKSPACE_LIMITS.manifestBytes);
303
+ } catch {
304
+ return new Map();
305
+ }
306
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed) || parsed.siteId !== siteId) {
307
+ return new Map();
308
+ }
309
+ return pageSourceRegistry(parsed);
310
+ }
311
+
312
+ function freeFormBody(page) {
313
+ const template = page.template;
314
+ if (template === null || typeof template !== "object") return undefined;
315
+ const data = template.freeFormData;
316
+ if (data === null || typeof data !== "object") return undefined;
317
+ const body = data.body;
318
+ return body !== null && typeof body === "object" && !Array.isArray(body) ? body : undefined;
319
+ }
320
+
321
+ function notThisSite(message, field) {
322
+ // One code for one situation: "this workspace is not provably this site's."
323
+ // The remedy is the same for every branch below — a fresh directory — and an
324
+ // agent branching on the code should not have to match four identities for
325
+ // one decision. The message carries which branch it was.
326
+ return new SiteAuthoringError("workspace.manifest_site_mismatch", message, { field });
327
+ }
328
+
329
+ /**
330
+ * Refuses to pull into a workspace that already holds another site's work, or
331
+ * whose ownership cannot be established at all.
332
+ *
333
+ * The hazard is one specific transplant. `pull` rewrites the manifest but not
334
+ * the page sources beside it, so a workspace carrying site A's pages that
335
+ * accepts a pull for site B ends up with a B-bound manifest that agrees with
336
+ * itself, A's `.pm.json` files sitting untracked next to it, and a `pages push`
337
+ * that creates A's bodies as brand-new pages on B. Every branch here is that
338
+ * same failure reached by a different route:
339
+ *
340
+ * - A manifest naming another site says so outright.
341
+ * - A manifest naming no site predates site binding and cannot be cleared.
342
+ * - A manifest too damaged to parse cannot be cleared either. It may predate
343
+ * the atomic workspace writer, or have been damaged by an external tool or
344
+ * manual edit while the page sources survived intact.
345
+ * - No manifest at all is the same accident one step earlier. `pull` writes
346
+ * page bodies first and the manifest last, so an interrupted first pull
347
+ * leaves a full `pages/` directory and nothing that says whose it is.
348
+ *
349
+ * What stays permitted is the workspace that is genuinely new, and the one
350
+ * `media upload` legitimately created before any pull — a media manifest bound
351
+ * to *this* site, with no page sources, is a workspace this site already owns.
352
+ */
353
+ async function requireWorkspaceBelongsToSite(workspaceDir, siteId) {
354
+ const binding = await inspectManifestSiteBinding(workspaceDir);
355
+ if (binding.state === "bound") {
356
+ if (binding.siteId === siteId) return;
357
+ throw notThisSite(
358
+ `This workspace was pulled from site ${binding.siteId.slice(0, 64)} and still holds its files, but this `
359
+ + `configuration targets site ${siteId}. Pull into a workspace of its own for this site.`,
360
+ MANIFEST_FILE_NAME,
361
+ );
362
+ }
363
+ if (binding.state === "unbound") {
364
+ throw notThisSite(
365
+ `'${MANIFEST_FILE_NAME}' predates site binding: it records no siteId, so the site whose files this `
366
+ + "workspace already holds cannot be established. Pull into a fresh directory instead.",
367
+ MANIFEST_FILE_NAME,
368
+ );
369
+ }
370
+ if (binding.state === "unreadable") {
371
+ throw notThisSite(
372
+ `'${MANIFEST_FILE_NAME}' cannot be read, so the site whose files this workspace already holds cannot be `
373
+ + "established — an interrupted write leaves exactly this, with the page sources intact. Pull into a "
374
+ + "fresh directory instead.",
375
+ MANIFEST_FILE_NAME,
376
+ );
377
+ }
378
+
379
+ // No manifest. Permitted only if nothing here belongs to a site already.
380
+ const pageSources = await walkWorkspaceFiles(workspaceDir, PAGES_DIRECTORY, PAGE_SOURCE_EXTENSIONS);
381
+ if (pageSources.length > 0) {
382
+ throw notThisSite(
383
+ `This workspace holds ${pageSources.length} page source(s) under '${PAGES_DIRECTORY}/' but no `
384
+ + `'${MANIFEST_FILE_NAME}', so the site they came from cannot be established — an interrupted pull `
385
+ + "leaves exactly this. Pull into a fresh directory instead.",
386
+ PAGES_DIRECTORY,
387
+ );
388
+ }
389
+ // These two do carry a site, so they can clear themselves.
390
+ for (const artifact of [NAVIGATION_FILE_NAME, MEDIA_MANIFEST_FILE_NAME]) {
391
+ const artifactBinding = await inspectArtifactSiteBinding(workspaceDir, artifact);
392
+ if (artifactBinding.state === "absent") continue;
393
+ if (artifactBinding.state === "bound" && artifactBinding.siteId === siteId) continue;
394
+ throw notThisSite(
395
+ `This workspace has no '${MANIFEST_FILE_NAME}', and '${artifact}' ${
396
+ artifactBinding.state === "bound"
397
+ ? `belongs to site ${artifactBinding.siteId.slice(0, 64)}`
398
+ : "does not record a readable site"
399
+ }, so this workspace is not provably site ${siteId}'s. Pull into a fresh directory instead.`,
400
+ artifact,
401
+ );
402
+ }
403
+ }
404
+
405
+ export async function pull(invocation) {
406
+ const { client, config, siteId, now, onProgress } = await openSession(invocation);
407
+ await ensureWorkspaceRoot(config);
408
+
409
+ await requireWorkspaceBelongsToSite(config.workspaceDir, siteId);
410
+
411
+ const registry = await readSourceRegistry(config.workspaceDir, siteId);
412
+
413
+ return await withRefusalGuidance(onProgress, "pull", async () => {
414
+ onProgress("Listing the site's pages.");
415
+ const { pages, truncated } = await listSitePages(client, siteId, { onProgress });
416
+ const live = pages.filter((summary) => summary.status !== PAGE_STATUS_DELETED);
417
+ const freeForm = live.filter((summary) => summary.templateType === TEMPLATE_TYPE_FREE_FORM);
418
+ const tracked = await resolveTrackedSources(config.workspaceDir, freeForm, registry, onProgress);
419
+ const pageFiles = assignPageFiles(freeForm, tracked);
420
+
421
+ // Project every settings response before the first workspace write. Theme
422
+ // decoding is a wire-contract check and can fail on malformed stored data;
423
+ // discovering that after page bodies were written would strand an
424
+ // unbound workspace that the next pull must refuse for safety.
425
+ const pulledSettings = [];
426
+ const skippedSettings = [];
427
+ const settingsDocuments = [];
428
+ let pulledFooter;
429
+ const pulledAppearance = {};
430
+ for (const group of SETTINGS_GROUPS) {
431
+ onProgress(`Reading settings group ${group.settingsType}.`);
432
+ let response;
433
+ try {
434
+ response = await getSettingsGroup(client, siteId, group.settingsType);
435
+ } catch (error) {
436
+ if (
437
+ error instanceof ApiError
438
+ && error.refusalKind() === REFUSAL_UNCLASSIFIED
439
+ && UNAVAILABLE_SETTINGS_STATUSES.has(error.httpStatus)
440
+ ) {
441
+ onProgress(`Settings group ${group.settingsType} is not readable by this credential; skipping it.`);
442
+ skippedSettings.push(group.settingsType);
443
+ continue;
444
+ }
445
+ throw error;
446
+ }
447
+ const file = `${SETTINGS_DIRECTORY}/${group.file}`;
448
+ const projected = projectSettingsGroup(group, response);
449
+ settingsDocuments.push({
450
+ file,
451
+ value: {
452
+ settingsType: group.settingsType,
453
+ entityId: siteId,
454
+ settings: projected,
455
+ },
456
+ });
457
+ if (group.settingsType === SETTINGS_TYPE_SITE_PUBLISHING_PREFERENCES) {
458
+ pulledFooter = projected.footerSettings;
459
+ }
460
+ if (APPEARANCE_SETTINGS_TYPES.includes(group.settingsType)) {
461
+ pulledAppearance[group.settingsType] = projected;
462
+ }
463
+ pulledSettings.push({ settingsType: group.settingsType, file });
464
+ }
465
+
466
+ // Phase one: the pages whose source this workspace already tracks. They are
467
+ // the only ones a remote edit can collide with, and the collision has to be
468
+ // reported before the ordinary pulled documents below are written — a
469
+ // refused pull must leave `pages/` exactly as it found it.
470
+ const trackedPlans = new Map();
471
+ const conflicts = [];
472
+ let bufferedBytes = 0;
473
+ for (const summary of freeForm) {
474
+ const source = tracked.get(summary.pageId);
475
+ if (source === undefined) continue;
476
+ onProgress(`Reading free-form page '${summary.path}'.`);
477
+ const page = await getPage(client, summary.pageId, bodyStatusFor(summary));
478
+ const body = freeFormBody(page);
479
+ const description = typeof page.shortDescription === "string" ? page.shortDescription : "";
480
+ if (body === undefined) {
481
+ // The site's body is unreadable, but the workspace file is still this
482
+ // page's one source. Dropping it from the registry would let the next
483
+ // pull mint the competing document all over again.
484
+ onProgress(`Page '${summary.path}' has no readable free-form body; keeping '${source.file}' as its source.`);
485
+ trackedPlans.set(summary.pageId, { description, file: source.file, sourceFormat: source.sourceFormat });
486
+ continue;
487
+ }
488
+ const baselineFile = internalPageBaselineFile(summary.pageId);
489
+ if (baselineFile === undefined) {
490
+ // `requireIdentifier` already narrows a page id to `[\w-]+`, so this
491
+ // needs an id starting with `_` or `-`. Refusing beats tracking a page
492
+ // whose remote body has nowhere to be preserved.
493
+ throw new SiteAuthoringError(
494
+ "workspace.page_id_unsupported",
495
+ `Taproot reported a page id for '${source.file}' that cannot name a workspace file, so this workspace `
496
+ + "cannot record what it last reconciled with.",
497
+ { field: source.file },
498
+ );
499
+ }
500
+ const serialized = Buffer.from(`${JSON.stringify(body, undefined, 2)}\n`, "utf8");
501
+ bufferedBytes += serialized.byteLength;
502
+ if (bufferedBytes > WORKSPACE_LIMITS.pulledBodyBytes) {
503
+ throw new SiteAuthoringError(
504
+ "pages.pulled_bodies_too_large",
505
+ `The tracked pages of this site exceed ${WORKSPACE_LIMITS.pulledBodyBytes} bytes, which is more than one `
506
+ + "pull can hold before deciding whether any of them conflict.",
507
+ { field: PAGES_DIRECTORY },
508
+ );
509
+ }
510
+ const remoteHash = canonicalDocumentHash(body);
511
+ const sourceBytes = await readWorkspaceFile(config.workspaceDir, source.file, WORKSPACE_LIMITS.documentBytes);
512
+ const sourceHash = workspaceContentHash(sourceBytes);
513
+ const remoteChanged = source.baseline?.remoteHash !== undefined && source.baseline.remoteHash !== remoteHash;
514
+ // Markdown is one-way, so a Markdown source can never be rewritten from
515
+ // the site's document and is always kept. A ProseMirror source can be
516
+ // refreshed — but only over content the author has not edited since the
517
+ // last pull, because that edit is work no push has sent yet.
518
+ const markdown = source.sourceFormat === PAGE_SOURCE_FORMAT_MARKDOWN;
519
+ // With no recorded hash to compare against, "unchanged" is not a safe
520
+ // default for a ProseMirror source: it schedules a refresh straight over
521
+ // whatever the file actually holds, and three states reach here — a
522
+ // version-4 manifest, a baseline too damaged to read, and an entry kept
523
+ // while the site's body was unreadable — in all of which an authored
524
+ // edit would be invisible. The bytes settle it instead, because a pulled
525
+ // ProseMirror source is exactly what pull wrote: one that still matches
526
+ // the site's document was not edited, and one that does not must be
527
+ // assumed to hold work, keeping its hash unknown so every later pull
528
+ // decides the same way rather than drifting to "unchanged".
529
+ //
530
+ // A Markdown source has no such relationship to the site's document —
531
+ // the formats differ, so the bytes never match — and pull never writes
532
+ // one at all. There is nothing to protect and nothing to compare, so an
533
+ // unrecorded hash simply establishes itself.
534
+ const localKnown = source.baseline?.sourceHash !== undefined;
535
+ const localChanged = localKnown
536
+ ? source.baseline.sourceHash !== sourceHash
537
+ : !markdown && !holdsRemoteDocument(sourceBytes, remoteHash);
538
+ const keepLocal = markdown || localChanged;
539
+ const conflicted = markdown ? remoteChanged : localChanged && remoteChanged;
540
+ trackedPlans.set(summary.pageId, {
541
+ description,
542
+ file: source.file,
543
+ sourceFormat: source.sourceFormat,
544
+ // `sourceHash` records the source as of the last time it *agreed* with
545
+ // the remote body recorded beside it — not merely what it looked like
546
+ // on the most recent pull. The difference is the whole protection:
547
+ // advancing it for an edit no push has sent would make the next pull
548
+ // read that edit as already reconciled, find nothing diverged, and
549
+ // refresh the file straight over it. So a kept-because-diverged source
550
+ // carries its previous hash forward until a push records a new one or
551
+ // a remote change turns the divergence into a conflict.
552
+ baseline: keepLocal
553
+ ? { remoteHash, sourceHash: localChanged ? source.baseline?.sourceHash : sourceHash }
554
+ : { remoteHash, sourceHash: workspaceContentHash(serialized) },
555
+ ...(keepLocal ? { baselineBody: serialized, baselineFile } : { refresh: serialized, baselineFile }),
556
+ });
557
+ if (conflicted) {
558
+ conflicts.push({
559
+ pagePath: normalizePagePath(summary.path) ?? summary.path,
560
+ file: source.file,
561
+ baselineFile,
562
+ localChanged,
563
+ });
564
+ } else if (keepLocal && localChanged) {
565
+ // Two different situations, and saying "kept your edits" for both
566
+ // would be a guess dressed as a fact: with a recorded hash the
567
+ // divergence is measured, without one it is only that the file and the
568
+ // site's document disagree — which an edit and a since-moved remote
569
+ // produce alike.
570
+ onProgress(
571
+ localKnown
572
+ ? `Kept the local edits in '${source.file}'; the site's copy of this page has not moved.`
573
+ : `'${source.file}' does not match the site's document, and this workspace has no record of what it last `
574
+ + "reconciled with, so it is kept as authored. Push it, or delete it and pull again, to settle which "
575
+ + "one wins.",
576
+ );
577
+ }
578
+ }
579
+
580
+ // Internal state, written before the refusal below on purpose: it is what
581
+ // makes "the site's version is preserved at ..." true. Nothing here is a
582
+ // page source, and rewriting it is idempotent.
583
+ for (const plan of trackedPlans.values()) {
584
+ if (plan.baselineBody === undefined) continue;
585
+ await writeWorkspaceFile(config.workspaceDir, plan.baselineFile, plan.baselineBody);
586
+ }
587
+ if (conflicts.length > 0) throw pullConflict(conflicts);
588
+
589
+ // Phase two: everything else, plus the tracked refreshes cleared above.
590
+ const manifestPages = [];
591
+ let bodies = 0;
592
+ for (const summary of live) {
593
+ const entry = {
594
+ pageId: summary.pageId,
595
+ resourceId: summary.resourceId,
596
+ path: summary.path,
597
+ title: summary.title,
598
+ status: summary.status,
599
+ templateType: summary.templateType,
600
+ hasDraft: summary.hasDraft,
601
+ isGenerated: summary.isGenerated,
602
+ workspaceMode: PAGE_WORKSPACE_MODE_METADATA_ONLY,
603
+ };
604
+ if (summary.templateType === TEMPLATE_TYPE_FREE_FORM) {
605
+ const plan = trackedPlans.get(summary.pageId);
606
+ if (plan !== undefined) {
607
+ entry.description = plan.description;
608
+ if (plan.file !== undefined) {
609
+ entry.file = plan.file;
610
+ entry.sourceFormat = plan.sourceFormat;
611
+ entry.workspaceMode = PAGE_WORKSPACE_MODE_EDITABLE;
612
+ // Absent when the site had no readable body to compare against;
613
+ // the next pull that reads one establishes it.
614
+ if (plan.baseline !== undefined) entry.baseline = plan.baseline;
615
+ if (plan.refresh !== undefined) {
616
+ await writeWorkspaceFile(config.workspaceDir, entry.file, plan.refresh);
617
+ bodies += 1;
618
+ // The page's own source now holds the site's document, so a
619
+ // second copy of it under internal state is stale by
620
+ // construction.
621
+ await deleteWorkspaceFile(config.workspaceDir, plan.baselineFile);
622
+ }
623
+ }
624
+ manifestPages.push(entry);
625
+ continue;
626
+ }
627
+ onProgress(`Reading free-form page '${summary.path}'.`);
628
+ const page = await getPage(client, summary.pageId, bodyStatusFor(summary));
629
+ const body = freeFormBody(page);
630
+ entry.description = typeof page.shortDescription === "string" ? page.shortDescription : "";
631
+ if (body === undefined) {
632
+ // The server accepts and stores a body it never validates, so an
633
+ // unreadable one is a real state. It is reported rather than written
634
+ // as an empty document that a later push would send back.
635
+ onProgress(`Page '${summary.path}' has no readable free-form body; snapshotting metadata only.`);
636
+ } else {
637
+ entry.file = pageFiles.get(summary.pageId);
638
+ const source = Buffer.from(`${JSON.stringify(body, undefined, 2)}\n`, "utf8");
639
+ if (isSystemNotFound(summary.path)) {
640
+ entry.workspaceMode = PAGE_WORKSPACE_MODE_READ_ONLY;
641
+ entry.readOnlyReason = PAGE_READ_ONLY_REASON_SYSTEM_404;
642
+ entry.workspaceContentHash = workspaceContentHash(source);
643
+ onProgress(
644
+ `Page '${summary.path}' is the system 404; writing an integrity-checked read-only projection.`,
645
+ );
646
+ } else {
647
+ entry.workspaceMode = PAGE_WORKSPACE_MODE_EDITABLE;
648
+ entry.sourceFormat = pageSourceFormat(entry.file);
649
+ entry.baseline = {
650
+ remoteHash: canonicalDocumentHash(body),
651
+ sourceHash: workspaceContentHash(source),
652
+ };
653
+ }
654
+ await writeWorkspaceFile(config.workspaceDir, entry.file, source);
655
+ bodies += 1;
656
+ const baselineFile = internalPageBaselineFile(summary.pageId);
657
+ if (baselineFile !== undefined) await deleteWorkspaceFile(config.workspaceDir, baselineFile);
658
+ }
659
+ }
660
+ manifestPages.push(entry);
661
+ }
662
+
663
+ onProgress("Reading the navigation tree.");
664
+ const navItems = await getNavigation(client, siteId);
665
+ await writeWorkspaceJson(config.workspaceDir, NAVIGATION_FILE_NAME, { siteId, navItems });
666
+
667
+ for (const document of settingsDocuments) {
668
+ await writeWorkspaceJson(config.workspaceDir, document.file, document.value);
669
+ }
670
+
671
+ const manifest = {
672
+ manifestVersion: MANIFEST_VERSION,
673
+ siteId,
674
+ pulledAt: new Date(now()).toISOString(),
675
+ pagesTruncated: truncated,
676
+ navigation: { file: NAVIGATION_FILE_NAME, items: navItems.length },
677
+ settings: pulledSettings,
678
+ settingsSkipped: skippedSettings,
679
+ ...(pulledFooter === undefined ? {} : { footer: footerManifestEntry(pulledFooter) }),
680
+ ...(
681
+ APPEARANCE_SETTINGS_TYPES.every((settingsType) => pulledAppearance[settingsType] !== undefined)
682
+ ? { appearance: appearanceManifestEntry(pulledAppearance) }
683
+ : {}
684
+ ),
685
+ pages: manifestPages,
686
+ };
687
+ await writeManifest(config.workspaceDir, manifest);
688
+ onProgress(`Wrote ${MANIFEST_FILE_NAME} describing ${manifestPages.length} pages.`);
689
+ if (truncated) {
690
+ onProgress("The page list was bounded before the site was fully enumerated; the manifest is partial.");
691
+ }
692
+
693
+ const reported = boundedList(
694
+ manifestPages.map((entry) => ({
695
+ pageId: entry.pageId,
696
+ path: entry.path,
697
+ status: entry.status,
698
+ templateType: entry.templateType,
699
+ file: entry.file,
700
+ sourceFormat: entry.sourceFormat,
701
+ workspaceMode: entry.workspaceMode,
702
+ })),
703
+ MAXIMUM_REPORTED,
704
+ );
705
+ return successResult(VERB_PULL, siteId, {
706
+ manifestFile: MANIFEST_FILE_NAME,
707
+ pages: {
708
+ total: manifestPages.length,
709
+ bodies,
710
+ // How many pages kept a source this workspace already had, rather than
711
+ // receiving a freshly written one. A caller that expected an authored
712
+ // Markdown page to survive the pull can assert on it.
713
+ tracked: tracked.size,
714
+ readOnly: manifestPages.filter((entry) => entry.workspaceMode === PAGE_WORKSPACE_MODE_READ_ONLY).length,
715
+ truncated,
716
+ items: reported.items,
717
+ ...(reported.truncated ? { itemsTruncated: true } : {}),
718
+ },
719
+ navigation: { file: NAVIGATION_FILE_NAME, items: navItems.length },
720
+ settings: {
721
+ pulled: pulledSettings.map((entry) => entry.settingsType),
722
+ skipped: skippedSettings,
723
+ files: pulledSettings.map((entry) => entry.file),
724
+ },
725
+ });
726
+ });
727
+ }