@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,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
|
+
}
|