mechanica 2.1.0-beta.1 → 2.1.0-beta.3

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/README.md CHANGED
@@ -192,7 +192,7 @@ title: Readable
192
192
  - A region runs until the next `@field`, child block or `:::`, and is taken as written, blank lines and Markdown included. It fills a `text` prop with that text, and a `richText` prop with its Markdown turned into rich text.
193
193
  - Blocks nest by placing fences inside a container's fence, and `slot=<name>` puts a child into a named slot (a container's children go either all to the default slot or all to named ones). Nothing is indented: every `:::` and `@field` starts at the beginning of its line, and a block is a child of whichever fence is still open. A container may close with `::: /<blockId>`, which reports a missing `:::` at the line where the fences stop pairing up.
194
194
  - Props are YAML, so quote a value that starts with `#` or contains `: `, as in `href: "#pricing"`. Inside a region nothing needs quoting; only a line that starts with `:::`, or consists of just `@name`, takes a leading backslash (`\:::`), and not even that inside a code fence.
195
- - An `image` prop points at an uploaded file: `photo: { src: /@mechanica/assets/team.jpg, alt: "Our team" }`.
195
+ - An `image` prop points at an uploaded file by its name in `.mech/assets/`: `photo: { src: /media/team.jpg, alt: "Our team" }` — the address the file has in dev and on the exported site (files uploaded through the editor get a content hash in their name).
196
196
  - Props left out take their schema defaults, so a hand-written page renders exactly like one made in the editor.
197
197
 
198
198
  With `npm run dev` running, edits you make to these files show up in the editor live, and the editor won't overwrite a file that changed on disk underneath it.
@@ -1,6 +1,6 @@
1
1
  import { a as renderCrop, c as useDialog, d as _plugin_vue_export_helper_default, i as readImageSize, l as VIcon_default, n as VDialog_default, r as analyzeImageFile, s as dialogKey, t as ImagePickerDialog_default } from "./ImagePickerDialog-DJGhtW3r.js";
2
2
  import { Fragment, Teleport, Transition, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createSlots, createTextVNode, createVNode, defineComponent, inject, mergeProps, nextTick, normalizeClass, normalizeStyle, onBeforeUnmount, onErrorCaptured, onMounted, openBlock, provide, reactive, ref, renderList, renderSlot, resolveDynamicComponent, shallowRef, toDisplayString, unref, vShow, watch, withCtx, withDirectives, withKeys, withModifiers } from "vue";
3
- import { EDITOR_API_BASE, UPLOADS_PREFIX, deepEqual, getDefaultValue } from "mechanica-shared";
3
+ import { EDITOR_API_BASE, deepEqual, getDefaultValue, uploadNameOf } from "mechanica-shared";
4
4
  import { unfoldSchema } from "compact-json-schema";
5
5
  import { TextEditor, uid } from "vuewrite";
6
6
  import { blocksToMarkdown, markdownToBlocks } from "vuewrite/markdown";
@@ -667,11 +667,8 @@ function derivativeName(src, rect, target) {
667
667
  }
668
668
  /** A readable, filename-safe stem for an asset src (`hero` from `…/hero.png`). */
669
669
  function baseName(src) {
670
- if (!src.startsWith(UPLOADS_PREFIX)) return "image";
671
- let file = src.slice(UPLOADS_PREFIX.length);
672
- try {
673
- file = decodeURIComponent(file);
674
- } catch {}
670
+ const file = uploadNameOf(src);
671
+ if (file == null) return "image";
675
672
  const dot = file.lastIndexOf(".");
676
673
  return (dot > 0 ? file.slice(0, dot) : file).replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "image";
677
674
  }
@@ -1,11 +1,11 @@
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";
1
+ import { n as configureSite, r as indexGeneratedPages } from "./site-ONN3eU9_.js";
2
+ import { A as pageVersion, C as harvestPageImages, D as movePage, E as listPages, G as listUploads, H as fsAssetStore, I as setPageDraft, L as translationsOf, M as renamePage, N as savePage, R as contentFilesOf, T as listFolders, U as isAssetName, _ as deletePage, a as folderOf, b as fillContentDefaults, c as mergeLocaleSiteData, f as readSiteData, g as createTranslation, h as createPage, j as readPage, l as mergeSiteData, m as PageExistsError, o as mergeFolderData, q as storeUpload, s as mergeLocaleFolderData, u as readFolderData, v as deleteTranslation, w as imageNamesOf, x as fillImageMeta, y as duplicatePage } from "./rich-text-codec-D8JAuJIS.js";
3
3
  import fs, { readFileSync, readdirSync } from "node:fs";
