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.
- package/README.md +4 -2
- package/bin/mechanica.js +3 -1
- package/dist/chunks/{editor-JV0Nhfw4.js → editor-MStotuaz.js} +276 -106
- package/dist/chunks/node-adapter-4eD9p0iP.js +699 -0
- package/dist/chunks/page-assets-DsJXmoDs.js +70 -0
- package/dist/chunks/{page-assets-CM_MCuoa.js → rich-text-codec--Nssj0Nn.js} +71 -117
- package/dist/chunks/site-DR_X2rzc.js +62 -0
- package/dist/cli.js +482 -618
- package/dist/composer.js +16 -65
- package/dist/editor.css +112 -112
- package/dist/editor.js +69 -123
- package/dist/export.js +583 -0
- package/dist/plugin.js +69 -656
- package/dist/server.js +4 -0
- package/dist/types/cli/build.d.ts +3 -1
- package/dist/types/cli/export.d.ts +31 -2
- package/dist/types/cli/platform/api.d.ts +33 -0
- package/dist/types/cli/platform/bundle-format.d.ts +40 -0
- package/dist/types/cli/platform/credentials.d.ts +11 -0
- package/dist/types/cli/platform/engine-version.d.ts +6 -0
- package/dist/types/cli/platform/git.d.ts +15 -0
- package/dist/types/cli/platform/link.d.ts +11 -0
- package/dist/types/cli/platform/login.d.ts +9 -0
- package/dist/types/cli/platform/push.d.ts +12 -0
- package/dist/types/cli/run-export.d.ts +7 -0
- package/dist/types/editor/lib/backend.d.ts +90 -0
- package/dist/types/editor/lib/page-list.d.ts +5 -20
- package/dist/types/{vite/dev → server}/assets-store.d.ts +2 -2
- package/dist/types/server/editor-page.d.ts +23 -0
- package/dist/types/server/index.d.ts +11 -0
- package/dist/types/server/node-adapter.d.ts +11 -0
- package/dist/types/{vite/dev → server}/pages-store.d.ts +5 -5
- package/dist/types/{vite → server}/rich-text-codec.d.ts +6 -0
- package/dist/types/server/service.d.ts +53 -0
- package/dist/types/server/site.d.ts +29 -0
- package/dist/types/vite/dev/middleware.d.ts +6 -28
- package/package.json +10 -2
- package/dist/types/cli/push.d.ts +0 -10
- /package/dist/types/{vite/dev → server}/composed-store.d.ts +0 -0
- /package/dist/types/{vite/dev → server}/data-store.d.ts +0 -0
- /package/dist/types/{vite/dev → server}/fs-utils.d.ts +0 -0
- /package/dist/types/{vite/dev → server}/image-preview.d.ts +0 -0
- /package/dist/types/{vite/dev → server}/page-state.d.ts +0 -0
- /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 };
|