mechanica 2.1.0-beta.2 → 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 +1 -1
- package/dist/chunks/{editor-MStotuaz.js → editor-DoYHG0mS.js} +3 -6
- package/dist/chunks/{node-adapter-4eD9p0iP.js → node-adapter-CSZY47h6.js} +203 -137
- package/dist/chunks/{rich-text-codec--Nssj0Nn.js → rich-text-codec-D8JAuJIS.js} +460 -280
- package/dist/chunks/{site-DR_X2rzc.js → site-ONN3eU9_.js} +7 -4
- package/dist/cli.js +43 -3
- package/dist/composer.js +1 -1
- package/dist/editor.js +1 -1
- package/dist/export.js +5 -5
- package/dist/plugin.js +8 -17
- package/dist/server.js +5 -4
- package/dist/types/cli/export.d.ts +2 -2
- package/dist/types/cli/migrate.d.ts +17 -0
- package/dist/types/server/assets-store.d.ts +50 -25
- package/dist/types/server/composed-store.d.ts +13 -17
- package/dist/types/server/content-files.d.ts +54 -0
- package/dist/types/server/data-store.d.ts +10 -9
- package/dist/types/server/index.d.ts +5 -2
- package/dist/types/server/node-adapter.d.ts +7 -0
- package/dist/types/server/page-state.d.ts +17 -2
- package/dist/types/server/pages-store.d.ts +62 -37
- package/dist/types/server/query-dev.d.ts +3 -2
- package/dist/types/server/service.d.ts +22 -2
- package/dist/types/server/site.d.ts +2 -1
- package/package.json +2 -2
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
|
|
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,
|
|
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
|
-
|
|
671
|
-
|
|
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-
|
|
2
|
-
import { A as
|
|
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(
|
|
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(
|
|
56
|
+
if (pageVersion(mech, urlPath) != null) return null;
|
|
86
57
|
const basePath = match[1] || "/";
|
|
87
|
-
if (pageVersion(
|
|
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(
|
|
84
|
+
function buildPageState(mech, urlPath, config) {
|
|
110
85
|
const { locale, path: localePathStripped } = parseLocalePath(urlPath, config);
|
|
111
|
-
const variant = paginatedVariantOf(
|
|
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(
|
|
90
|
+
let page = readPage(mech, pagePath, localeCode);
|
|
116
91
|
let layout = page.layout;
|
|
117
92
|
if (localeCode) {
|
|
118
|
-
const hasTranslation = pageVersion(
|
|
119
|
-
const hasBase = pageVersion(
|
|
93
|
+
const hasTranslation = pageVersion(mech, pagePath, localeCode) != null;
|
|
94
|
+
const hasBase = pageVersion(mech, pagePath) != null;
|
|
120
95
|
if (!hasTranslation) {
|
|
121
|
-
page = readPage(
|
|
96
|
+
page = readPage(mech, pagePath);
|
|
122
97
|
fallback = true;
|
|
123
98
|
layout = page.layout;
|
|
124
99
|
} else if (hasBase) {
|
|
125
|
-
const base = readPage(
|
|
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(
|
|
146
|
-
fillImageMeta(mechDir, content);
|
|
120
|
+
fillContentDefaults(mech, content);
|
|
147
121
|
let baseContent;
|
|
148
|
-
if (localeCode && pageVersion(
|
|
149
|
-
const base = readPage(
|
|
150
|
-
fillContentDefaults(
|
|
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(
|
|
155
|
-
const siteData = readSiteData(
|
|
156
|
-
const folderData = readFolderData(
|
|
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(
|
|
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(
|
|
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(
|
|
168
|
+
function buildGeneratedState(mech, vp, config) {
|
|
196
169
|
const content = vp.content ?? [];
|
|
197
|
-
fillContentDefaults(
|
|
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(
|
|
201
|
-
const siteData = readSiteData(
|
|
202
|
-
const folderData = readFolderData(
|
|
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
|
-
|
|
241
|
-
|
|
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(
|
|
257
|
-
return
|
|
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
|
|
265
|
-
function readComposedBlock(
|
|
266
|
-
const
|
|
267
|
-
if (
|
|
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
|
|
275
|
-
function composedVersion(
|
|
276
|
-
const
|
|
277
|
-
|
|
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 = (
|
|
281
|
-
const
|
|
282
|
-
writeFileAtomic(file, serializeComposedBlock({
|
|
304
|
+
var writeDef = (mech, id, def) => {
|
|
305
|
+
const text = serializeComposedBlock({
|
|
283
306
|
...def,
|
|
284
307
|
id
|
|
285
|
-
})
|
|
286
|
-
|
|
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(
|
|
290
|
-
if (
|
|
291
|
-
return { version: writeDef(
|
|
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
|
|
294
|
-
function saveComposedBlock(
|
|
295
|
-
return { version: writeDef(
|
|
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(
|
|
299
|
-
|
|
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
|
-
/**
|
|
398
|
-
|
|
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(
|
|
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
|
|
425
|
-
if (pathname.startsWith("/thumbs/"))
|
|
426
|
-
|
|
427
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
451
|
-
return json(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
525
|
-
if (input.name) renamePage(
|
|
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(
|
|
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(
|
|
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(
|
|
562
|
-
|
|
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(
|
|
571
|
-
mergeFolderData(
|
|
572
|
-
mergeLocaleSiteData(
|
|
573
|
-
mergeLocaleFolderData(
|
|
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(
|
|
620
|
+
mergeSiteData(mech, {
|
|
576
621
|
...save.siteData ?? {},
|
|
577
622
|
...siteI18n
|
|
578
623
|
});
|
|
579
|
-
mergeFolderData(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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 {
|
|
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 };
|