4
4
  import { basename, extname, join, resolve, sep } from "node:path";
5
5
  import { EDITOR_DIST_DIR, mergeTranslation, parseLocalePath, passDataToHTML, resolveQueryKey, serializeState } from "mechanica-shared";
6
6
  import { parseComposedBlock, serializeComposedBlock } from "mechanica-shared/block-format";
7
- import { Readable } from "node:stream";
8
7
  import { createHash } from "node:crypto";
8
+ import { Readable } from "node:stream";
9
9
  //#region src/vite/collect-composed.ts
10
10
  /** Filename suffix for composed-block documents under `<mech>/blocks`. */
11
11
  var COMPOSED_EXT = ".block.yml";
@@ -41,35 +41,6 @@ function collectComposed(dir, onError) {
41
41
  return `export const composedList = ${JSON.stringify(defs)}\n`;
42
42
  }
43
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
44
  //#region src/server/page-state.ts
74
45
  /**
75
46
  * Recognize a paginated variant URL (`/blog/2`): no page file of its own, a
@@ -77,14 +48,14 @@ function resolveDevQuery(mechDir, key, context = {}, generated) {
77
48
  * Checked against the default-locale (base) files — pagination structure is
78
49
  * defined by the logical page, and variant URLs never have their own file.
79
50
  */
