mechanica 2.0.2 → 2.1.0-beta.1

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 (44) hide show
  1. package/README.md +4 -2
  2. package/bin/mechanica.js +3 -1
  3. package/dist/chunks/{editor-JV0Nhfw4.js → editor-MStotuaz.js} +276 -106
  4. package/dist/chunks/node-adapter-4eD9p0iP.js +699 -0
  5. package/dist/chunks/page-assets-DsJXmoDs.js +70 -0
  6. package/dist/chunks/{page-assets-CM_MCuoa.js → rich-text-codec--Nssj0Nn.js} +71 -117
  7. package/dist/chunks/site-DR_X2rzc.js +62 -0
  8. package/dist/cli.js +482 -618
  9. package/dist/composer.js +16 -65
  10. package/dist/editor.css +112 -112
  11. package/dist/editor.js +69 -123
  12. package/dist/export.js +583 -0
  13. package/dist/plugin.js +69 -656
  14. package/dist/server.js +4 -0
  15. package/dist/types/cli/build.d.ts +3 -1
  16. package/dist/types/cli/export.d.ts +31 -2
  17. package/dist/types/cli/platform/api.d.ts +33 -0
  18. package/dist/types/cli/platform/bundle-format.d.ts +40 -0
  19. package/dist/types/cli/platform/credentials.d.ts +11 -0
  20. package/dist/types/cli/platform/engine-version.d.ts +6 -0
  21. package/dist/types/cli/platform/git.d.ts +15 -0
  22. package/dist/types/cli/platform/link.d.ts +11 -0
  23. package/dist/types/cli/platform/login.d.ts +9 -0
  24. package/dist/types/cli/platform/push.d.ts +12 -0
  25. package/dist/types/cli/run-export.d.ts +7 -0
  26. package/dist/types/editor/lib/backend.d.ts +90 -0
  27. package/dist/types/editor/lib/page-list.d.ts +5 -20
  28. package/dist/types/{vite/dev → server}/assets-store.d.ts +2 -2
  29. package/dist/types/server/editor-page.d.ts +23 -0
  30. package/dist/types/server/index.d.ts +11 -0
  31. package/dist/types/server/node-adapter.d.ts +11 -0
  32. package/dist/types/{vite/dev → server}/pages-store.d.ts +5 -5
  33. package/dist/types/{vite → server}/rich-text-codec.d.ts +6 -0
  34. package/dist/types/server/service.d.ts +53 -0
  35. package/dist/types/server/site.d.ts +29 -0
  36. package/dist/types/vite/dev/middleware.d.ts +6 -28
  37. package/package.json +10 -2
  38. package/dist/types/cli/push.d.ts +0 -10
  39. /package/dist/types/{vite/dev → server}/composed-store.d.ts +0 -0
  40. /package/dist/types/{vite/dev → server}/data-store.d.ts +0 -0
  41. /package/dist/types/{vite/dev → server}/fs-utils.d.ts +0 -0
  42. /package/dist/types/{vite/dev → server}/image-preview.d.ts +0 -0
  43. /package/dist/types/{vite/dev → server}/page-state.d.ts +0 -0
  44. /package/dist/types/{vite/dev → server}/query-dev.d.ts +0 -0
