@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,319 @@
|
|
|
1
|
+
import {
|
|
2
|
+
confirmImageUpload,
|
|
3
|
+
IMAGE_OWNERSHIP_SCOPE_SITE,
|
|
4
|
+
IMAGE_PROCESSING_STATE_COMPLETE,
|
|
5
|
+
IMAGE_PROCESSING_STATE_FAILED,
|
|
6
|
+
listSiteImages,
|
|
7
|
+
normalizeImage,
|
|
8
|
+
poll,
|
|
9
|
+
requestImageUpload,
|
|
10
|
+
withRefusalGuidance,
|
|
11
|
+
} from "../api.js";
|
|
12
|
+
import { LIMITS, VERB_MEDIA_UPLOAD } from "../constants.js";
|
|
13
|
+
import { SiteAuthoringError } from "../errors.js";
|
|
14
|
+
import { contentHash, inspectImageBytes } from "../image-metadata.js";
|
|
15
|
+
import { boundedList, openSession, successResult } from "../session.js";
|
|
16
|
+
import {
|
|
17
|
+
ensureWorkspaceRoot,
|
|
18
|
+
inspectWorkspaceEntry,
|
|
19
|
+
MEDIA_DIRECTORY,
|
|
20
|
+
MEDIA_MANIFEST_FILE_NAME,
|
|
21
|
+
MEDIA_MANIFEST_VERSION,
|
|
22
|
+
readMediaManifest,
|
|
23
|
+
readWorkspaceFile,
|
|
24
|
+
SAFE_MEDIA_SEGMENT,
|
|
25
|
+
walkWorkspaceFiles,
|
|
26
|
+
WORKSPACE_LIMITS,
|
|
27
|
+
writeMediaManifest,
|
|
28
|
+
} from "../workspace.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `media upload` — hash, request, PUT, confirm, then wait for processing.
|
|
32
|
+
*
|
|
33
|
+
* The client computes the SHA-256 and the pixel dimensions before it asks for
|
|
34
|
+
* anything, because `RequestImageUpload` needs all three: the hash drives the
|
|
35
|
+
* dedup short-circuit, and the dimensions are recorded with the image. When the
|
|
36
|
+
* response reports `isDuplicate`, the presigned PUT and the confirm are both
|
|
37
|
+
* skipped and the returned image is used as-is — re-uploading identical bytes
|
|
38
|
+
* would spend the site's storage budget to produce the row it already has.
|
|
39
|
+
*
|
|
40
|
+
* Otherwise the signed capability goes to `SiteApiClient.upload`, which echoes
|
|
41
|
+
* `requiredHeaders` verbatim: those values are covered by the signature, so a
|
|
42
|
+
* regenerated `Content-Type` turns into an opaque object-store 403.
|
|
43
|
+
*
|
|
44
|
+
* Processing is observed through `ListSiteImages`; `GetImageById` is
|
|
45
|
+
* session-only under TR00602's read list.
|
|
46
|
+
*
|
|
47
|
+
* What gets uploaded comes from the positional arguments — files, directories,
|
|
48
|
+
* or both — and from the workspace's `media/` directory when none are given.
|
|
49
|
+
*
|
|
50
|
+
* The workspace media manifest this writes is what `pages push` resolves image
|
|
51
|
+
* references against, and it is deliberately a separate file from the pull
|
|
52
|
+
* manifest: uploading media into a workspace that was never pulled is
|
|
53
|
+
* legitimate, and minting a pull manifest here would let `pages push` mistake
|
|
54
|
+
* it for evidence of a pull that never happened.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
const MAXIMUM_REPORTED = 200;
|
|
58
|
+
const MAXIMUM_FILES = 500;
|
|
59
|
+
const MEDIA_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp"];
|
|
60
|
+
const MEDIA_WALK_OPTIONS = Object.freeze({
|
|
61
|
+
segmentPattern: SAFE_MEDIA_SEGMENT,
|
|
62
|
+
segmentDescription: "letters, digits, '.', '_', '-', and '@'",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
function requireUploadCapability(response, fileName) {
|
|
66
|
+
if (
|
|
67
|
+
typeof response.presignedUrl !== "string"
|
|
68
|
+
|| response.presignedUrl === ""
|
|
69
|
+
|| typeof response.uploadId !== "string"
|
|
70
|
+
|| response.uploadId === ""
|
|
71
|
+
) {
|
|
72
|
+
throw new SiteAuthoringError(
|
|
73
|
+
"media.upload_capability_invalid",
|
|
74
|
+
`Taproot did not return a usable upload capability for '${fileName}'.`,
|
|
75
|
+
{ field: fileName },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return { url: response.presignedUrl, requiredHeaders: response.requiredHeaders };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function fileNameOf(relativePath) {
|
|
82
|
+
return relativePath.slice(relativePath.lastIndexOf("/") + 1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Normalizes one positional into the workspace-relative form the workspace
|
|
87
|
+
* helpers accept: `./media/hero.png` and `media/` and `media` all name the same
|
|
88
|
+
* thing to a person typing them, and none of the three should be a different
|
|
89
|
+
* outcome.
|
|
90
|
+
*/
|
|
91
|
+
function normalizePositional(value) {
|
|
92
|
+
return typeof value === "string" ? value.replace(/^\.\//u, "").replace(/\/+$/u, "") : "";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Expands the positional arguments into the concrete files to upload. A
|
|
97
|
+
* directory is walked for media the way a bare invocation walks `media/`; a
|
|
98
|
+
* regular file is taken as named, and its container is validated from its bytes
|
|
99
|
+
* rather than its extension. Anything else — a missing path, a symlink, a
|
|
100
|
+
* device — is refused by name rather than surfacing later as an unrelated
|
|
101
|
+
* "not a regular file" from the read.
|
|
102
|
+
*/
|
|
103
|
+
async function resolveUploadTargets(workspaceDir, positionals) {
|
|
104
|
+
const files = [];
|
|
105
|
+
const seen = new Set();
|
|
106
|
+
for (const positional of positionals) {
|
|
107
|
+
const candidate = normalizePositional(positional);
|
|
108
|
+
if (candidate === "") {
|
|
109
|
+
throw new SiteAuthoringError(
|
|
110
|
+
"media.path_invalid",
|
|
111
|
+
`'${positional}' does not name a file or directory relative to the workspace root.`,
|
|
112
|
+
{ field: positional },
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const kind = await inspectWorkspaceEntry(workspaceDir, candidate);
|
|
116
|
+
if (kind === "missing" || kind === "other") {
|
|
117
|
+
throw new SiteAuthoringError(
|
|
118
|
+
"media.path_invalid",
|
|
119
|
+
`'${candidate}' is neither a media file nor a directory relative to the workspace root.`,
|
|
120
|
+
{ field: candidate },
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const expanded = kind === "directory"
|
|
124
|
+
? await walkWorkspaceFiles(workspaceDir, candidate, MEDIA_EXTENSIONS, MEDIA_WALK_OPTIONS)
|
|
125
|
+
: [candidate];
|
|
126
|
+
for (const file of expanded) {
|
|
127
|
+
// Overlapping positionals — a directory and a file inside it — upload
|
|
128
|
+
// each file once.
|
|
129
|
+
if (seen.has(file)) continue;
|
|
130
|
+
seen.add(file);
|
|
131
|
+
files.push(file);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return files;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function waitForProcessing(client, siteId, imageIds, { onProgress, now }) {
|
|
138
|
+
const pending = new Set(imageIds);
|
|
139
|
+
if (pending.size === 0) return new Map();
|
|
140
|
+
const observed = new Map();
|
|
141
|
+
return await poll({
|
|
142
|
+
client,
|
|
143
|
+
now,
|
|
144
|
+
onProgress,
|
|
145
|
+
timeoutMilliseconds: LIMITS.uploadMilliseconds,
|
|
146
|
+
// The poller's `{ deadline, now }` travels into every page of the listing,
|
|
147
|
+
// so the bound holds *within* one read of a large library and not only
|
|
148
|
+
// between reads.
|
|
149
|
+
read: async (requestOptions) => (await listSiteImages(client, siteId, requestOptions)).images,
|
|
150
|
+
evaluate: (images) => {
|
|
151
|
+
for (const image of images) {
|
|
152
|
+
if (!pending.has(image.imageId)) continue;
|
|
153
|
+
observed.set(image.imageId, image);
|
|
154
|
+
}
|
|
155
|
+
const waiting = [...pending].filter((imageId) => {
|
|
156
|
+
const state = observed.get(imageId)?.processingState;
|
|
157
|
+
return state !== IMAGE_PROCESSING_STATE_COMPLETE && state !== IMAGE_PROCESSING_STATE_FAILED;
|
|
158
|
+
});
|
|
159
|
+
if (waiting.length === 0) return { done: true, value: observed };
|
|
160
|
+
return { done: false, progress: `Waiting for ${waiting.length} image(s) to finish processing.` };
|
|
161
|
+
},
|
|
162
|
+
timeoutCode: "media.processing_timeout",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function mediaUpload(invocation) {
|
|
167
|
+
const { client, config, siteId, now, onProgress } = await openSession(invocation);
|
|
168
|
+
await ensureWorkspaceRoot(config);
|
|
169
|
+
|
|
170
|
+
// Positional arguments name the files or directories to upload — `cli.js`
|
|
171
|
+
// hands them over as `paths`, and a programmatic caller can supply the same
|
|
172
|
+
// key directly. With none, the workspace's own `media/` directory is walked.
|
|
173
|
+
// Read before anything is selected or sent: a media manifest from another
|
|
174
|
+
// site holds image ids this site cannot deliver, and appending to it would
|
|
175
|
+
// mix two sites' media in one file.
|
|
176
|
+
const mediaManifest = await readMediaManifest(config.workspaceDir, siteId);
|
|
177
|
+
|
|
178
|
+
const selected = Array.isArray(invocation.paths) ? invocation.paths.filter((value) => value !== "") : [];
|
|
179
|
+
const files = selected.length > 0
|
|
180
|
+
? await resolveUploadTargets(config.workspaceDir, selected)
|
|
181
|
+
: await walkWorkspaceFiles(config.workspaceDir, MEDIA_DIRECTORY, MEDIA_EXTENSIONS, MEDIA_WALK_OPTIONS);
|
|
182
|
+
if (files.length === 0) {
|
|
183
|
+
throw new SiteAuthoringError(
|
|
184
|
+
"media.none_found",
|
|
185
|
+
selected.length > 0
|
|
186
|
+
? `No PNG, JPEG, GIF, or WebP files were found under ${selected.map((value) => `'${value}'`).join(", ")}.`
|
|
187
|
+
: `No PNG, JPEG, GIF, or WebP files were found under '${MEDIA_DIRECTORY}/' in the workspace.`,
|
|
188
|
+
{ field: selected.length > 0 ? normalizePositional(selected[0]) : MEDIA_DIRECTORY },
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
if (files.length > MAXIMUM_FILES) {
|
|
192
|
+
throw new SiteAuthoringError(
|
|
193
|
+
"media.too_many_files",
|
|
194
|
+
`This run would upload ${files.length} files, more than the bounded maximum of ${MAXIMUM_FILES}.`,
|
|
195
|
+
{ field: MEDIA_DIRECTORY },
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const uploaded = [];
|
|
200
|
+
|
|
201
|
+
return await withRefusalGuidance(onProgress, "upload", async () => {
|
|
202
|
+
try {
|
|
203
|
+
for (const file of files) {
|
|
204
|
+
const fileName = fileNameOf(file);
|
|
205
|
+
const bytes = await readWorkspaceFile(config.workspaceDir, file, WORKSPACE_LIMITS.mediaBytes);
|
|
206
|
+
const { contentType, width, height } = inspectImageBytes(bytes, file);
|
|
207
|
+
const hash = contentHash(bytes);
|
|
208
|
+
onProgress(`Requesting an upload for '${file}' (${contentType}, ${width}x${height}).`);
|
|
209
|
+
const response = await requestImageUpload(client, {
|
|
210
|
+
fileName,
|
|
211
|
+
contentType,
|
|
212
|
+
fileSize: bytes.byteLength,
|
|
213
|
+
contentHash: hash,
|
|
214
|
+
width,
|
|
215
|
+
height,
|
|
216
|
+
ownershipScope: IMAGE_OWNERSHIP_SCOPE_SITE,
|
|
217
|
+
siteId,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
let image;
|
|
221
|
+
let deduplicated = false;
|
|
222
|
+
if (response.isDuplicate === true) {
|
|
223
|
+
// The dedup hit *is* the existing image. There is nothing to PUT and
|
|
224
|
+
// nothing to confirm.
|
|
225
|
+
deduplicated = true;
|
|
226
|
+
image = normalizeImage(response.image);
|
|
227
|
+
onProgress(`'${file}' matched an existing image by content hash; skipping the upload.`);
|
|
228
|
+
} else {
|
|
229
|
+
await client.upload(requireUploadCapability(response, file), bytes);
|
|
230
|
+
const confirmation = await confirmImageUpload(client, response.uploadId);
|
|
231
|
+
image = normalizeImage(confirmation.image);
|
|
232
|
+
onProgress(`Uploaded and confirmed '${file}'.`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
mediaManifest.media[file] = {
|
|
236
|
+
imageId: image.imageId,
|
|
237
|
+
contentHash: hash,
|
|
238
|
+
contentType,
|
|
239
|
+
width: width || image.width,
|
|
240
|
+
height: height || image.height,
|
|
241
|
+
byteLength: bytes.byteLength,
|
|
242
|
+
// Preserve the author-owned alt text. Delivery fields start with
|
|
243
|
+
// whatever confirm returned and are refreshed from the completed
|
|
244
|
+
// library record below.
|
|
245
|
+
alt: typeof mediaManifest.media[file]?.alt === "string" ? mediaManifest.media[file].alt : "",
|
|
246
|
+
src: image.url,
|
|
247
|
+
urls: image.responsiveUrls,
|
|
248
|
+
deduplicated,
|
|
249
|
+
};
|
|
250
|
+
uploaded.push({ file, imageId: image.imageId, deduplicated, contentType, width, height });
|
|
251
|
+
}
|
|
252
|
+
} finally {
|
|
253
|
+
// Written before processing is awaited: an image that fails to process
|
|
254
|
+
// still exists, and losing its id would orphan it. The site is recorded
|
|
255
|
+
// with it, because an image id means nothing without one.
|
|
256
|
+
mediaManifest.mediaManifestVersion = MEDIA_MANIFEST_VERSION;
|
|
257
|
+
mediaManifest.siteId = siteId;
|
|
258
|
+
if (uploaded.length > 0) await writeMediaManifest(config.workspaceDir, mediaManifest);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
onProgress("Waiting for image processing to finish.");
|
|
262
|
+
const observed = await waitForProcessing(client, siteId, uploaded.map((entry) => entry.imageId), {
|
|
263
|
+
onProgress,
|
|
264
|
+
now,
|
|
265
|
+
});
|
|
266
|
+
const failed = uploaded
|
|
267
|
+
.map((entry) => ({ ...entry, processingState: observed.get(entry.imageId)?.processingState }))
|
|
268
|
+
.filter((entry) => entry.processingState === IMAGE_PROCESSING_STATE_FAILED);
|
|
269
|
+
if (failed.length > 0) {
|
|
270
|
+
throw new SiteAuthoringError(
|
|
271
|
+
"media.processing_failed",
|
|
272
|
+
`Taproot failed to process ${failed.length} uploaded image(s), starting with '${failed[0].file}'. `
|
|
273
|
+
+ `They are recorded in ${MEDIA_MANIFEST_FILE_NAME} but cannot be published.`,
|
|
274
|
+
{ field: failed[0].file, status: IMAGE_PROCESSING_STATE_FAILED },
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Processing is the point at which the delivery contract is complete.
|
|
279
|
+
// Persist and report the exact object shape component media fields accept,
|
|
280
|
+
// so an author never has to invent empty `src`/`urls` placeholders.
|
|
281
|
+
for (const entry of uploaded) {
|
|
282
|
+
const image = observed.get(entry.imageId);
|
|
283
|
+
const manifestEntry = mediaManifest.media[entry.file];
|
|
284
|
+
if (image === undefined || manifestEntry === undefined) continue;
|
|
285
|
+
manifestEntry.src = image.url;
|
|
286
|
+
manifestEntry.urls = image.responsiveUrls;
|
|
287
|
+
}
|
|
288
|
+
await writeMediaManifest(config.workspaceDir, mediaManifest);
|
|
289
|
+
|
|
290
|
+
const reported = boundedList(
|
|
291
|
+
uploaded.map((entry) => ({
|
|
292
|
+
file: entry.file,
|
|
293
|
+
imageId: entry.imageId,
|
|
294
|
+
deduplicated: entry.deduplicated,
|
|
295
|
+
width: entry.width,
|
|
296
|
+
height: entry.height,
|
|
297
|
+
processingState: observed.get(entry.imageId)?.processingState ?? IMAGE_PROCESSING_STATE_COMPLETE,
|
|
298
|
+
media: {
|
|
299
|
+
imageId: entry.imageId,
|
|
300
|
+
src: observed.get(entry.imageId)?.url ?? "",
|
|
301
|
+
urls: observed.get(entry.imageId)?.responsiveUrls ?? [],
|
|
302
|
+
width: entry.width,
|
|
303
|
+
height: entry.height,
|
|
304
|
+
alt: mediaManifest.media[entry.file]?.alt ?? "",
|
|
305
|
+
},
|
|
306
|
+
})),
|
|
307
|
+
MAXIMUM_REPORTED,
|
|
308
|
+
);
|
|
309
|
+
return successResult(VERB_MEDIA_UPLOAD, siteId, {
|
|
310
|
+
mediaManifestFile: MEDIA_MANIFEST_FILE_NAME,
|
|
311
|
+
media: {
|
|
312
|
+
total: uploaded.length,
|
|
313
|
+
deduplicated: uploaded.filter((entry) => entry.deduplicated).length,
|
|
314
|
+
items: reported.items,
|
|
315
|
+
...(reported.truncated ? { itemsTruncated: true } : {}),
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getNavigation,
|
|
3
|
+
listSitePages,
|
|
4
|
+
NAV_ITEM_KIND_EXTERNAL_URL,
|
|
5
|
+
NAV_ITEM_KIND_PAGE,
|
|
6
|
+
NAV_ITEM_KINDS,
|
|
7
|
+
NAVIGATION_MAXIMUM_DEPTH,
|
|
8
|
+
saveNavigation,
|
|
9
|
+
withRefusalGuidance,
|
|
10
|
+
} from "../api.js";
|
|
11
|
+
import { VERB_NAV_PUSH } from "../constants.js";
|
|
12
|
+
import { SiteAuthoringError } from "../errors.js";
|
|
13
|
+
import { boundedList, openSession, successResult } from "../session.js";
|
|
14
|
+
import {
|
|
15
|
+
NAVIGATION_FILE_NAME,
|
|
16
|
+
readWorkspaceJson,
|
|
17
|
+
workspaceFileExists,
|
|
18
|
+
WORKSPACE_LIMITS,
|
|
19
|
+
} from "../workspace.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `nav push` — replace the whole navigation tree.
|
|
23
|
+
*
|
|
24
|
+
* `SaveSiteNavigation` is a whole-tree PUT with no concurrency token, so it is
|
|
25
|
+
* last-write-wins against a browser session editing the same tree. Two things
|
|
26
|
+
* follow, and the body does both:
|
|
27
|
+
*
|
|
28
|
+
* 1. The tree is re-read immediately before the write, so the window in which
|
|
29
|
+
* someone else's edit can be lost is as small as the contract allows, and
|
|
30
|
+
* what the replace is about to remove is *named* rather than discovered
|
|
31
|
+
* afterwards.
|
|
32
|
+
* 2. Local validation is exhaustive and refuses by item path. The server stores
|
|
33
|
+
* what it is given; a `PAGE` item carrying `pageId` instead of `resourceId`,
|
|
34
|
+
* or a fourth level of nesting, is accepted and then renders wrong. And the
|
|
35
|
+
* one shape it does check — `id` and `resourceId` are parsed as GUIDs — it
|
|
36
|
+
* refuses with a bare `InvalidArgument` naming no field at all, so a single
|
|
37
|
+
* `id: "home"` in a hundred-item tree would come back as an unclassified 400
|
|
38
|
+
* with nothing to grep for. A `resourceId` that is a valid UUID pointing at
|
|
39
|
+
* no page is the same story with a bare `ResourceId`, so PAGE targets are
|
|
40
|
+
* checked against the live page list too. Naming `navItems[1].children[0]`
|
|
41
|
+
* is the difference between a fixable error and a hunt.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const MAXIMUM_REPORTED = 100;
|
|
45
|
+
const MAXIMUM_ITEMS = 1_000;
|
|
46
|
+
const MAXIMUM_TITLE_LENGTH = 200;
|
|
47
|
+
const MAXIMUM_URL_LENGTH = 2_048;
|
|
48
|
+
const ITEM_KEYS = new Set(["id", "kind", "title", "resourceId", "externalUrl", "children"]);
|
|
49
|
+
|
|
50
|
+
// `SaveSiteNavigation` maps both `id` and `resourceId` through
|
|
51
|
+
// `RequestValidation.RequireGuid`. A value that is not a GUID comes back as a
|
|
52
|
+
// bare `InvalidArgument` carrying no field violation at all — an unclassified
|
|
53
|
+
// 400 with nothing to say which of a hundred items was wrong. This verb's whole
|
|
54
|
+
// contract is that a malformed tree is named locally, by item path, so the
|
|
55
|
+
// shape is checked here rather than discovered there.
|
|
56
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
|
57
|
+
|
|
58
|
+
function navError(code, message, itemPath) {
|
|
59
|
+
return new SiteAuthoringError(code, `${itemPath}: ${message}`, { field: itemPath });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isBoundedText(value, maximum) {
|
|
63
|
+
return typeof value === "string" && value.length > 0 && value.length <= maximum;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isUuid(value) {
|
|
67
|
+
return typeof value === "string" && UUID.test(value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isEmpty(value) {
|
|
71
|
+
return value === undefined || value === null || value === "";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function validateItem(item, itemPath, depth, seenIds, counters) {
|
|
75
|
+
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
|
76
|
+
throw navError("nav.item_invalid", "a navigation item must be an object.", itemPath);
|
|
77
|
+
}
|
|
78
|
+
if (depth > NAVIGATION_MAXIMUM_DEPTH) {
|
|
79
|
+
throw navError(
|
|
80
|
+
"nav.depth_exceeded",
|
|
81
|
+
`navigation nests deeper than ${NAVIGATION_MAXIMUM_DEPTH} levels.`,
|
|
82
|
+
itemPath,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
counters.items += 1;
|
|
86
|
+
if (counters.items > MAXIMUM_ITEMS) {
|
|
87
|
+
throw navError("nav.too_many_items", `the navigation tree holds more than ${MAXIMUM_ITEMS} items.`, itemPath);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const unknown = Object.keys(item).filter((key) => !ITEM_KEYS.has(key)).sort();
|
|
91
|
+
if (unknown.length > 0) {
|
|
92
|
+
// `pageId` is the specific mistake this catches: the navigation contract
|
|
93
|
+
// targets a page by its *site resource* id, and a `pageId` key is stored
|
|
94
|
+
// and then silently ignored.
|
|
95
|
+
throw navError(
|
|
96
|
+
"nav.unknown_field",
|
|
97
|
+
unknown.includes("pageId")
|
|
98
|
+
? "a PAGE item targets 'resourceId', never 'pageId'."
|
|
99
|
+
: `unsupported navigation field '${unknown[0]}'.`,
|
|
100
|
+
itemPath,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (!isUuid(item.id)) {
|
|
104
|
+
throw navError(
|
|
105
|
+
"nav.id_invalid",
|
|
106
|
+
"every navigation item needs a stable 'id' that is a canonical lowercase UUID; Taproot parses it as one.",
|
|
107
|
+
itemPath,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (seenIds.has(item.id)) {
|
|
111
|
+
throw navError("nav.id_duplicate", `navigation id '${item.id}' appears more than once in the tree.`, itemPath);
|
|
112
|
+
}
|
|
113
|
+
seenIds.add(item.id);
|
|
114
|
+
if (typeof item.kind !== "string" || !NAV_ITEM_KINDS.includes(item.kind)) {
|
|
115
|
+
throw navError(
|
|
116
|
+
"nav.kind_invalid",
|
|
117
|
+
`'kind' must be one of ${NAV_ITEM_KINDS.join(", ")}.`,
|
|
118
|
+
itemPath,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (!isBoundedText(item.title, MAXIMUM_TITLE_LENGTH)) {
|
|
122
|
+
throw navError("nav.title_invalid", "every navigation item needs a non-empty 'title'.", itemPath);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (item.kind === NAV_ITEM_KIND_PAGE) {
|
|
126
|
+
if (!isUuid(item.resourceId)) {
|
|
127
|
+
throw navError(
|
|
128
|
+
"nav.resource_missing",
|
|
129
|
+
"a PAGE item needs the target page's 'resourceId' — the canonical lowercase UUID 'pull' records in the "
|
|
130
|
+
+ "manifest, not the page path or pageId.",
|
|
131
|
+
itemPath,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (!isEmpty(item.externalUrl)) {
|
|
135
|
+
throw navError("nav.external_url_unexpected", "a PAGE item must not carry 'externalUrl'.", itemPath);
|
|
136
|
+
}
|
|
137
|
+
// Kept with its item path so the liveness check below can still name the
|
|
138
|
+
// item; by then the tree has been rebuilt and the paths are gone.
|
|
139
|
+
counters.pageReferences.push({ itemPath, resourceId: item.resourceId });
|
|
140
|
+
} else if (item.kind === NAV_ITEM_KIND_EXTERNAL_URL) {
|
|
141
|
+
if (!isBoundedText(item.externalUrl, MAXIMUM_URL_LENGTH)) {
|
|
142
|
+
throw navError("nav.external_url_missing", "an EXTERNAL_URL item needs a non-empty 'externalUrl'.", itemPath);
|
|
143
|
+
}
|
|
144
|
+
let url;
|
|
145
|
+
try {
|
|
146
|
+
url = new URL(item.externalUrl);
|
|
147
|
+
} catch {
|
|
148
|
+
url = undefined;
|
|
149
|
+
}
|
|
150
|
+
if (url === undefined || (url.protocol !== "https:" && url.protocol !== "http:")) {
|
|
151
|
+
throw navError("nav.external_url_invalid", "'externalUrl' must be an absolute http or https URL.", itemPath);
|
|
152
|
+
}
|
|
153
|
+
if (!isEmpty(item.resourceId)) {
|
|
154
|
+
throw navError("nav.resource_unexpected", "an EXTERNAL_URL item must not carry 'resourceId'.", itemPath);
|
|
155
|
+
}
|
|
156
|
+
} else if (!isEmpty(item.resourceId) || !isEmpty(item.externalUrl)) {
|
|
157
|
+
throw navError(
|
|
158
|
+
"nav.target_unexpected",
|
|
159
|
+
"a GROUP_HEADER item is a label and must carry neither 'resourceId' nor 'externalUrl'.",
|
|
160
|
+
itemPath,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const children = item.children ?? [];
|
|
165
|
+
if (!Array.isArray(children)) {
|
|
166
|
+
throw navError("nav.children_invalid", "'children' must be a list when it is present.", itemPath);
|
|
167
|
+
}
|
|
168
|
+
const validated = children.map((child, index) =>
|
|
169
|
+
validateItem(child, `${itemPath}.children[${index}]`, depth + 1, seenIds, counters));
|
|
170
|
+
// Rebuilt field by field so nothing outside the contract can ride along, and
|
|
171
|
+
// so an absent optional crosses the wire as the zero value proto3 expects.
|
|
172
|
+
return {
|
|
173
|
+
id: item.id,
|
|
174
|
+
kind: item.kind,
|
|
175
|
+
title: item.title,
|
|
176
|
+
resourceId: item.kind === NAV_ITEM_KIND_PAGE ? item.resourceId : "",
|
|
177
|
+
externalUrl: item.kind === NAV_ITEM_KIND_EXTERNAL_URL ? item.externalUrl : "",
|
|
178
|
+
children: validated,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function collectIds(items, into = new Set()) {
|
|
183
|
+
for (const item of items) {
|
|
184
|
+
if (item === null || typeof item !== "object") continue;
|
|
185
|
+
if (typeof item.id === "string") into.add(item.id);
|
|
186
|
+
if (Array.isArray(item.children)) collectIds(item.children, into);
|
|
187
|
+
}
|
|
188
|
+
return into;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function requireKnownPageTargets(pageReferences, knownResourceIds, knownPageIds) {
|
|
192
|
+
for (const reference of pageReferences) {
|
|
193
|
+
if (knownResourceIds.has(reference.resourceId)) continue;
|
|
194
|
+
throw navError(
|
|
195
|
+
"nav.resource_unknown",
|
|
196
|
+
knownPageIds.has(reference.resourceId)
|
|
197
|
+
? `'${reference.resourceId}' is a pageId, not a site resourceId. Navigation targets resource_id — use the `
|
|
198
|
+
+ "'resourceId' that 'pull' records beside 'pageId' on the same manifest entry."
|
|
199
|
+
: `'${reference.resourceId}' does not name a page on this site. Use the 'resourceId' that 'pull' records `
|
|
200
|
+
+ "in the manifest, and re-run 'pull' if the page was created elsewhere.",
|
|
201
|
+
reference.itemPath,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Refuses a PAGE item whose `resourceId` is well-formed but points at nothing.
|
|
208
|
+
*
|
|
209
|
+
* The shape check upstream only proves the value is a UUID. A UUID that names
|
|
210
|
+
* no page on this site is accepted by every local rule and then refused by the
|
|
211
|
+
* server with a bare `ResourceId` — no index, no item path, nothing to locate
|
|
212
|
+
* across a tree that may hold a thousand items. Since the ids come from the
|
|
213
|
+
* manifest, where `pageId` and `resourceId` sit next to each other on the same
|
|
214
|
+
* entry, the overwhelmingly likely mistake is reaching for the wrong one; when
|
|
215
|
+
* the value turns out to be a live `pageId`, the message says so instead of
|
|
216
|
+
* repeating "not found".
|
|
217
|
+
*
|
|
218
|
+
* A page list this CLI could not fully enumerate produces no refusal at all: an
|
|
219
|
+
* incomplete set would reject valid trees, and a false refusal here is worse
|
|
220
|
+
* than the server's vague one.
|
|
221
|
+
*/
|
|
222
|
+
async function requireLivePageTargets(client, siteId, pageReferences, onProgress) {
|
|
223
|
+
if (pageReferences.length === 0) return;
|
|
224
|
+
onProgress(`Checking ${pageReferences.length} PAGE target(s) against the site's pages.`);
|
|
225
|
+
const { pages, truncated } = await listSitePages(client, siteId, { onProgress });
|
|
226
|
+
if (truncated) {
|
|
227
|
+
onProgress("The page list was bounded before the site was fully enumerated; PAGE targets are not checked.");
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const liveResourceIds = new Set(pages.map((summary) => summary.resourceId).filter(Boolean));
|
|
231
|
+
const livePageIds = new Set(pages.map((summary) => summary.pageId));
|
|
232
|
+
requireKnownPageTargets(pageReferences, liveResourceIds, livePageIds);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The complete local validation phase shared by live push and offline fixtures. */
|
|
236
|
+
export function validateNavigationWorkspaceDocument(parsed, siteId, knownPages) {
|
|
237
|
+
// The pulled shape, and only the pulled shape. A bare array carries no site,
|
|
238
|
+
// and this verb replaces a whole navigation tree in one unversioned write.
|
|
239
|
+
if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") {
|
|
240
|
+
throw new SiteAuthoringError(
|
|
241
|
+
"nav.file_invalid",
|
|
242
|
+
`${NAVIGATION_FILE_NAME} must be the object 'pull' writes — { "siteId": ..., "navItems": [...] } — `
|
|
243
|
+
+ "not a bare list.",
|
|
244
|
+
{ field: NAVIGATION_FILE_NAME },
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
if (parsed.siteId !== siteId) {
|
|
248
|
+
throw new SiteAuthoringError(
|
|
249
|
+
"nav.file_site_mismatch",
|
|
250
|
+
`${NAVIGATION_FILE_NAME} records ${
|
|
251
|
+
typeof parsed.siteId === "string" && parsed.siteId !== "" ? `site ${parsed.siteId.slice(0, 64)}` : "no site"
|
|
252
|
+
}, but this configuration targets site ${siteId}. Run 'taproot-site pull' into a workspace of its own `
|
|
253
|
+
+ "for this site.",
|
|
254
|
+
{ field: NAVIGATION_FILE_NAME },
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
const declared = parsed.navItems;
|
|
258
|
+
if (!Array.isArray(declared)) {
|
|
259
|
+
throw new SiteAuthoringError(
|
|
260
|
+
"nav.file_invalid",
|
|
261
|
+
`${NAVIGATION_FILE_NAME} must hold a 'navItems' list.`,
|
|
262
|
+
{ field: "navItems" },
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const counters = { items: 0, pageReferences: [] };
|
|
267
|
+
const seenIds = new Set();
|
|
268
|
+
const navItems = declared.map((item, index) =>
|
|
269
|
+
validateItem(item, `navItems[${index}]`, 1, seenIds, counters)
|
|
270
|
+
);
|
|
271
|
+
if (knownPages !== undefined) {
|
|
272
|
+
requireKnownPageTargets(counters.pageReferences, knownPages.resourceIds, knownPages.pageIds);
|
|
273
|
+
}
|
|
274
|
+
return { navItems, items: counters.items, pageReferences: counters.pageReferences };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export async function navPush(invocation) {
|
|
278
|
+
const { client, config, siteId, onProgress } = await openSession(invocation);
|
|
279
|
+
if (!await workspaceFileExists(config.workspaceDir, NAVIGATION_FILE_NAME)) {
|
|
280
|
+
throw new SiteAuthoringError(
|
|
281
|
+
"nav.file_missing",
|
|
282
|
+
`No ${NAVIGATION_FILE_NAME} was found in the workspace. Run 'taproot-site pull' first.`,
|
|
283
|
+
{ field: NAVIGATION_FILE_NAME },
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
const parsed = await readWorkspaceJson(config.workspaceDir, NAVIGATION_FILE_NAME, WORKSPACE_LIMITS.navigationBytes);
|
|
287
|
+
const validated = validateNavigationWorkspaceDocument(parsed, siteId);
|
|
288
|
+
const { navItems, pageReferences } = validated;
|
|
289
|
+
onProgress(`Validated ${validated.items} navigation items to depth ${NAVIGATION_MAXIMUM_DEPTH}.`);
|
|
290
|
+
|
|
291
|
+
return await withRefusalGuidance(onProgress, "navigation push", async () => {
|
|
292
|
+
await requireLivePageTargets(client, siteId, pageReferences, onProgress);
|
|
293
|
+
|
|
294
|
+
// Re-read immediately before the replace. There is no concurrency token to
|
|
295
|
+
// check, so this is the narrowest window the contract permits — and the
|
|
296
|
+
// only way to say what the replace removes. It happens *after* the target
|
|
297
|
+
// check so the window between reading the tree and replacing it stays as
|
|
298
|
+
// small as the contract allows.
|
|
299
|
+
onProgress("Re-reading the live navigation tree immediately before replacing it.");
|
|
300
|
+
const liveItems = await getNavigation(client, siteId);
|
|
301
|
+
const liveIds = collectIds(liveItems);
|
|
302
|
+
const pushedIds = collectIds(navItems);
|
|
303
|
+
const removed = [...liveIds].filter((id) => !pushedIds.has(id)).sort();
|
|
304
|
+
const added = [...pushedIds].filter((id) => !liveIds.has(id)).sort();
|
|
305
|
+
if (removed.length > 0) {
|
|
306
|
+
onProgress(
|
|
307
|
+
`This whole-tree replace removes ${removed.length} navigation item(s) that are live now: ${
|
|
308
|
+
removed.slice(0, 10).join(", ")
|
|
309
|
+
}${removed.length > 10 ? ", ..." : ""}.`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const saved = await saveNavigation(client, siteId, navItems);
|
|
314
|
+
onProgress(`Replaced the navigation tree with ${validated.items} items.`);
|
|
315
|
+
|
|
316
|
+
const removedReport = boundedList(removed, MAXIMUM_REPORTED);
|
|
317
|
+
const addedReport = boundedList(added, MAXIMUM_REPORTED);
|
|
318
|
+
return successResult(VERB_NAV_PUSH, siteId, {
|
|
319
|
+
navigation: {
|
|
320
|
+
file: NAVIGATION_FILE_NAME,
|
|
321
|
+
items: validated.items,
|
|
322
|
+
rootItems: navItems.length,
|
|
323
|
+
savedRootItems: saved.length,
|
|
324
|
+
added: addedReport.items,
|
|
325
|
+
removed: removedReport.items,
|
|
326
|
+
...(addedReport.truncated || removedReport.truncated ? { diffTruncated: true } : {}),
|
|
327
|
+
},
|
|
328
|
+
// Said plainly, because it is the risk this verb carries: there is no
|
|
329
|
+
// concurrency token on the navigation contract.
|
|
330
|
+
lastWriteWins: true,
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
}
|