80
- function paginatedVariantOf(mechDir, urlPath) {
51
+ function paginatedVariantOf(mech, urlPath) {
81
52
  const match = urlPath.match(/^(.*)\/(\d+)$/);
82
53
  if (!match) return null;
83
54
  const page = Number(match[2]);
84
55
  if (page < 2) return null;
85
- if (pageVersion(mechDir, urlPath) != null) return null;
56
+ if (pageVersion(mech, urlPath) != null) return null;
86
57
  const basePath = match[1] || "/";
87
- if (pageVersion(mechDir, basePath) == null) return null;
58
+ if (pageVersion(mech, basePath) == null) return null;
88
59
  return {
89
60
  basePath,
90
61
  page
@@ -101,28 +72,32 @@ function paginatedVariantOf(mechDir, urlPath) {
101
72
  * `page.pagination` set — `page.path` stays the base path, so editing the
102
73
  * variant edits (and saves to) the real page.
103
74
  *
75
+ * Image info (LQIP previews, dimensions) is not in the result — page files
76
+ * don't carry it and the asset store that does is asynchronous: follow up with
77
+ * {@link fillStateImages}.
78
+ *
104
79
  * A locale-prefixed URL (`/ru/about`) serves that page's translation: the
105
80
  * prefix is stripped to the logical path, `page.locale`/`page.locales` are set,
106
81
  * and the version targets the translation file. When no translation exists the
107
82
  * default-locale content renders as a fallback (`page.localeFallback`).
108
83
  */
109
- function buildPageState(mechDir, urlPath, config) {
84
+ function buildPageState(mech, urlPath, config) {
110
85
  const { locale, path: localePathStripped } = parseLocalePath(urlPath, config);
111
- const variant = paginatedVariantOf(mechDir, localePathStripped);
86
+ const variant = paginatedVariantOf(mech, localePathStripped);
112
87
  const pagePath = variant?.basePath ?? localePathStripped;
113
88
  const localeCode = !config || locale === config.default ? void 0 : locale;
114
89
  let fallback = false;
115
- let page = readPage(mechDir, pagePath, localeCode);
90
+ let page = readPage(mech, pagePath, localeCode);
116
91
  let layout = page.layout;
117
92
  if (localeCode) {
118
- const hasTranslation = pageVersion(mechDir, pagePath, localeCode) != null;
119
- const hasBase = pageVersion(mechDir, pagePath) != null;
93
+ const hasTranslation = pageVersion(mech, pagePath, localeCode) != null;
94
+ const hasBase = pageVersion(mech, pagePath) != null;
120
95
  if (!hasTranslation) {
121
- page = readPage(mechDir, pagePath);
96
+ page = readPage(mech, pagePath);
122
97
  fallback = true;
123
98
  layout = page.layout;
124
99
  } else if (hasBase) {
125
- const base = readPage(mechDir, pagePath);
100
+ const base = readPage(mech, pagePath);
126
101
  layout = base.layout;
127
102
  const merged = mergeTranslation({
128
103
  content: base.content ?? [],
@@ -142,18 +117,16 @@ function buildPageState(mechDir, urlPath, config) {
142
117
  }
143
118
  }
144
119
  const content = page.content ?? [];
145
- fillContentDefaults(mechDir, content);
146
- fillImageMeta(mechDir, content);
120
+ fillContentDefaults(mech, content);
147
121
  let baseContent;
148
- if (localeCode && pageVersion(mechDir, pagePath) != null) {
149
- const base = readPage(mechDir, pagePath).content ?? [];
150
- fillContentDefaults(mechDir, base);
151
- fillImageMeta(mechDir, base);
122
+ if (localeCode && pageVersion(mech, pagePath) != null) {
123
+ const base = readPage(mech, pagePath).content ?? [];
124
+ fillContentDefaults(mech, base);
152
125
  baseContent = base;
153
126
  }
154
- const folder = folderOf(mechDir, pagePath);
155
- const siteData = readSiteData(mechDir, localeCode);
156
- const folderData = readFolderData(mechDir, folder, localeCode);
127
+ const folder = folderOf(mech, pagePath);
128
+ const siteData = readSiteData(mech, localeCode);
129
+ const folderData = readFolderData(mech, folder, localeCode);
157
130
  const pageData = page.data ?? {};
158
131
  const pageMeta = {
159
132
  path: pagePath,
@@ -163,7 +136,7 @@ function buildPageState(mechDir, urlPath, config) {
163
136
  if (variant) pageMeta.pagination = { page: variant.page };
164
137
  if (config) {
165
138
  pageMeta.locale = locale;
166
- pageMeta.locales = translationsOf(mechDir, pagePath, config);
139
+ pageMeta.locales = translationsOf(mech, pagePath, config);
167
140
  if (fallback) pageMeta.localeFallback = true;
168
141
  }
169
142
  return {
@@ -178,7 +151,7 @@ function buildPageState(mechDir, urlPath, config) {
178
151
  pageData,
179
152
  folder,
180
153
  page: pageMeta,
181
- version: pageVersion(mechDir, pagePath, localeCode),
154
+ version: pageVersion(mech, pagePath, localeCode),
182
155
  ...config ? { locales: config } : {},
183
156
  ...baseContent ? { baseContent } : {}
184
157
  };
@@ -192,14 +165,13 @@ function buildPageState(mechDir, urlPath, config) {
192
165
  * null and `generated` is set, so the editor treats it as read-only and never
193
166
  * queues a save against a file that doesn't exist.
194
167
  */
195
- function buildGeneratedState(mechDir, vp, config) {
168
+ function buildGeneratedState(mech, vp, config) {
196
169
  const content = vp.content ?? [];
197
- fillContentDefaults(mechDir, content);
198
- fillImageMeta(mechDir, content);
170
+ fillContentDefaults(mech, content);
199
171
  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);
172
+ const folder = folderOf(mech, vp.path);
173
+ const siteData = readSiteData(mech, localeCode);
174
+ const folderData = readFolderData(mech, folder, localeCode);
203
175
  const pageData = vp.data ?? {};
204
176
  const pageMeta = {
205
177
  path: vp.path,
@@ -227,6 +199,49 @@ function buildGeneratedState(mechDir, vp, config) {
227
199
  ...config ? { locales: config } : {}
228
200
  };
229
201
  }
202
+ /**
203
+ * Complete a page state with the image info its content refers to — previews
204
+ * and dimensions come from the asset store, the one lookup a state needs that
205
+ * is not a content file. Fills `content` and, on a translation, `baseContent`.
206
+ */
207
+ async function fillStateImages(mech, assets, state) {
208
+ if (!assets) return state;
209
+ const lists = [state.content, ...state.baseContent ? [state.baseContent] : []];
210
+ const names = [...new Set(lists.flatMap((content) => imageNamesOf(mech, content)))];
211
+ if (!names.length) return state;
212
+ const images = await assets.info(names);
213
+ for (const content of lists) fillImageMeta(mech, content, images);
214
+ return state;
215
+ }
216
+ //#endregion
217
+ //#region src/server/query-dev.ts
218
+ /**
219
+ * The dev server's {@link QuerySource}: pages come live from the `.mech`
220
+ * store; `useFetch` URLs are fetched server-side (no CORS, matching how the
221
+ * export resolves them at build time). `generated` pages (plugin `generatePages`)
222
+ * are merged into the page listing so `usePages()` sees them, like the export.
223
+ */
224
+ function devQuerySource(mech, generated) {
225
+ return {
226
+ listPages: (options) => listPages(mech, {
227
+ ...options,
228
+ generated
229
+ }),
230
+ fetchJson: async ({ url, ...init }) => {
231
+ const res = await fetch(url, init);
232
+ if (!res.ok) throw new Error(`${url} responded ${res.status}`);
233
+ return res.json();
234
+ }
235
+ };
236
+ }
237
+ /**
238
+ * Resolve a dev-mode query key against the local `.mech` store. Keys are of the
239
+ * form `"<type>.<json-args>"` (the same encoding the runtime composables use);
240
+ * `context.page` selects the chunk of a paginated query.
241
+ */
242
+ function resolveDevQuery(mech, key, context = {}, generated) {
243
+ return resolveQueryKey(devQuerySource(mech, generated), key, context);
244
+ }
230
245
  //#endregion
231
246
  //#region src/server/composed-store.ts
232
247
  /**
@@ -236,11 +251,9 @@ function buildGeneratedState(mechDir, vp, config) {
236
251
  * optimistic-concurrency saves (a 409 when the file changed under the editor).
237
252
  * See PLAN.md § 5.1.
238
253
  */
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}`);
254
+ /** The directory composed blocks live in, relative to the `.mech` root. */
255
+ var BLOCKS = "blocks";
256
+ var fileOf = (id) => `${BLOCKS}/${id}${COMPOSED_EXT}`;
244
257
  var hash = (text) => createHash("sha1").update(text).digest("hex").slice(0, 16);
245
258
  /** Thrown by {@link createComposedBlock} when the id is already taken. */
246
259
  var ComposedBlockExistsError = class ComposedBlockExistsError extends Error {
@@ -252,55 +265,62 @@ var ComposedBlockExistsError = class ComposedBlockExistsError extends Error {
252
265
  Object.setPrototypeOf(this, ComposedBlockExistsError.prototype);
253
266
  }
254
267
  };
268
+ /** Every composed-block definition of the site, in id order; a malformed file is skipped. */
269
+ function readComposedBlocks(mech) {
270
+ const files = contentFilesOf(mech);
271
+ const defs = [];
272
+ for (const file of files.list(BLOCKS).sort()) {
273
+ const name = file.slice(7);
274
+ if (name.includes("/") || !name.endsWith(".block.yml")) continue;
275
+ try {
276
+ defs.push(parseComposedBlock(files.read(file) ?? "", name.slice(0, -COMPOSED_EXT.length)));
277
+ } catch {}
278
+ }
279
+ return defs;
280
+ }
255
281
  /** List every composed block (summaries), skipping malformed files. */
256
- function listComposedBlocks(mechDir) {
257
- return loadComposedDefinitions(composedDirOf(mechDir)).map((def) => ({
282
+ function listComposedBlocks(mech) {
283
+ return readComposedBlocks(mech).map((def) => ({
258
284
  id: def.id,
259
285
  name: def.name,
260
286
  icon: def.icon,
261
287
  category: def.category
262
288
  }));
263
289
  }
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");
290
+ /** Read one composed block plus its version, or `null` when absent. */
291
+ function readComposedBlock(mech, id) {
292
+ const text = contentFilesOf(mech).read(fileOf(id));
293
+ if (text == null) return null;
269
294
  return {
270
295
  def: parseComposedBlock(text, id),
271
296
  version: hash(text)
272
297
  };
273
298
  }
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"));
299
+ /** A composed block's current version (content hash), or `null`. */
300
+ function composedVersion(mech, id) {
301
+ const text = contentFilesOf(mech).read(fileOf(id));
302
+ return text == null ? null : hash(text);
279
303
  }
280
- var writeDef = (mechDir, id, def) => {
281
- const file = fileOf(mechDir, id);
282
- writeFileAtomic(file, serializeComposedBlock({
304
+ var writeDef = (mech, id, def) => {
305
+ const text = serializeComposedBlock({
283
306
  ...def,
284
307
  id
285
- }));
286
- return hash(fs.readFileSync(file, "utf-8"));
308
+ });
309
+ contentFilesOf(mech).write(fileOf(id), text);
310
+ return hash(text);
287
311
  };
288
312
  /** 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) };
313
+ function createComposedBlock(mech, def) {
314
+ if (contentFilesOf(mech).has(fileOf(def.id))) throw new ComposedBlockExistsError(def.id);
315
+ return { version: writeDef(mech, def.id, def) };
292
316
  }
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) };
317
+ /** Overwrite a composed block's definition; returns the new version. */
318
+ function saveComposedBlock(mech, id, def) {
319
+ return { version: writeDef(mech, id, def) };
296
320
  }
297
321
  /** 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;
322
+ function deleteComposedBlock(mech, id) {
323
+ return contentFilesOf(mech).remove(fileOf(id));
304
324
  }
305
325
  //#endregion
306
326
  //#region src/server/editor-page.ts
@@ -394,15 +414,34 @@ function validateComposed(def) {
394
414
  if (d.template !== void 0 && !Array.isArray(d.template)) return { template: "template must be a list" };
395
415
  return null;
396
416
  }
397
- /** Create the editor API over a site's `.mech` directory. */
398
- function createEditorService(mechDir, options = {}) {
417
+ /**
418
+ * Create the editor API over a site's content: a `.mech` directory, or a set of
419
+ * content files (`ContentFiles`) a host assembled — then uploads, which need a
420
+ * directory, are not served.
421
+ */
422
+ function createEditorService(mech, options = {}) {
423
+ const mechDir = typeof mech === "string" ? mech : null;
424
+ const assets = options.assets ?? (mechDir ? fsAssetStore(mechDir) : null);
425
+ const asset = async (encoded) => {
426
+ let name;
427
+ try {
428
+ name = decodeURIComponent(encoded);
429
+ } catch {
430
+ return new Response("Bad request", { status: 400 });
431
+ }
432
+ if (!assets || !isAssetName(name)) return null;
433
+ const body = await assets.open(name);
434
+ if (!body) return null;
435
+ const type = MIME[extname(name).toLowerCase()];
436
+ return new Response(body, { headers: type ? { "content-type": type } : {} });
437
+ };
399
438
  let configured = null;
400
439
  let generatedIndex = null;
401
440
  const currentSite = async () => {
402
441
  if (!options.site) return null;
403
442
  const manifest = typeof options.site === "function" ? await options.site() : options.site;
404
443
  if (manifest !== configured) {
405
- configureSite(mechDir, manifest);
444
+ configureSite(mech, manifest);
406
445
  generatedIndex = indexGeneratedPages(manifest);
407
446
  configured = manifest;
408
447
  }
@@ -421,36 +460,39 @@ function createEditorService(mechDir, options = {}) {
421
460
  const body = () => request.json();
422
461
  /** The rejection for a mutation targeting a file-less generated page, if it is one. */
423
462
  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));
463
+ if (pathname.startsWith("/assets/")) return await asset(pathname.slice(8)) ?? new Response("Not found", { status: 404 });
464
+ if (pathname.startsWith("/thumbs/")) {
465
+ if (!mechDir) return new Response("Not found", { status: 404 });
466
+ return serveFile(resolve(mechDir, "thumbs"), pathname.slice(8), { "cache-control": "no-cache" });
467
+ }
468
+ if (pathname === "/images" && method === "GET") return json(assets ? await listUploads(assets) : []);
469
+ if (pathname === "/folders" && method === "GET") return json(listFolders(mech));
428
470
  if (pathname === "/query" && method === "GET") {
429
471
  const key = query.get("q");
430
472
  if (!key) return json({ error: "Missing query key" }, 400);
431
- return json(await resolveDevQuery(mechDir, key, {
473
+ return json(await resolveDevQuery(mech, key, {
432
474
  page: Number(query.get("page") ?? "") || void 0,
433
475
  locale: variantCode(query.get("locale"))
434
476
  }, gen?.pages));
435
477
  }
436
478
  if (pathname === "/upload" && method === "POST") {
437
- const bytes = Buffer.from(await request.arrayBuffer());
479
+ if (!assets) return json({ error: "No asset storage" }, 501);
480
+ const bytes = new Uint8Array(await request.arrayBuffer());
438
481
  const header = request.headers.get("x-file-name") ?? "file";
439
482
  let name = header;
440
483
  try {
441
484
  name = decodeURIComponent(header);
442
485
  } catch {}
443
- if (request.headers.get("x-derived-asset")) return json(await saveDerivedAsset(mechDir, name, bytes));
444
- return json(await saveUpload(mechDir, name, bytes));
486
+ return json(await storeUpload(assets, name, bytes, { derived: request.headers.has("x-derived-asset") }));
445
487
  }
446
488
  if (pathname === "/state" && method === "GET") {
447
489
  const pathParam = query.get("path");
448
490
  if (!pathParam) return json({ error: "Missing path" }, 400);
449
491
  const generated = gen?.byServed.get(pathParam);
450
- if (generated) return json(buildGeneratedState(mechDir, generated, config));
451
- return json(buildPageState(mechDir, pathParam, config));
492
+ const state = generated ? buildGeneratedState(mech, generated, config) : buildPageState(mech, pathParam, config);
493
+ return json(await fillStateImages(mech, assets, state));
452
494
  }
453
- if (pathname === "/pages" && method === "GET") return json(listPages(mechDir, {
495
+ if (pathname === "/pages" && method === "GET") return json(listPages(mech, {
454
496
  ...config ? { locales: config } : {},
455
497
  generated: gen?.pages
456
498
  }));
@@ -465,7 +507,7 @@ function createEditorService(mechDir, options = {}) {
465
507
  if (pathname === "/pages" && method === "DELETE") {
466
508
  const pathParam = query.get("path");
467
509
  if (!pathParam) return json({ error: "Missing path" }, 400);
468
- return generatedGuard(pathParam) ?? json({ success: deletePage(mechDir, pathParam) });
510
+ return generatedGuard(pathParam) ?? json({ success: deletePage(mech, pathParam) });
469
511
  }
470
512
  if (pathname === "/pages/draft" && method === "POST") {
471
513
  const pathParam = query.get("path");
@@ -473,7 +515,7 @@ function createEditorService(mechDir, options = {}) {
473
515
  const blocked = generatedGuard(pathParam);
474
516
  if (blocked) return blocked;
475
517
  const { draft } = await body();
476
- setPageDraft(mechDir, pathParam, draft === true, variantCode(query.get("locale")));
518
+ setPageDraft(mech, pathParam, draft === true, variantCode(query.get("locale")));
477
519
  return json({
478
520
  success: true,
479
521
  draft: draft === true
@@ -487,7 +529,7 @@ function createEditorService(mechDir, options = {}) {
487
529
  if (blocked) return blocked;
488
530
  if (!config || locale === config.default || !config.all.includes(locale)) return json({ error: "Unknown locale" }, 400);
489
531
  try {
490
- createTranslation(mechDir, pathParam, locale);
532
+ createTranslation(mech, pathParam, locale);
491
533
  return json({ success: true });
492
534
  } catch (error) {
493
535
  if (error instanceof PageExistsError) return json({ error: "Translation already exists" }, 400);
@@ -498,7 +540,7 @@ function createEditorService(mechDir, options = {}) {
498
540
  const pathParam = query.get("path");
499
541
  const locale = query.get("locale");
500
542
  if (!pathParam || !locale) return json({ error: "Missing path or locale" }, 400);
501
- return generatedGuard(pathParam) ?? json({ success: deleteTranslation(mechDir, pathParam, locale) });
543
+ return generatedGuard(pathParam) ?? json({ success: deleteTranslation(mech, pathParam, locale) });
502
544
  }
503
545
  if (pathname === "/pages/duplicate" && method === "POST") {
504
546
  const pathParam = query.get("path");
@@ -508,7 +550,7 @@ function createEditorService(mechDir, options = {}) {
508
550
  const input = await body();
509
551
  if (!input.path || !input.name) return json({ error: "path and name are required" }, 400);
510
552
  try {
511
- return json(duplicatePage(mechDir, pathParam, input));
553
+ return json(duplicatePage(mech, pathParam, input));
512
554
  } catch (error) {
513
555
  if (error instanceof PageExistsError) return json({ error: { path: "Page already exists" } }, 400);
514
556
  throw error;
@@ -521,8 +563,8 @@ function createEditorService(mechDir, options = {}) {
521
563
  const blocked = generatedGuard(pathParam);
522
564
  if (blocked) return blocked;
523
565
  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);
566
+ const path = typeof input.path === "string" && input.path.trim() && input.path.trim() !== pathParam ? movePage(mech, pathParam, input.path).path : pathParam;
567
+ if (input.name) renamePage(mech, path, input.name);
526
568
  return json({
527
569
  success: true,
528
570
  path
@@ -538,7 +580,7 @@ function createEditorService(mechDir, options = {}) {
538
580
  if (first && first !== config.default && config.all.includes(first)) return json({ error: { path: `"/${first}" is reserved for the ${first} locale` } }, 400);
539
581
  }
540
582
  try {
541
- return json(createPage(mechDir, input));
583
+ return json(createPage(mech, input));
542
584
  } catch (error) {
543
585
  if (error instanceof PageExistsError) return json({ error: { path: "Page already exists" } }, 400);
544
586
  throw error;
@@ -552,31 +594,34 @@ function createEditorService(mechDir, options = {}) {
552
594
  const locale = variantCode(query.get("locale"));
553
595
  const save = await body();
554
596
  if (!save.force && typeof save.version === "string") {
555
- const current = pageVersion(mechDir, pathParam, locale);
597
+ const current = pageVersion(mech, pathParam, locale);
556
598
  if (current != null && current !== save.version) return json({
557
599
  error: "conflict",
558
600
  version: current
559
601
  }, 409);
560
602
  }
561
- const folder = folderOf(mechDir, pathParam);
562
- const version = savePage(mechDir, pathParam, {
603
+ const folder = folderOf(mech, pathParam);
604
+ if (assets && Array.isArray(save.content)) await assets.saveInfo(harvestPageImages(mech, save.content));
605
+ const baseNames = assets && locale ? imageNamesOf(mech, readPage(mech, pathParam).content ?? []) : [];
606
+ const baseImages = assets && baseNames.length ? await assets.info(baseNames) : {};
607
+ const version = savePage(mech, pathParam, {
563
608
  content: save.content,
564
609
  data: save.pageData ?? {},
565
610
  layout: save.layout
566
- }, locale);
611
+ }, locale, baseImages);
567
612
  const siteI18n = save.siteDataI18n ?? {};
568
613
  const folderI18n = save.folderDataI18n ?? {};
569
614
  if (locale) {
570
- mergeSiteData(mechDir, save.siteData ?? {});
571
- mergeFolderData(mechDir, folder, save.folderData ?? {});
572
- mergeLocaleSiteData(mechDir, locale, siteI18n);
573
- mergeLocaleFolderData(mechDir, locale, folder, folderI18n);
615
+ mergeSiteData(mech, save.siteData ?? {});
616
+ mergeFolderData(mech, folder, save.folderData ?? {});
617
+ mergeLocaleSiteData(mech, locale, siteI18n);
618
+ mergeLocaleFolderData(mech, locale, folder, folderI18n);
574
619
  } else {
575
- mergeSiteData(mechDir, {
620
+ mergeSiteData(mech, {
576
621
  ...save.siteData ?? {},
577
622
  ...siteI18n
578
623
  });
579
- mergeFolderData(mechDir, folder, {
624
+ mergeFolderData(mech, folder, {
580
625
  ...save.folderData ?? {},
581
626
  ...folderI18n
582
627
  });
@@ -586,11 +631,11 @@ function createEditorService(mechDir, options = {}) {
586
631
  version
587
632
  });
588
633
  }
589
- if (pathname === "/composed" && method === "GET") return json(listComposedBlocks(mechDir));
634
+ if (pathname === "/composed" && method === "GET") return json(listComposedBlocks(mech));
590
635
  if (pathname === "/composed/get" && method === "GET") {
591
636
  const id = query.get("id");
592
637
  if (!id || !isValidId(id)) return json({ error: "Missing or invalid id" }, 400);
593
- const result = readComposedBlock(mechDir, id);
638
+ const result = readComposedBlock(mech, id);
594
639
  return result ? json(result) : json({ error: "not found" }, 404);
595
640
  }
596
641
  if (pathname === "/composed/create" && method === "POST") {
@@ -601,7 +646,7 @@ function createEditorService(mechDir, options = {}) {
601
646
  try {
602
647
  return json({
603
648
  success: true,
604
- ...createComposedBlock(mechDir, def)
649
+ ...createComposedBlock(mech, def)
605
650
  });
606
651
  } catch (error) {
607
652
  if (error instanceof ComposedBlockExistsError) return json({ error: { id: "A block with this id already exists" } }, 400);
@@ -615,7 +660,7 @@ function createEditorService(mechDir, options = {}) {
615
660
  const invalid = validateComposed(save.def);
616
661
  if (invalid) return json({ error: invalid }, 400);
617
662
  if (!save.force && typeof save.version === "string") {
618
- const current = composedVersion(mechDir, id);
663
+ const current = composedVersion(mech, id);
619
664
  if (current != null && current !== save.version) return json({
620
665
  error: "conflict",
621
666
  version: current
@@ -623,13 +668,13 @@ function createEditorService(mechDir, options = {}) {
623
668
  }
624
669
  return json({
625
670
  success: true,
626
- ...saveComposedBlock(mechDir, id, save.def)
671
+ ...saveComposedBlock(mech, id, save.def)
627
672
  });
628
673
  }
629
674
  if (pathname === "/composed/delete" && method === "POST") {
630
675
  const id = query.get("id");
631
676
  if (!id || !isValidId(id)) return json({ error: "Missing or invalid id" }, 400);
632
- return json({ success: deleteComposedBlock(mechDir, id) });
677
+ return json({ success: deleteComposedBlock(mech, id) });
633
678
  }
634
679
  return null;
635
680
  };
@@ -641,13 +686,14 @@ function createEditorService(mechDir, options = {}) {
641
686
  return json({ error: String(error) }, 500);
642
687
  }
643
688
  },
689
+ asset,
644
690
  async page(urlPath) {
645
691
  if (!options.editorHtml) return null;
646
692
  const site = await currentSite();
647
693
  const template = typeof options.editorHtml === "function" ? await options.editorHtml() : options.editorHtml;
648
694
  const generated = site ? generatedIndex?.byServed.get(urlPath) : void 0;
649
695
  const locales = site?.locales ?? null;
650
- const html = renderEditablePage(template, generated ? buildGeneratedState(mechDir, generated, locales) : buildPageState(mechDir, urlPath, locales), {
696
+ const html = renderEditablePage(template, await fillStateImages(mech, assets, generated ? buildGeneratedState(mech, generated, locales) : buildPageState(mech, urlPath, locales)), {
651
697
  site: site?.site,
652
698
  hostConfig: options.hostConfig
653
699
  });
@@ -688,12 +734,32 @@ function toNodeMiddleware(service) {
688
734
  });
689
735
  const response = await service.handle(request);
690
736
  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();
737
+ await send(res, response);
738
+ })().catch(next);
739
+ };
740
+ }
741
+ async function send(res, response) {
742
+ res.statusCode = response.status;
743
+ response.headers.forEach((value, name) => res.setHeader(name, value));
744
+ if (response.body) for await (const chunk of response.body) res.write(chunk);
745
+ res.end();
746
+ }
747
+ /**
748
+ * Serve a site's uploads on a Node HTTP stack. Mount it at the uploads prefix
749
+ * (`app.use('/media', …)`, see `UPLOADS_PREFIX`) — `req.url` then arrives as
750
+ * `/<name>`. A name that is not an upload falls through to `next()`, so files
751
+ * a site keeps in `public/media` are still served by whatever comes after.
752
+ */
753
+ function toAssetMiddleware(service) {
754
+ return (req, res, next) => {
755
+ if (req.method !== "GET" && req.method !== "HEAD") return next();
756
+ (async () => {
757
+ const name = (req.url ?? "/").split("?")[0].slice(1);
758
+ const response = name ? await service.asset(name) : null;
759
+ if (!response) return next();
760
+ await send(res, response);
695
761
  })().catch(next);
696
762
  };
697
763
  }
698
764
  //#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 };
765
+ export { renderEditablePage as a, fillStateImages as c, loadComposedDefinitions as d, readEditorTemplate as i, COMPOSED_EXT as l, toNodeMiddleware as n, buildGeneratedState as o, createEditorService as r, buildPageState as s, toAssetMiddleware as t, collectComposed as u };