@@ -0,0 +1,699 @@
1
+ import { n as configureSite, r as indexGeneratedPages } from "./site-DR_X2rzc.js";
2
+ import { A as savePage, B as saveDerivedAsset, C as listFolders, D as pageVersion, G as writeFileAtomic, N as setPageDraft, O as readPage, P as translationsOf, R as listImages, T as movePage, U as markMutated, V as saveUpload, _ as deletePage, a as folderOf, b as fillContentDefaults, c as mergeLocaleSiteData, f as readSiteData, g as createTranslation, h as createPage, k as renamePage, l as mergeSiteData, m as PageExistsError, o as mergeFolderData, s as mergeLocaleFolderData, u as readFolderData, v as deleteTranslation, w as listPages, x as fillImageMeta, y as duplicatePage } from "./rich-text-codec--Nssj0Nn.js";
3
+ import fs, { readFileSync, readdirSync } from "node:fs";
4
+ import { basename, extname, join, resolve, sep } from "node:path";
5
+ import { EDITOR_DIST_DIR, mergeTranslation, parseLocalePath, passDataToHTML, resolveQueryKey, serializeState } from "mechanica-shared";
6
+ import { parseComposedBlock, serializeComposedBlock } from "mechanica-shared/block-format";
7
+ import { Readable } from "node:stream";
8
+ import { createHash } from "node:crypto";
9
+ //#region src/vite/collect-composed.ts
10
+ /** Filename suffix for composed-block documents under `<mech>/blocks`. */
11
+ var COMPOSED_EXT = ".block.yml";
12
+ /** List `*.block.yml` files directly under `dir`, in stable (sorted) order. */
13
+ function listComposedFiles(dir) {
14
+ try {
15
+ return readdirSync(dir).filter((name) => name.endsWith(COMPOSED_EXT)).sort().map((name) => join(dir, name));
16
+ } catch {
17
+ return [];
18
+ }
19
+ }
20
+ /**
21
+ * Read and parse every composed-block definition under `dir`
22
+ * (`<mech>/blocks`). A malformed file is skipped (reported via `onError`) so one
23
+ * bad block never breaks the whole set. The id defaults to the filename base.
24
+ */
25
+ function loadComposedDefinitions(dir, onError) {
26
+ const defs = [];
27
+ for (const file of listComposedFiles(dir)) try {
28
+ defs.push(parseComposedBlock(readFileSync(file, "utf-8"), basename(file, COMPOSED_EXT)));
29
+ } catch (error) {
30
+ onError?.(file, error);
31
+ }
32
+ return defs;
33
+ }
34
+ /**
35
+ * Generate the `virtual:mechanica/composed` module source: the parsed
36
+ * definitions baked in as a JSON literal (they are data, never imports). Loaded
37
+ * by the client/SSR/preview entries, which register them into the block set.
38
+ */
39
+ function collectComposed(dir, onError) {
40
+ const defs = loadComposedDefinitions(dir, onError);
41
+ return `export const composedList = ${JSON.stringify(defs)}\n`;
42
+ }
43
+ //#endregion
44
+ //#region src/server/query-dev.ts
45
+ /**
46
+ * The dev server's {@link QuerySource}: pages come live from the `.mech`
47
+ * store; `useFetch` URLs are fetched server-side (no CORS, matching how the
48
+ * export resolves them at build time). `generated` pages (plugin `generatePages`)
49
+ * are merged into the page listing so `usePages()` sees them, like the export.
50
+ */
51
+ function devQuerySource(mechDir, generated) {
52
+ return {
53
+ listPages: (options) => listPages(mechDir, {
54
+ ...options,
55
+ generated
56
+ }),
57
+ fetchJson: async ({ url, ...init }) => {
58
+ const res = await fetch(url, init);
59
+ if (!res.ok) throw new Error(`${url} responded ${res.status}`);
60
+ return res.json();
61
+ }
62
+ };
63
+ }
64
+ /**
65
+ * Resolve a dev-mode query key against the local `.mech` store. Keys are of the
66
+ * form `"<type>.<json-args>"` (the same encoding the runtime composables use);
67
+ * `context.page` selects the chunk of a paginated query.
68
+ */
69
+ function resolveDevQuery(mechDir, key, context = {}, generated) {
70
+ return resolveQueryKey(devQuerySource(mechDir, generated), key, context);
71
+ }
72
+ //#endregion
73
+ //#region src/server/page-state.ts
74
+ /**
75
+ * Recognize a paginated variant URL (`/blog/2`): no page file of its own, a
76
+ * numeric tail ≥ 2, and an existing base page. Returns the base path + number.
77
+ * Checked against the default-locale (base) files — pagination structure is
78
+ * defined by the logical page, and variant URLs never have their own file.
79
+ */
80
+ function paginatedVariantOf(mechDir, urlPath) {
81
+ const match = urlPath.match(/^(.*)\/(\d+)$/);
82
+ if (!match) return null;
83
+ const page = Number(match[2]);
84
+ if (page < 2) return null;
85
+ if (pageVersion(mechDir, urlPath) != null) return null;
86
+ const basePath = match[1] || "/";
87
+ if (pageVersion(mechDir, basePath) == null) return null;
88
+ return {
89
+ basePath,
90
+ page
91
+ };
92
+ }
93
+ /**
94
+ * The dev state for a page: effective data resolved site < folder < page, plus
95
+ * the scope buckets the editor edits, and the page's on-disk version for
96
+ * optimistic-concurrency saves. Injected as `window.state` on page load and
97
+ * served by `GET /@mechanica/state` when the editor refreshes after an
98
+ * external change.
99
+ *
100
+ * A paginated variant URL (`/blog/2`) serves its base page's state with
101
+ * `page.pagination` set — `page.path` stays the base path, so editing the
102
+ * variant edits (and saves to) the real page.
103
+ *
104
+ * A locale-prefixed URL (`/ru/about`) serves that page's translation: the
105
+ * prefix is stripped to the logical path, `page.locale`/`page.locales` are set,
106
+ * and the version targets the translation file. When no translation exists the
107
+ * default-locale content renders as a fallback (`page.localeFallback`).
108
+ */
109
+ function buildPageState(mechDir, urlPath, config) {
110
+ const { locale, path: localePathStripped } = parseLocalePath(urlPath, config);
111
+ const variant = paginatedVariantOf(mechDir, localePathStripped);
112
+ const pagePath = variant?.basePath ?? localePathStripped;
113
+ const localeCode = !config || locale === config.default ? void 0 : locale;
114
+ let fallback = false;
115
+ let page = readPage(mechDir, pagePath, localeCode);
116
+ let layout = page.layout;
117
+ if (localeCode) {
118
+ const hasTranslation = pageVersion(mechDir, pagePath, localeCode) != null;
119
+ const hasBase = pageVersion(mechDir, pagePath) != null;
120
+ if (!hasTranslation) {
121
+ page = readPage(mechDir, pagePath);
122
+ fallback = true;
123
+ layout = page.layout;
124
+ } else if (hasBase) {
125
+ const base = readPage(mechDir, pagePath);
126
+ layout = base.layout;
127
+ const merged = mergeTranslation({
128
+ content: base.content ?? [],
129
+ data: base.data ?? {},
130
+ meta: base.meta ?? {}
131
+ }, {
132
+ content: page.content ?? [],
133
+ data: page.data ?? {},
134
+ meta: page.meta ?? {}
135
+ });
136
+ page = {
137
+ ...page,
138
+ content: merged.content,
139
+ data: merged.data,
140
+ meta: merged.meta
141
+ };
142
+ }
143
+ }
144
+ const content = page.content ?? [];
145
+ fillContentDefaults(mechDir, content);
146
+ fillImageMeta(mechDir, content);
147
+ let baseContent;
148
+ if (localeCode && pageVersion(mechDir, pagePath) != null) {
149
+ const base = readPage(mechDir, pagePath).content ?? [];
150
+ fillContentDefaults(mechDir, base);
151
+ fillImageMeta(mechDir, base);
152
+ baseContent = base;
153
+ }
154
+ const folder = folderOf(mechDir, pagePath);
155
+ const siteData = readSiteData(mechDir, localeCode);
156
+ const folderData = readFolderData(mechDir, folder, localeCode);
157
+ const pageData = page.data ?? {};
158
+ const pageMeta = {
159
+ path: pagePath,
160
+ meta: page.meta ?? {}
161
+ };
162
+ if (layout) pageMeta.layout = layout;
163
+ if (variant) pageMeta.pagination = { page: variant.page };
164
+ if (config) {
165
+ pageMeta.locale = locale;
166
+ pageMeta.locales = translationsOf(mechDir, pagePath, config);
167
+ if (fallback) pageMeta.localeFallback = true;
168
+ }
169
+ return {
170
+ content,
171
+ data: {
172
+ ...siteData,
173
+ ...folderData,
174
+ ...pageData
175
+ },
176
+ siteData,
177
+ folderData,
178
+ pageData,
179
+ folder,
180
+ page: pageMeta,
181
+ version: pageVersion(mechDir, pagePath, localeCode),
182
+ ...config ? { locales: config } : {},
183
+ ...baseContent ? { baseContent } : {}
184
+ };
185
+ }
186
+ /**
187
+ * The dev state for a programmatically generated page ({@link VirtualPage}) —
188
+ * the file-free counterpart of {@link buildPageState}. It replicates the same
189
+ * shape (content with block-prop defaults + image meta filled, the
190
+ * site‹folder‹page data merge, and `page.locale`/`page.locales`), but reads its
191
+ * content/data/meta from the baked page rather than a `.page.md`. `version` is
192
+ * null and `generated` is set, so the editor treats it as read-only and never
193
+ * queues a save against a file that doesn't exist.
194
+ */
195
+ function buildGeneratedState(mechDir, vp, config) {
196
+ const content = vp.content ?? [];
197
+ fillContentDefaults(mechDir, content);
198
+ fillImageMeta(mechDir, content);
199
+ const localeCode = !config || !vp.locale || vp.locale === config.default ? void 0 : vp.locale;
200
+ const folder = folderOf(mechDir, vp.path);
201
+ const siteData = readSiteData(mechDir, localeCode);
202
+ const folderData = readFolderData(mechDir, folder, localeCode);
203
+ const pageData = vp.data ?? {};
204
+ const pageMeta = {
205
+ path: vp.path,
206
+ meta: vp.meta ?? {}
207
+ };
208
+ if (vp.layout) pageMeta.layout = vp.layout;
209
+ if (config) {
210
+ pageMeta.locale = vp.locale ?? config.default;
211
+ pageMeta.locales = vp.locales ?? [config.default];
212
+ }
213
+ return {
214
+ content,
215
+ data: {
216
+ ...siteData,
217
+ ...folderData,
218
+ ...pageData
219
+ },
220
+ siteData,
221
+ folderData,
222
+ pageData,
223
+ folder,
224
+ page: pageMeta,
225
+ version: null,
226
+ generated: true,
227
+ ...config ? { locales: config } : {}
228
+ };
229
+ }
230
+ //#endregion
231
+ //#region src/server/composed-store.ts
232
+ /**
233
+ * CRUD for composed blocks under `<mech>/blocks/<id>.block.yml`. Mirrors
234
+ * `pages-store`: atomic writes, own-write marking (so the watcher tells the
235
+ * composer's own saves from external edits), and content-hash versioning for
236
+ * optimistic-concurrency saves (a 409 when the file changed under the editor).
237
+ * See PLAN.md § 5.1.
238
+ */
239
+ /** The directory composed blocks live in. */
240
+ function composedDirOf(mechDir) {
241
+ return join(mechDir, "blocks");
242
+ }
243
+ var fileOf = (mechDir, id) => join(composedDirOf(mechDir), `${id}${COMPOSED_EXT}`);
244
+ var hash = (text) => createHash("sha1").update(text).digest("hex").slice(0, 16);
245
+ /** Thrown by {@link createComposedBlock} when the id is already taken. */
246
+ var ComposedBlockExistsError = class ComposedBlockExistsError extends Error {
247
+ id;
248
+ constructor(id) {
249
+ super(`Composed block already exists: ${id}`);
250
+ this.id = id;
251
+ this.name = "ComposedBlockExistsError";
252
+ Object.setPrototypeOf(this, ComposedBlockExistsError.prototype);
253
+ }
254
+ };
255
+ /** List every composed block (summaries), skipping malformed files. */
256
+ function listComposedBlocks(mechDir) {
257
+ return loadComposedDefinitions(composedDirOf(mechDir)).map((def) => ({
258
+ id: def.id,
259
+ name: def.name,
260
+ icon: def.icon,
261
+ category: def.category
262
+ }));
263
+ }
264
+ /** Read one composed block plus its on-disk version, or `null` when absent. */
265
+ function readComposedBlock(mechDir, id) {
266
+ const file = fileOf(mechDir, id);
267
+ if (!fs.existsSync(file)) return null;
268
+ const text = fs.readFileSync(file, "utf-8");
269
+ return {
270
+ def: parseComposedBlock(text, id),
271
+ version: hash(text)
272
+ };
273
+ }
274
+ /** A composed block's current on-disk version (content hash), or `null`. */
275
+ function composedVersion(mechDir, id) {
276
+ const file = fileOf(mechDir, id);
277
+ if (!fs.existsSync(file)) return null;
278
+ return hash(fs.readFileSync(file, "utf-8"));
279
+ }
280
+ var writeDef = (mechDir, id, def) => {
281
+ const file = fileOf(mechDir, id);
282
+ writeFileAtomic(file, serializeComposedBlock({
283
+ ...def,
284
+ id
285
+ }));
286
+ return hash(fs.readFileSync(file, "utf-8"));
287
+ };
288
+ /** Create a new composed block; throws {@link ComposedBlockExistsError} if taken. */
289
+ function createComposedBlock(mechDir, def) {
290
+ if (fs.existsSync(fileOf(mechDir, def.id))) throw new ComposedBlockExistsError(def.id);
291
+ return { version: writeDef(mechDir, def.id, def) };
292
+ }
293
+ /** Overwrite a composed block's definition; returns the new on-disk version. */
294
+ function saveComposedBlock(mechDir, id, def) {
295
+ return { version: writeDef(mechDir, id, def) };
296
+ }
297
+ /** Delete a composed block. Returns whether it existed. */
298
+ function deleteComposedBlock(mechDir, id) {
299
+ const file = fileOf(mechDir, id);
300
+ if (!fs.existsSync(file)) return false;
301
+ fs.rmSync(file);
302
+ markMutated(file);
303
+ return true;
304
+ }
305
+ //#endregion
306
+ //#region src/server/editor-page.ts
307
+ /**
308
+ * Turn the site's `index.html` into an editable page: resolve the `{{ … }}`
309
+ * head placeholders against the page's data — the same pass the export runs —
310
+ * and inject `window.state` at the top of `<body>`, ahead of any module script.
311
+ * One function for every host: the dev server feeds it the source `index.html`,
312
+ * a hosted editor the built `dist/mechanica-editor/index.html`.
313
+ */
314
+ function renderEditablePage(html, state, options = {}) {
315
+ const templated = passDataToHTML(html, {
316
+ ...state.data,
317
+ site: {
318
+ url: options.site?.url,
319
+ name: options.site?.name
320
+ },
321
+ page: state.page
322
+ });
323
+ const inject = [
324
+ ...options.hostConfig ? [`<script>window.__MECHANICA_EDITOR__=${serializeState(options.hostConfig)}<\/script>`] : [],
325
+ `<script>window.state=${serializeState(state)}<\/script>`,
326
+ ...options.scripts ? [options.scripts] : []
327
+ ].join("\n");
328
+ return templated.replace("<body>", `<body>\n${inject}`);
329
+ }
330
+ /**
331
+ * The page template of an editor build (`mechanica build --editor`), or null
332
+ * when `distDir` holds none. Its sibling files in `dist/mechanica-editor/` are
333
+ * the static root of the editing origin.
334
+ */
335
+ function readEditorTemplate(distDir) {
336
+ const file = join(distDir, EDITOR_DIST_DIR, "index.html");
337
+ return fs.existsSync(file) ? fs.readFileSync(file, "utf-8") : null;
338
+ }
339
+ //#endregion
340
+ //#region src/server/service.ts
341
+ var json = (data, status = 200) => new Response(JSON.stringify(data), {
342
+ status,
343
+ headers: { "content-type": "application/json" }
344
+ });
345
+ /** Content types of the files a site stores — browsers don't sniff SVG or fonts. */
346
+ var MIME = {
347
+ ".png": "image/png",
348
+ ".jpg": "image/jpeg",
349
+ ".jpeg": "image/jpeg",
350
+ ".gif": "image/gif",
351
+ ".webp": "image/webp",
352
+ ".avif": "image/avif",
353
+ ".svg": "image/svg+xml",
354
+ ".ico": "image/x-icon",
355
+ ".mp4": "video/mp4",
356
+ ".webm": "video/webm",
357
+ ".mp3": "audio/mpeg",
358
+ ".pdf": "application/pdf",
359
+ ".json": "application/json",
360
+ ".txt": "text/plain; charset=utf-8",
361
+ ".woff": "font/woff",
362
+ ".woff2": "font/woff2"
363
+ };
364
+ /**
365
+ * Stream a file from `dir`, containing the resolved path so an encoded `..`
366
+ * (or an absolute path) cannot escape it.
367
+ */
368
+ function serveFile(dir, relative, headers = {}) {
369
+ let decoded;
370
+ try {
371
+ decoded = decodeURIComponent(relative);
372
+ } catch {
373
+ return new Response("Bad request", { status: 400 });
374
+ }
375
+ const file = resolve(dir, decoded);
376
+ if (file !== dir && !file.startsWith(dir + sep)) return new Response("Forbidden", { status: 403 });
377
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return new Response("Not found", { status: 404 });
378
+ const type = MIME[extname(file).toLowerCase()];
379
+ return new Response(Readable.toWeb(fs.createReadStream(file)), { headers: {
380
+ ...type ? { "content-type": type } : {},
381
+ ...headers
382
+ } });
383
+ }
384
+ /** A safe composed-block id — a filename component, never a path (no traversal). */
385
+ function isValidId(id) {
386
+ return /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(id);
387
+ }
388
+ /** Validate an incoming composed-block definition; returns a field-error map or null. */
389
+ function validateComposed(def) {
390
+ if (!def || typeof def !== "object") return { def: "Invalid definition" };
391
+ const d = def;
392
+ if (typeof d.id !== "string" || !isValidId(d.id)) return { id: "A valid id is required" };
393
+ if (typeof d.name !== "string" || d.name === "") return { name: "A name is required" };
394
+ if (d.template !== void 0 && !Array.isArray(d.template)) return { template: "template must be a list" };
395
+ return null;
396
+ }
397
+ /** Create the editor API over a site's `.mech` directory. */
398
+ function createEditorService(mechDir, options = {}) {
399
+ let configured = null;
400
+ let generatedIndex = null;
401
+ const currentSite = async () => {
402
+ if (!options.site) return null;
403
+ const manifest = typeof options.site === "function" ? await options.site() : options.site;
404
+ if (manifest !== configured) {
405
+ configureSite(mechDir, manifest);
406
+ generatedIndex = indexGeneratedPages(manifest);
407
+ configured = manifest;
408
+ }
409
+ return manifest;
410
+ };
411
+ const route = async (request) => {
412
+ const site = await currentSite();
413
+ const config = site?.locales ?? null;
414
+ const gen = site ? generatedIndex : null;
415
+ /** Resolve a `locale` query param to a variant code (undefined = base/default). */
416
+ const variantCode = (raw) => config && raw && raw !== config.default && config.all.includes(raw) ? raw : void 0;
417
+ const url = new URL(request.url);
418
+ const { pathname } = url;
419
+ const query = url.searchParams;
420
+ const method = request.method;
421
+ const body = () => request.json();
422
+ /** The rejection for a mutation targeting a file-less generated page, if it is one. */
423
+ const generatedGuard = (path) => path && gen?.logical.has(path) ? json({ error: "This page is generated and read-only" }, 409) : null;
424
+ if (pathname.startsWith("/assets/")) return serveFile(resolve(mechDir, "assets"), pathname.slice(8));
425
+ if (pathname.startsWith("/thumbs/")) return serveFile(resolve(mechDir, "thumbs"), pathname.slice(8), { "cache-control": "no-cache" });
426
+ if (pathname === "/images" && method === "GET") return json(listImages(mechDir));
427
+ if (pathname === "/folders" && method === "GET") return json(listFolders(mechDir));
428
+ if (pathname === "/query" && method === "GET") {
429
+ const key = query.get("q");
430
+ if (!key) return json({ error: "Missing query key" }, 400);
431
+ return json(await resolveDevQuery(mechDir, key, {
432
+ page: Number(query.get("page") ?? "") || void 0,
433
+ locale: variantCode(query.get("locale"))
434
+ }, gen?.pages));
435
+ }
436
+ if (pathname === "/upload" && method === "POST") {
437
+ const bytes = Buffer.from(await request.arrayBuffer());
438
+ const header = request.headers.get("x-file-name") ?? "file";
439
+ let name = header;
440
+ try {
441
+ name = decodeURIComponent(header);
442
+ } catch {}
443
+ if (request.headers.get("x-derived-asset")) return json(await saveDerivedAsset(mechDir, name, bytes));
444
+ return json(await saveUpload(mechDir, name, bytes));
445
+ }
446
+ if (pathname === "/state" && method === "GET") {
447
+ const pathParam = query.get("path");
448
+ if (!pathParam) return json({ error: "Missing path" }, 400);
449
+ const generated = gen?.byServed.get(pathParam);
450
+ if (generated) return json(buildGeneratedState(mechDir, generated, config));
451
+ return json(buildPageState(mechDir, pathParam, config));
452
+ }
453
+ if (pathname === "/pages" && method === "GET") return json(listPages(mechDir, {
454
+ ...config ? { locales: config } : {},
455
+ generated: gen?.pages
456
+ }));
457
+ if (pathname === "/blocks" && method === "GET") {
458
+ if (!site) return json({ error: "Block listing unavailable" }, 503);
459
+ return json(site.blocks.map(({ id, name, hidden }) => ({
460
+ id,
461
+ name,
462
+ hidden
463
+ })));
464
+ }
465
+ if (pathname === "/pages" && method === "DELETE") {
466
+ const pathParam = query.get("path");
467
+ if (!pathParam) return json({ error: "Missing path" }, 400);
468
+ return generatedGuard(pathParam) ?? json({ success: deletePage(mechDir, pathParam) });
469
+ }
470
+ if (pathname === "/pages/draft" && method === "POST") {
471
+ const pathParam = query.get("path");
472
+ if (!pathParam) return json({ error: "Missing path" }, 400);
473
+ const blocked = generatedGuard(pathParam);
474
+ if (blocked) return blocked;
475
+ const { draft } = await body();
476
+ setPageDraft(mechDir, pathParam, draft === true, variantCode(query.get("locale")));
477
+ return json({
478
+ success: true,
479
+ draft: draft === true
480
+ });
481
+ }
482
+ if (pathname === "/pages/translation" && method === "POST") {
483
+ const pathParam = query.get("path");
484
+ const locale = query.get("locale");
485
+ if (!pathParam || !locale) return json({ error: "Missing path or locale" }, 400);
486
+ const blocked = generatedGuard(pathParam);
487
+ if (blocked) return blocked;
488
+ if (!config || locale === config.default || !config.all.includes(locale)) return json({ error: "Unknown locale" }, 400);
489
+ try {
490
+ createTranslation(mechDir, pathParam, locale);
491
+ return json({ success: true });
492
+ } catch (error) {
493
+ if (error instanceof PageExistsError) return json({ error: "Translation already exists" }, 400);
494
+ throw error;
495
+ }
496
+ }
497
+ if (pathname === "/pages/translation" && method === "DELETE") {
498
+ const pathParam = query.get("path");
499
+ const locale = query.get("locale");
500
+ if (!pathParam || !locale) return json({ error: "Missing path or locale" }, 400);
501
+ return generatedGuard(pathParam) ?? json({ success: deleteTranslation(mechDir, pathParam, locale) });
502
+ }
503
+ if (pathname === "/pages/duplicate" && method === "POST") {
504
+ const pathParam = query.get("path");
505
+ if (!pathParam) return json({ error: "Missing path" }, 400);
506
+ const blocked = generatedGuard(pathParam);
507
+ if (blocked) return blocked;
508
+ const input = await body();
509
+ if (!input.path || !input.name) return json({ error: "path and name are required" }, 400);
510
+ try {
511
+ return json(duplicatePage(mechDir, pathParam, input));
512
+ } catch (error) {
513
+ if (error instanceof PageExistsError) return json({ error: { path: "Page already exists" } }, 400);
514
+ throw error;
515
+ }
516
+ }
517
+ if (pathname === "/pages" && method === "POST") {
518
+ const input = await body();
519
+ const pathParam = query.get("path");
520
+ if (pathParam) {
521
+ const blocked = generatedGuard(pathParam);
522
+ if (blocked) return blocked;
523
+ try {
524
+ const path = typeof input.path === "string" && input.path.trim() && input.path.trim() !== pathParam ? movePage(mechDir, pathParam, input.path).path : pathParam;
525
+ if (input.name) renamePage(mechDir, path, input.name);
526
+ return json({
527
+ success: true,
528
+ path
529
+ });
530
+ } catch (error) {
531
+ if (error instanceof PageExistsError) return json({ error: { path: "Page already exists" } }, 400);
532
+ throw error;
533
+ }
534
+ }
535
+ if (!input.path || !input.name) return json({ error: "path and name are required" }, 400);
536
+ if (config) {
537
+ const first = `${typeof input.folderId === "string" ? `${input.folderId}/` : ""}${String(input.path).trim()}`.replace(/^\/+/, "").split("/")[0];
538
+ if (first && first !== config.default && config.all.includes(first)) return json({ error: { path: `"/${first}" is reserved for the ${first} locale` } }, 400);
539
+ }
540
+ try {
541
+ return json(createPage(mechDir, input));
542
+ } catch (error) {
543
+ if (error instanceof PageExistsError) return json({ error: { path: "Page already exists" } }, 400);
544
+ throw error;
545
+ }
546
+ }
547
+ if (pathname === "/save" && method === "POST") {
548
+ const pathParam = query.get("path");
549
+ if (!pathParam) return json({ error: "Missing path" }, 400);
550
+ const blocked = generatedGuard(pathParam);
551
+ if (blocked) return blocked;
552
+ const locale = variantCode(query.get("locale"));
553
+ const save = await body();
554
+ if (!save.force && typeof save.version === "string") {
555
+ const current = pageVersion(mechDir, pathParam, locale);
556
+ if (current != null && current !== save.version) return json({
557
+ error: "conflict",
558
+ version: current
559
+ }, 409);
560
+ }
561
+ const folder = folderOf(mechDir, pathParam);
562
+ const version = savePage(mechDir, pathParam, {
563
+ content: save.content,
564
+ data: save.pageData ?? {},
565
+ layout: save.layout
566
+ }, locale);
567
+ const siteI18n = save.siteDataI18n ?? {};
568
+ const folderI18n = save.folderDataI18n ?? {};
569
+ if (locale) {
570
+ mergeSiteData(mechDir, save.siteData ?? {});
571
+ mergeFolderData(mechDir, folder, save.folderData ?? {});
572
+ mergeLocaleSiteData(mechDir, locale, siteI18n);
573
+ mergeLocaleFolderData(mechDir, locale, folder, folderI18n);
574
+ } else {
575
+ mergeSiteData(mechDir, {
576
+ ...save.siteData ?? {},
577
+ ...siteI18n
578
+ });
579
+ mergeFolderData(mechDir, folder, {
580
+ ...save.folderData ?? {},
581
+ ...folderI18n
582
+ });
583
+ }
584
+ return json({
585
+ success: true,
586
+ version
587
+ });
588
+ }
589
+ if (pathname === "/composed" && method === "GET") return json(listComposedBlocks(mechDir));
590
+ if (pathname === "/composed/get" && method === "GET") {
591
+ const id = query.get("id");
592
+ if (!id || !isValidId(id)) return json({ error: "Missing or invalid id" }, 400);
593
+ const result = readComposedBlock(mechDir, id);
594
+ return result ? json(result) : json({ error: "not found" }, 404);
595
+ }
596
+ if (pathname === "/composed/create" && method === "POST") {
597
+ const def = await body();
598
+ const invalid = validateComposed(def);
599
+ if (invalid) return json({ error: invalid }, 400);
600
+ if (site?.blocks.some((block) => block.id === def.id)) return json({ error: { id: "A block with this id already exists" } }, 400);
601
+ try {
602
+ return json({
603
+ success: true,
604
+ ...createComposedBlock(mechDir, def)
605
+ });
606
+ } catch (error) {
607
+ if (error instanceof ComposedBlockExistsError) return json({ error: { id: "A block with this id already exists" } }, 400);
608
+ throw error;
609
+ }
610
+ }
611
+ if (pathname === "/composed/save" && method === "POST") {
612
+ const id = query.get("id");
613
+ if (!id || !isValidId(id)) return json({ error: "Missing or invalid id" }, 400);
614
+ const save = await body();
615
+ const invalid = validateComposed(save.def);
616
+ if (invalid) return json({ error: invalid }, 400);
617
+ if (!save.force && typeof save.version === "string") {
618
+ const current = composedVersion(mechDir, id);
619
+ if (current != null && current !== save.version) return json({
620
+ error: "conflict",
621
+ version: current
622
+ }, 409);
623
+ }
624
+ return json({
625
+ success: true,
626
+ ...saveComposedBlock(mechDir, id, save.def)
627
+ });
628
+ }
629
+ if (pathname === "/composed/delete" && method === "POST") {
630
+ const id = query.get("id");
631
+ if (!id || !isValidId(id)) return json({ error: "Missing or invalid id" }, 400);
632
+ return json({ success: deleteComposedBlock(mechDir, id) });
633
+ }
634
+ return null;
635
+ };
636
+ return {
637
+ async handle(request) {
638
+ try {
639
+ return await route(request);
640
+ } catch (error) {
641
+ return json({ error: String(error) }, 500);
642
+ }
643
+ },
644
+ async page(urlPath) {
645
+ if (!options.editorHtml) return null;
646
+ const site = await currentSite();
647
+ const template = typeof options.editorHtml === "function" ? await options.editorHtml() : options.editorHtml;
648
+ const generated = site ? generatedIndex?.byServed.get(urlPath) : void 0;
649
+ const locales = site?.locales ?? null;
650
+ const html = renderEditablePage(template, generated ? buildGeneratedState(mechDir, generated, locales) : buildPageState(mechDir, urlPath, locales), {
651
+ site: site?.site,
652
+ hostConfig: options.hostConfig
653
+ });
654
+ return new Response(html, { headers: {
655
+ "content-type": "text/html; charset=utf-8",
656
+ "cache-control": "no-store"
657
+ } });
658
+ }
659
+ };
660
+ }
661
+ //#endregion
662
+ //#region src/server/node-adapter.ts
663
+ function readBody(req) {
664
+ return new Promise((resolve, reject) => {
665
+ const chunks = [];
666
+ req.on("data", (chunk) => chunks.push(chunk));
667
+ req.on("end", () => resolve(Buffer.concat(chunks)));
668
+ req.on("error", reject);
669
+ });
670
+ }
671
+ /**
672
+ * Mount an {@link EditorService} on a Node HTTP stack. Mount it under the API
673
+ * prefix (`app.use('/@mechanica', …)`) — connect strips the prefix, so
674
+ * `req.url` arrives relative, which is what the service expects. Routes the
675
+ * service doesn't own fall through to `next()`.
676
+ */
677
+ function toNodeMiddleware(service) {
678
+ return (req, res, next) => {
679
+ (async () => {
680
+ const method = req.method ?? "GET";
681
+ const headers = new Headers();
682
+ for (const [name, value] of Object.entries(req.headers)) if (value != null) headers.set(name, Array.isArray(value) ? value.join(", ") : value);
683
+ const hasBody = method !== "GET" && method !== "HEAD";
684
+ const request = new Request(new URL(req.url ?? "/", "http://localhost"), {
685
+ method,
686
+ headers,
687
+ body: hasBody ? new Uint8Array(await readBody(req)) : void 0
688
+ });
689
+ const response = await service.handle(request);
690
+ if (!response) return next();
691
+ res.statusCode = response.status;
692
+ response.headers.forEach((value, name) => res.setHeader(name, value));
693
+ if (response.body) for await (const chunk of response.body) res.write(chunk);
694
+ res.end();
695
+ })().catch(next);
696
+ };
697
+ }
698
+ //#endregion
699
+ export { buildGeneratedState as a, collectComposed as c, renderEditablePage as i, loadComposedDefinitions as l, createEditorService as n, buildPageState as o, readEditorTemplate as r, COMPOSED_EXT as s, toNodeMiddleware as t };