mechanica 2.0.0-alpha.5 → 2.0.0-alpha.7
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/bin/mechanica.js +0 -0
- package/dist/cli.js +38 -0
- package/dist/editor.js +5 -1
- package/dist/plugin.js +93 -3
- package/dist/types/cli/export.d.ts +4 -1
- package/dist/types/vite/dev/middleware.d.ts +10 -1
- package/dist/types/vite/dev/page-state.d.ts +24 -1
- package/dist/types/vite/entries.d.ts +3 -0
- package/dist/types/vite/generate-pages.d.ts +35 -0
- package/dist/types/vite/index.d.ts +1 -0
- package/dist/types/vite/plugin.d.ts +9 -0
- package/package.json +2 -2
- package/src/cli/export.ts +53 -0
- package/src/editor/editor.ts +7 -1
- package/src/vite/dev/middleware.ts +15 -2
- package/src/vite/dev/page-state.ts +40 -1
- package/src/vite/entries.ts +4 -0
- package/src/vite/generate-pages.ts +51 -0
- package/src/vite/index.ts +1 -0
- package/src/vite/plugin.ts +55 -3
package/bin/mechanica.js
CHANGED
|
File without changes
|
package/dist/cli.js
CHANGED
|
@@ -204,6 +204,34 @@ function breadcrumbsFor(path, names) {
|
|
|
204
204
|
}));
|
|
205
205
|
}
|
|
206
206
|
/**
|
|
207
|
+
* Map a baked {@link VirtualPage} to an {@link ExportPage}. The provider emits
|
|
208
|
+
* one per (logical page × locale) and self-declares `locale`/`locales`, so the
|
|
209
|
+
* mapping just localizes the served `path` and carries the alternates — it never
|
|
210
|
+
* goes through the file-based `readTranslation`.
|
|
211
|
+
*/
|
|
212
|
+
function virtualToExportPage(v, config) {
|
|
213
|
+
const isDefault = !config || !v.locale || v.locale === config.default;
|
|
214
|
+
const locale = config ? v.locale ?? config.default : void 0;
|
|
215
|
+
const path = isDefault ? v.path : localePath(v.path, v.locale, config);
|
|
216
|
+
const name = typeof v.meta?.title === "string" ? v.meta.title : void 0;
|
|
217
|
+
return {
|
|
218
|
+
path,
|
|
219
|
+
logicalPath: v.path,
|
|
220
|
+
locale,
|
|
221
|
+
translations: v.locales,
|
|
222
|
+
content: v.content ?? [],
|
|
223
|
+
data: v.data ?? {},
|
|
224
|
+
name,
|
|
225
|
+
lastmod: v.lastmod,
|
|
226
|
+
page: {
|
|
227
|
+
meta: v.meta ?? {},
|
|
228
|
+
locale,
|
|
229
|
+
locales: v.locales,
|
|
230
|
+
path: v.path
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
207
235
|
* Load the pieces needed to preload per-page block chunks: the client build's
|
|
208
236
|
* Vite manifest and the plugin's block map. Older builds (or builds outside
|
|
209
237
|
* `mechanica build`) may not have them — links are simply skipped then.
|
|
@@ -328,6 +356,15 @@ async function exportProject(cwd, ssr, options = {}) {
|
|
|
328
356
|
}
|
|
329
357
|
for (const [locale, paths] of Object.entries(missing)) if (paths.length) warn(`[mechanica] locale "${locale}" is missing ${paths.length} translation(s) (rendered only in ${config.default}): ${paths.join(", ")}`);
|
|
330
358
|
}
|
|
359
|
+
if (ssr.generatedPages?.length) {
|
|
360
|
+
const taken = new Set(pages.map((page) => page.path));
|
|
361
|
+
for (const vp of ssr.generatedPages) {
|
|
362
|
+
const generated = virtualToExportPage(vp, config);
|
|
363
|
+
if (taken.has(generated.path)) throw new Error(`[mechanica] generatePages produced "${generated.path}", but a page already exists there — change the generated path or remove the conflicting page.`);
|
|
364
|
+
taken.add(generated.path);
|
|
365
|
+
pages.push(generated);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
331
368
|
setPageCodec(richText);
|
|
332
369
|
const querySource = {
|
|
333
370
|
listPages: (opts) => listPages(join(cwd, ".mech"), opts),
|
|
@@ -421,6 +458,7 @@ async function exportProject(cwd, ssr, options = {}) {
|
|
|
421
458
|
};
|
|
422
459
|
const templateHandlesPagination = index.includes("page.pagination");
|
|
423
460
|
const pageNames = new Map(defaultPages.map((page) => [page.logicalPath, page.name ?? (page.logicalPath === "/" ? "Home" : page.logicalPath.split("/").pop())]));
|
|
461
|
+
for (const vp of ssr.generatedPages ?? []) pageNames.set(vp.path, typeof vp.meta?.title === "string" ? vp.meta.title : vp.path.split("/").pop());
|
|
424
462
|
/** hreflang alternates for a translated page (each locale + x-default), at page N. */
|
|
425
463
|
const alternatesFor = (source, pageNum = 1) => {
|
|
426
464
|
if (!config || !source.translations || source.translations.length < 2) return void 0;
|
package/dist/editor.js
CHANGED
|
@@ -3007,6 +3007,7 @@ var savePath = () => {
|
|
|
3007
3007
|
};
|
|
3008
3008
|
var pageVersion = state.version ?? null;
|
|
3009
3009
|
var forceNextSave = false;
|
|
3010
|
+
var readOnly = state.generated === true;
|
|
3010
3011
|
var localizedIds = new Set(dataEntries.filter((entry) => entry.localized).map((entry) => entry.id));
|
|
3011
3012
|
/**
|
|
3012
3013
|
* Split the editable snapshot for the wire: shared site/folder data stays in
|
|
@@ -3072,6 +3073,7 @@ async function loadState(path) {
|
|
|
3072
3073
|
pageVersion = fresh.version ?? null;
|
|
3073
3074
|
pagePath = fresh.page?.path ?? path;
|
|
3074
3075
|
pageLocale = fresh.page?.locale;
|
|
3076
|
+
readOnly = fresh.generated === true;
|
|
3075
3077
|
externalState.value = fresh;
|
|
3076
3078
|
return true;
|
|
3077
3079
|
}
|
|
@@ -3152,7 +3154,9 @@ createApp(EditorApp_default, {
|
|
|
3152
3154
|
uploadFile,
|
|
3153
3155
|
uploadDerived,
|
|
3154
3156
|
listImages,
|
|
3155
|
-
onChange: (snapshot) =>
|
|
3157
|
+
onChange: (snapshot) => {
|
|
3158
|
+
if (!readOnly) saveQueue.push(snapshot);
|
|
3159
|
+
},
|
|
3156
3160
|
save: saveController,
|
|
3157
3161
|
externalState,
|
|
3158
3162
|
navigation
|
package/dist/plugin.js
CHANGED
|
@@ -2,7 +2,7 @@ import { A as renamePage, D as pageUrlOf, E as movePage, F as translationsOf, G
|
|
|
2
2
|
import fs, { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
3
|
import { basename, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { parseVueRequest } from "@vitejs/plugin-vue";
|
|
5
|
-
import { mergeTranslation, normalizeLocales, parseLocalePath, passDataToHTML, resolveQueryKey, serializeState } from "mechanica-shared";
|
|
5
|
+
import { localePath, mergeTranslation, normalizeLocales, parseLocalePath, passDataToHTML, resolveQueryKey, serializeState } from "mechanica-shared";
|
|
6
6
|
import { babelParse, parse as parse$1 } from "@vue/compiler-sfc";
|
|
7
7
|
import { walk } from "estree-walker";
|
|
8
8
|
import MagicString from "magic-string";
|
|
@@ -10,6 +10,18 @@ import { parseComposedBlock, serializeComposedBlock } from "mechanica-shared/blo
|
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
11
|
import { promises } from "fs";
|
|
12
12
|
import path from "path";
|
|
13
|
+
//#region src/vite/generate-pages.ts
|
|
14
|
+
/** Run every provider's `list` once and flatten the results. */
|
|
15
|
+
async function collectGeneratedPages(providers, ctx) {
|
|
16
|
+
if (!providers?.length) return [];
|
|
17
|
+
return (await Promise.all(providers.map((p) => (typeof p === "function" ? p : p.list)(ctx)))).flat();
|
|
18
|
+
}
|
|
19
|
+
/** The served (locale-prefixed) URL a generated page renders at. */
|
|
20
|
+
function generatedServedPath(page, config) {
|
|
21
|
+
if (!config || !page.locale || page.locale === config.default) return page.path;
|
|
22
|
+
return localePath(page.path, page.locale, config);
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
13
25
|
//#region src/compiler/compile-block.ts
|
|
14
26
|
/**
|
|
15
27
|
* The macro name authors call inside a block's `<script setup>`.
|
|
@@ -764,6 +776,7 @@ function generateSsrEntry(options) {
|
|
|
764
776
|
`registerComponents(blocksMap)`,
|
|
765
777
|
`export const site = ${JSON.stringify(options.site ?? {})}`,
|
|
766
778
|
`export const locales = ${JSON.stringify(options.locales ?? null)}`,
|
|
779
|
+
`export const generatedPages = ${JSON.stringify(options.generatedPages ?? [])}`,
|
|
767
780
|
`export const dataEntries = getDataEntries()`,
|
|
768
781
|
``,
|
|
769
782
|
`export async function render(state, context = {}) {`,
|
|
@@ -913,6 +926,49 @@ function buildPageState(mechDir, urlPath, config) {
|
|
|
913
926
|
...baseContent ? { baseContent } : {}
|
|
914
927
|
};
|
|
915
928
|
}
|
|
929
|
+
/**
|
|
930
|
+
* The dev state for a programmatically generated page ({@link VirtualPage}) —
|
|
931
|
+
* the file-free counterpart of {@link buildPageState}. It replicates the same
|
|
932
|
+
* shape (content with block-prop defaults + image meta filled, the
|
|
933
|
+
* site‹folder‹page data merge, and `page.locale`/`page.locales`), but reads its
|
|
934
|
+
* content/data/meta from the baked page rather than a `.page.md`. `version` is
|
|
935
|
+
* null and `generated` is set, so the editor treats it as read-only and never
|
|
936
|
+
* queues a save against a file that doesn't exist.
|
|
937
|
+
*/
|
|
938
|
+
function buildGeneratedState(mechDir, vp, config) {
|
|
939
|
+
const content = vp.content ?? [];
|
|
940
|
+
fillContentDefaults(content);
|
|
941
|
+
fillImageMeta(mechDir, content);
|
|
942
|
+
const localeCode = !config || !vp.locale || vp.locale === config.default ? void 0 : vp.locale;
|
|
943
|
+
const folder = folderOf(mechDir, vp.path);
|
|
944
|
+
const siteData = readSiteData(mechDir, localeCode);
|
|
945
|
+
const folderData = readFolderData(mechDir, folder, localeCode);
|
|
946
|
+
const pageData = vp.data ?? {};
|
|
947
|
+
const pageMeta = {
|
|
948
|
+
path: vp.path,
|
|
949
|
+
meta: vp.meta ?? {}
|
|
950
|
+
};
|
|
951
|
+
if (config) {
|
|
952
|
+
pageMeta.locale = vp.locale ?? config.default;
|
|
953
|
+
pageMeta.locales = vp.locales ?? [config.default];
|
|
954
|
+
}
|
|
955
|
+
return {
|
|
956
|
+
content,
|
|
957
|
+
data: {
|
|
958
|
+
...siteData,
|
|
959
|
+
...folderData,
|
|
960
|
+
...pageData
|
|
961
|
+
},
|
|
962
|
+
siteData,
|
|
963
|
+
folderData,
|
|
964
|
+
pageData,
|
|
965
|
+
folder,
|
|
966
|
+
page: pageMeta,
|
|
967
|
+
version: null,
|
|
968
|
+
generated: true,
|
|
969
|
+
...config ? { locales: config } : {}
|
|
970
|
+
};
|
|
971
|
+
}
|
|
916
972
|
//#endregion
|
|
917
973
|
//#region src/vite/dev/composed-store.ts
|
|
918
974
|
/**
|
|
@@ -1055,6 +1111,8 @@ function createDevMiddleware(mechDir, options = {}) {
|
|
|
1055
1111
|
const pathParam = query.get("path");
|
|
1056
1112
|
if (!pathParam) return json({ error: "Missing path" }, 400);
|
|
1057
1113
|
await options.ready?.();
|
|
1114
|
+
const generated = (await options.generated?.())?.byServed.get(pathParam);
|
|
1115
|
+
if (generated) return json(buildGeneratedState(mechDir, generated, config));
|
|
1058
1116
|
return json(buildPageState(mechDir, pathParam, config));
|
|
1059
1117
|
}
|
|
1060
1118
|
if (pathname === "/pages" && req.method === "GET") return json(listPages(mechDir, config ? { locales: config } : {}));
|
|
@@ -1137,6 +1195,7 @@ function createDevMiddleware(mechDir, options = {}) {
|
|
|
1137
1195
|
if (pathname === "/save" && req.method === "POST") {
|
|
1138
1196
|
const pathParam = query.get("path");
|
|
1139
1197
|
if (!pathParam) return json({ error: "Missing path" }, 400);
|
|
1198
|
+
if ((await options.generated?.())?.logical.has(pathParam)) return json({ error: "This page is generated and read-only" }, 409);
|
|
1140
1199
|
const locale = variantCode(query.get("locale"));
|
|
1141
1200
|
const body = JSON.parse((await readBody(req)).toString("utf-8"));
|
|
1142
1201
|
if (!body.force && typeof body.version === "string") {
|
|
@@ -1392,6 +1451,34 @@ function mechanica(options = {}) {
|
|
|
1392
1451
|
let locales = null;
|
|
1393
1452
|
let isClientBuild = false;
|
|
1394
1453
|
let lazyBlockFiles = null;
|
|
1454
|
+
let generatedPagesCache = null;
|
|
1455
|
+
const loadGeneratedPages = () => {
|
|
1456
|
+
if (!generatedPagesCache) {
|
|
1457
|
+
const ctx = {
|
|
1458
|
+
root,
|
|
1459
|
+
mechDir,
|
|
1460
|
+
locales
|
|
1461
|
+
};
|
|
1462
|
+
generatedPagesCache = collectGeneratedPages(options.generatePages, ctx);
|
|
1463
|
+
}
|
|
1464
|
+
return generatedPagesCache;
|
|
1465
|
+
};
|
|
1466
|
+
let generatedIndexCache = null;
|
|
1467
|
+
const loadGeneratedIndex = () => {
|
|
1468
|
+
if (!generatedIndexCache) generatedIndexCache = loadGeneratedPages().then((pages) => {
|
|
1469
|
+
const byServed = /* @__PURE__ */ new Map();
|
|
1470
|
+
const logical = /* @__PURE__ */ new Set();
|
|
1471
|
+
for (const page of pages) {
|
|
1472
|
+
byServed.set(generatedServedPath(page, locales), page);
|
|
1473
|
+
logical.add(page.path);
|
|
1474
|
+
}
|
|
1475
|
+
return {
|
|
1476
|
+
byServed,
|
|
1477
|
+
logical
|
|
1478
|
+
};
|
|
1479
|
+
});
|
|
1480
|
+
return generatedIndexCache;
|
|
1481
|
+
};
|
|
1395
1482
|
const blockSchemas = /* @__PURE__ */ new Map();
|
|
1396
1483
|
const blockChunkNames = /* @__PURE__ */ new Map();
|
|
1397
1484
|
const bundleBlocks = (options.blockChunks ?? "bundled") === "bundled";
|
|
@@ -1577,7 +1664,8 @@ function mechanica(options = {}) {
|
|
|
1577
1664
|
url: options.siteUrl,
|
|
1578
1665
|
name: options.siteName
|
|
1579
1666
|
},
|
|
1580
|
-
locales
|
|
1667
|
+
locales,
|
|
1668
|
+
generatedPages: await loadGeneratedPages()
|
|
1581
1669
|
});
|
|
1582
1670
|
if (id === RESOLVED_PREVIEW_ID) return generatePreviewEntry({ userEntry });
|
|
1583
1671
|
if (id === RESOLVED_WIDGETS_ID) return collectWidgets(widgetsDir, (p) => this.resolve(p));
|
|
@@ -1611,6 +1699,7 @@ function mechanica(options = {}) {
|
|
|
1611
1699
|
server.middlewares.use("/@mechanica/composer", createComposerMiddleware());
|
|
1612
1700
|
server.middlewares.use("/@mechanica", createDevMiddleware(mechDir, {
|
|
1613
1701
|
locales,
|
|
1702
|
+
generated: () => loadGeneratedIndex(),
|
|
1614
1703
|
ready: () => ensurePageCodec(server),
|
|
1615
1704
|
blocks: async () => {
|
|
1616
1705
|
const compiled = ((await server.ssrLoadModule("virtual:mechanica/blocks")).blocksList ?? []).map((component) => {
|
|
@@ -1700,7 +1789,8 @@ function mechanica(options = {}) {
|
|
|
1700
1789
|
if (/\.\w+$/.test(urlPath)) return html;
|
|
1701
1790
|
const withEditor = !url.searchParams.has("mechanica-shot");
|
|
1702
1791
|
if (ctx.server) await ensurePageCodec(ctx.server);
|
|
1703
|
-
const
|
|
1792
|
+
const generated = (await loadGeneratedIndex()).byServed.get(urlPath);
|
|
1793
|
+
const state = generated ? buildGeneratedState(mechDir, generated, locales) : buildPageState(mechDir, urlPath, locales);
|
|
1704
1794
|
const inject = [
|
|
1705
1795
|
`<script>window.state=${serializeState(state)}<\/script>`,
|
|
1706
1796
|
`<script type="module">`,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ComposedBlockDefinition, type LocalesConfig, type RenderResult } from 'mechanica-shared';
|
|
1
|
+
import { type ComposedBlockDefinition, type LocalesConfig, type RenderResult, type VirtualPage } from 'mechanica-shared';
|
|
2
2
|
import { type BlockComponent } from '../editor/lib/block-meta';
|
|
3
3
|
/** An already-built SSR bundle (`dist/ssr.js`) exposes these. */
|
|
4
4
|
export interface SsrBundle {
|
|
@@ -24,6 +24,9 @@ export interface SsrBundle {
|
|
|
24
24
|
};
|
|
25
25
|
/** The site's locale config baked in from the plugin's `locales` option. */
|
|
26
26
|
locales?: LocalesConfig | null;
|
|
27
|
+
/** Programmatically generated pages (plugin `generatePages`), baked as plain
|
|
28
|
+
* data at build so they render without a `.page.md` file. */
|
|
29
|
+
generatedPages?: VirtualPage[];
|
|
27
30
|
}
|
|
28
31
|
export interface ExportOptions {
|
|
29
32
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Connect } from 'vite';
|
|
2
|
-
import type { LocalesConfig } from 'mechanica-shared';
|
|
2
|
+
import type { LocalesConfig, VirtualPage } from 'mechanica-shared';
|
|
3
3
|
/** What `/blocks` reports per block — enough for the thumbs CLI to walk them. */
|
|
4
4
|
export interface BlockListing {
|
|
5
5
|
id: string;
|
|
@@ -13,6 +13,15 @@ export interface DevMiddlewareOptions {
|
|
|
13
13
|
blocks?: () => Promise<BlockListing[]> | BlockListing[];
|
|
14
14
|
/** The site's locale config (multi-language sites); null/absent = i18n off. */
|
|
15
15
|
locales?: LocalesConfig | null;
|
|
16
|
+
/**
|
|
17
|
+
* Generated routes (plugin `generatePages`): a served-path → page map (for
|
|
18
|
+
* `/state`) and the set of their logical paths (for the read-only `/save`
|
|
19
|
+
* guard). Absent when no providers are configured.
|
|
20
|
+
*/
|
|
21
|
+
generated?: () => Promise<{
|
|
22
|
+
byServed: Map<string, VirtualPage>;
|
|
23
|
+
logical: Set<string>;
|
|
24
|
+
}>;
|
|
16
25
|
}
|
|
17
26
|
/**
|
|
18
27
|
* The `/@mechanica` dev middleware: page CRUD, asset upload/serve, folder list,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type LocalesConfig, type PageMeta } from 'mechanica-shared';
|
|
1
|
+
import { type LocalesConfig, type PageMeta, type VirtualPage } from 'mechanica-shared';
|
|
2
2
|
/**
|
|
3
3
|
* The dev state for a page: effective data resolved site < folder < page, plus
|
|
4
4
|
* the scope buckets the editor edits, and the page's on-disk version for
|
|
@@ -29,3 +29,26 @@ export declare function buildPageState(mechDir: string, urlPath: string, config?
|
|
|
29
29
|
page: PageMeta;
|
|
30
30
|
version: string | null;
|
|
31
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* The dev state for a programmatically generated page ({@link VirtualPage}) —
|
|
34
|
+
* the file-free counterpart of {@link buildPageState}. It replicates the same
|
|
35
|
+
* shape (content with block-prop defaults + image meta filled, the
|
|
36
|
+
* site‹folder‹page data merge, and `page.locale`/`page.locales`), but reads its
|
|
37
|
+
* content/data/meta from the baked page rather than a `.page.md`. `version` is
|
|
38
|
+
* null and `generated` is set, so the editor treats it as read-only and never
|
|
39
|
+
* queues a save against a file that doesn't exist.
|
|
40
|
+
*/
|
|
41
|
+
export declare function buildGeneratedState(mechDir: string, vp: VirtualPage, config?: LocalesConfig | null): {
|
|
42
|
+
locales?: LocalesConfig | undefined;
|
|
43
|
+
content: import("mechanica-shared").ContentBlock[];
|
|
44
|
+
data: {
|
|
45
|
+
[x: string]: unknown;
|
|
46
|
+
};
|
|
47
|
+
siteData: Record<string, unknown>;
|
|
48
|
+
folderData: Record<string, unknown>;
|
|
49
|
+
pageData: Record<string, unknown>;
|
|
50
|
+
folder: string | null;
|
|
51
|
+
page: PageMeta;
|
|
52
|
+
version: null;
|
|
53
|
+
generated: boolean;
|
|
54
|
+
};
|
|
@@ -61,6 +61,9 @@ export interface SsrEntryOptions {
|
|
|
61
61
|
/** The site's locale config (multi-language) — rides the bundle so the export
|
|
62
62
|
* can walk translations, and gets baked into each page's `state.locales`. */
|
|
63
63
|
locales?: import('mechanica-shared').LocalesConfig | null;
|
|
64
|
+
/** Programmatically generated pages (plugin `generatePages`), run at build and
|
|
65
|
+
* baked as plain data so `mechanica export` renders them without a file. */
|
|
66
|
+
generatedPages?: import('mechanica-shared').VirtualPage[];
|
|
64
67
|
}
|
|
65
68
|
/**
|
|
66
69
|
* Generate the SSR entry. Exposes the block list, data entries and a `render`
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type LocalesConfig, type VirtualPage } from 'mechanica-shared';
|
|
2
|
+
/**
|
|
3
|
+
* Programmatic route generation (`generatePages`).
|
|
4
|
+
*
|
|
5
|
+
* A provider turns some source — a glob of Markdown, a CMS, anything — into a
|
|
6
|
+
* list of {@link VirtualPage}s: pages with no `.page.md` file, rendered through
|
|
7
|
+
* the normal pipeline. The plugin runs `list` at build and bakes the output
|
|
8
|
+
* into the SSR bundle for the static export (see `generateSsrEntry`), and runs
|
|
9
|
+
* it at dev-server start into a route map.
|
|
10
|
+
*
|
|
11
|
+
* These are build-side types (a provider gets fs paths) — they live with the
|
|
12
|
+
* plugin options, not in the `mechanica-shared` barrel the client imports.
|
|
13
|
+
* `resolve`/`revalidate` are reserved for the future render backend (render one
|
|
14
|
+
* path on a webhook without enumerating the whole source); Phase 1 uses `list`.
|
|
15
|
+
*/
|
|
16
|
+
export interface PageProviderCtx {
|
|
17
|
+
/** The Vite project root (absolute). */
|
|
18
|
+
root: string;
|
|
19
|
+
/** The project's `.mech` directory (absolute). */
|
|
20
|
+
mechDir: string;
|
|
21
|
+
/** The site's locale config, or null when i18n is off. */
|
|
22
|
+
locales: LocalesConfig | null;
|
|
23
|
+
}
|
|
24
|
+
export type PageProvider = ((ctx: PageProviderCtx) => VirtualPage[] | Promise<VirtualPage[]>) | {
|
|
25
|
+
/** Enumerate every page (static export, sitemap, dev route map). */
|
|
26
|
+
list: (ctx: PageProviderCtx) => VirtualPage[] | Promise<VirtualPage[]>;
|
|
27
|
+
/** Reserved: render one path without enumerating — backend ISR / lazy dev. */
|
|
28
|
+
resolve?: (path: string, ctx: PageProviderCtx) => VirtualPage | null | Promise<VirtualPage | null>;
|
|
29
|
+
/** Reserved: cache/revalidation hint for the backend. */
|
|
30
|
+
revalidate?: number | ((page: VirtualPage) => number);
|
|
31
|
+
};
|
|
32
|
+
/** Run every provider's `list` once and flatten the results. */
|
|
33
|
+
export declare function collectGeneratedPages(providers: PageProvider[] | undefined, ctx: PageProviderCtx): Promise<VirtualPage[]>;
|
|
34
|
+
/** The served (locale-prefixed) URL a generated page renders at. */
|
|
35
|
+
export declare function generatedServedPath(page: VirtualPage, config: LocalesConfig | null): string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { mechanica, BLOCKS_MODULE_ID, CLIENT_MODULE_ID, WIDGETS_MODULE_ID, type MechanicaPluginOptions, } from './plugin';
|
|
2
|
+
export { type PageProvider, type PageProviderCtx } from './generate-pages';
|
|
2
3
|
export { default as svgGlob } from '../svg-plugin';
|
|
3
4
|
export { collectBlocks } from './collect-blocks';
|
|
4
5
|
export { collectWidgets } from './collect-widgets';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Plugin } from 'vite';
|
|
2
|
+
import { type PageProvider } from './generate-pages';
|
|
2
3
|
/** Virtual module exposing the collected block components. */
|
|
3
4
|
export declare const BLOCKS_MODULE_ID = "virtual:mechanica/blocks";
|
|
4
5
|
/** Virtual module that mounts the user's app (generated client entry). */
|
|
@@ -58,6 +59,14 @@ export interface MechanicaPluginOptions {
|
|
|
58
59
|
* locale) = i18n off — nothing changes for existing sites.
|
|
59
60
|
*/
|
|
60
61
|
locales?: import('mechanica-shared').LocalesOption;
|
|
62
|
+
/**
|
|
63
|
+
* Programmatic route generation. Each provider returns a list of pages built
|
|
64
|
+
* from any source (a content glob, a CMS, …) — rendered through the normal
|
|
65
|
+
* pipeline with no `.page.md` file per route. Providers run at build (their
|
|
66
|
+
* output is baked into the static export) and at dev-server start; generated
|
|
67
|
+
* pages are read-only. See GENERATED-PAGES.md.
|
|
68
|
+
*/
|
|
69
|
+
generatePages?: PageProvider[];
|
|
61
70
|
/**
|
|
62
71
|
* How the production client build chunks block code.
|
|
63
72
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mechanica",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.7",
|
|
4
4
|
"description": "Build Vue 3 websites with a visual block editor",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"prepublishOnly": "bun run build"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"mechanica-shared": "
|
|
66
|
+
"mechanica-shared": "2.0.0-alpha.4",
|
|
67
67
|
"@vue/compiler-sfc": "^3.5.38",
|
|
68
68
|
"compact-json-schema": "^0.1.5",
|
|
69
69
|
"estree-walker": "^3.0.3",
|
package/src/cli/export.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type QuerySource,
|
|
26
26
|
type RenderResult,
|
|
27
27
|
type SitemapEntry,
|
|
28
|
+
type VirtualPage,
|
|
28
29
|
} from 'mechanica-shared'
|
|
29
30
|
import { parsePage, type RichTextCodec } from 'mechanica-shared/page-format'
|
|
30
31
|
import { toBlockMeta, composedBlockMeta, type BlockComponent } from '../editor/lib/block-meta'
|
|
@@ -228,6 +229,34 @@ export interface SsrBundle {
|
|
|
228
229
|
site?: { url?: string; name?: string }
|
|
229
230
|
/** The site's locale config baked in from the plugin's `locales` option. */
|
|
230
231
|
locales?: LocalesConfig | null
|
|
232
|
+
/** Programmatically generated pages (plugin `generatePages`), baked as plain
|
|
233
|
+
* data at build so they render without a `.page.md` file. */
|
|
234
|
+
generatedPages?: VirtualPage[]
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Map a baked {@link VirtualPage} to an {@link ExportPage}. The provider emits
|
|
239
|
+
* one per (logical page × locale) and self-declares `locale`/`locales`, so the
|
|
240
|
+
* mapping just localizes the served `path` and carries the alternates — it never
|
|
241
|
+
* goes through the file-based `readTranslation`.
|
|
242
|
+
*/
|
|
243
|
+
function virtualToExportPage(v: VirtualPage, config: LocalesConfig | null): ExportPage {
|
|
244
|
+
const isDefault = !config || !v.locale || v.locale === config.default
|
|
245
|
+
const locale = config ? (v.locale ?? config.default) : undefined
|
|
246
|
+
const path = isDefault ? v.path : localePath(v.path, v.locale!, config!)
|
|
247
|
+
const name = typeof v.meta?.title === 'string' ? v.meta.title : undefined
|
|
248
|
+
return {
|
|
249
|
+
path,
|
|
250
|
+
logicalPath: v.path,
|
|
251
|
+
locale,
|
|
252
|
+
translations: v.locales,
|
|
253
|
+
content: v.content ?? [],
|
|
254
|
+
data: v.data ?? {},
|
|
255
|
+
name,
|
|
256
|
+
lastmod: v.lastmod,
|
|
257
|
+
// `state.page.path` stays the logical path so `<Link>` prefixes it per locale.
|
|
258
|
+
page: { meta: v.meta ?? {}, locale, locales: v.locales, path: v.path },
|
|
259
|
+
}
|
|
231
260
|
}
|
|
232
261
|
|
|
233
262
|
export interface ExportOptions {
|
|
@@ -421,6 +450,24 @@ export async function exportProject(
|
|
|
421
450
|
}
|
|
422
451
|
}
|
|
423
452
|
|
|
453
|
+
// Programmatically generated routes (plugin `generatePages`), baked into the
|
|
454
|
+
// SSR bundle as plain data. They self-declare locale/translations, so they
|
|
455
|
+
// join after the file-based i18n pass — never through `readTranslation`.
|
|
456
|
+
if (ssr.generatedPages?.length) {
|
|
457
|
+
const taken = new Set(pages.map((page) => page.path))
|
|
458
|
+
for (const vp of ssr.generatedPages) {
|
|
459
|
+
const generated = virtualToExportPage(vp, config)
|
|
460
|
+
if (taken.has(generated.path)) {
|
|
461
|
+
throw new Error(
|
|
462
|
+
`[mechanica] generatePages produced "${generated.path}", but a page already exists ` +
|
|
463
|
+
`there — change the generated path or remove the conflicting page.`,
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
taken.add(generated.path)
|
|
467
|
+
pages.push(generated)
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
424
471
|
// Queries (`usePages`/`usePagination`/`useFetch`) resolve at build time
|
|
425
472
|
// against the same `.mech` store, memoized per (page-number, key) across the
|
|
426
473
|
// whole export — a nav query shared by 100 pages resolves once. The codec is
|
|
@@ -554,6 +601,12 @@ export async function exportProject(
|
|
|
554
601
|
page.name ?? (page.logicalPath === '/' ? 'Home' : page.logicalPath.split('/').pop()!),
|
|
555
602
|
]),
|
|
556
603
|
)
|
|
604
|
+
// Generated pages contribute their own crumb (and any generated ancestor like
|
|
605
|
+
// `/docs/api`) so the BreadcrumbList JSON-LD includes the full trail; keyed by
|
|
606
|
+
// logical path, so the per-locale variants collapse to one entry.
|
|
607
|
+
for (const vp of ssr.generatedPages ?? []) {
|
|
608
|
+
pageNames.set(vp.path, typeof vp.meta?.title === 'string' ? vp.meta.title : vp.path.split('/').pop()!)
|
|
609
|
+
}
|
|
557
610
|
|
|
558
611
|
/** hreflang alternates for a translated page (each locale + x-default), at page N. */
|
|
559
612
|
const alternatesFor = (source: ExportPage, pageNum = 1): { hreflang: string; path: string }[] | undefined => {
|
package/src/editor/editor.ts
CHANGED
|
@@ -45,6 +45,9 @@ const savePath = () => {
|
|
|
45
45
|
// editor never silently clobbers e.g. Claude's edits to the .page.md.
|
|
46
46
|
let pageVersion: string | null = (state as { version?: string | null }).version ?? null
|
|
47
47
|
let forceNextSave = false
|
|
48
|
+
// A programmatically generated page (plugin `generatePages`) has no file — the
|
|
49
|
+
// editor renders it but never queues a save (the dev server would reject it).
|
|
50
|
+
let readOnly = state.generated === true
|
|
48
51
|
|
|
49
52
|
// `localized` data entries: their site/folder value is translated per locale, so
|
|
50
53
|
// the save payload routes them to separate buckets the dev server writes to the
|
|
@@ -125,6 +128,7 @@ async function loadState(path: string): Promise<boolean> {
|
|
|
125
128
|
pageVersion = fresh.version ?? null
|
|
126
129
|
pagePath = fresh.page?.path ?? path
|
|
127
130
|
pageLocale = fresh.page?.locale
|
|
131
|
+
readOnly = fresh.generated === true
|
|
128
132
|
externalState.value = fresh
|
|
129
133
|
return true
|
|
130
134
|
}
|
|
@@ -234,7 +238,9 @@ createApp(EditorApp, {
|
|
|
234
238
|
uploadFile,
|
|
235
239
|
uploadDerived,
|
|
236
240
|
listImages,
|
|
237
|
-
onChange: (snapshot: EditorSnapshot) =>
|
|
241
|
+
onChange: (snapshot: EditorSnapshot) => {
|
|
242
|
+
if (!readOnly) saveQueue.push(snapshot)
|
|
243
|
+
},
|
|
238
244
|
save: saveController,
|
|
239
245
|
externalState,
|
|
240
246
|
navigation,
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
deleteTranslation,
|
|
17
17
|
PageExistsError,
|
|
18
18
|
} from './pages-store'
|
|
19
|
-
import type { LocalesConfig } from 'mechanica-shared'
|
|
19
|
+
import type { LocalesConfig, VirtualPage } from 'mechanica-shared'
|
|
20
20
|
import { saveUpload, saveDerivedAsset, listImages } from './assets-store'
|
|
21
21
|
import { resolveDevQuery } from './query-dev'
|
|
22
22
|
import {
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
mergeLocaleFolderData,
|
|
27
27
|
folderOf,
|
|
28
28
|
} from './data-store'
|
|
29
|
-
import { buildPageState } from './page-state'
|
|
29
|
+
import { buildPageState, buildGeneratedState } from './page-state'
|
|
30
30
|
import {
|
|
31
31
|
listComposedBlocks,
|
|
32
32
|
readComposedBlock,
|
|
@@ -52,6 +52,12 @@ export interface DevMiddlewareOptions {
|
|
|
52
52
|
blocks?: () => Promise<BlockListing[]> | BlockListing[]
|
|
53
53
|
/** The site's locale config (multi-language sites); null/absent = i18n off. */
|
|
54
54
|
locales?: LocalesConfig | null
|
|
55
|
+
/**
|
|
56
|
+
* Generated routes (plugin `generatePages`): a served-path → page map (for
|
|
57
|
+
* `/state`) and the set of their logical paths (for the read-only `/save`
|
|
58
|
+
* guard). Absent when no providers are configured.
|
|
59
|
+
*/
|
|
60
|
+
generated?: () => Promise<{ byServed: Map<string, VirtualPage>; logical: Set<string> }>
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
/**
|
|
@@ -138,6 +144,8 @@ export function createDevMiddleware(
|
|
|
138
144
|
const pathParam = query.get('path')
|
|
139
145
|
if (!pathParam) return json({ error: 'Missing path' }, 400)
|
|
140
146
|
await options.ready?.()
|
|
147
|
+
const generated = (await options.generated?.())?.byServed.get(pathParam)
|
|
148
|
+
if (generated) return json(buildGeneratedState(mechDir, generated, config))
|
|
141
149
|
return json(buildPageState(mechDir, pathParam, config))
|
|
142
150
|
}
|
|
143
151
|
|
|
@@ -238,6 +246,11 @@ export function createDevMiddleware(
|
|
|
238
246
|
if (pathname === '/save' && req.method === 'POST') {
|
|
239
247
|
const pathParam = query.get('path')
|
|
240
248
|
if (!pathParam) return json({ error: 'Missing path' }, 400)
|
|
249
|
+
// Generated pages (plugin `generatePages`) have no file — reject a save
|
|
250
|
+
// rather than writing a stray `.page.md` at their path.
|
|
251
|
+
if ((await options.generated?.())?.logical.has(pathParam)) {
|
|
252
|
+
return json({ error: 'This page is generated and read-only' }, 409)
|
|
253
|
+
}
|
|
241
254
|
// A non-default `locale` writes to that translation's variant file; the
|
|
242
255
|
// default locale (or i18n off) writes the base page.
|
|
243
256
|
const locale = variantCode(query.get('locale'))
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseLocalePath, mergeTranslation, type LocalesConfig, type PageMeta } from 'mechanica-shared'
|
|
1
|
+
import { parseLocalePath, mergeTranslation, type LocalesConfig, type PageMeta, type VirtualPage } from 'mechanica-shared'
|
|
2
2
|
import {
|
|
3
3
|
readPage,
|
|
4
4
|
pageVersion,
|
|
@@ -126,3 +126,42 @@ export function buildPageState(
|
|
|
126
126
|
...(baseContent ? { baseContent } : {}),
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The dev state for a programmatically generated page ({@link VirtualPage}) —
|
|
132
|
+
* the file-free counterpart of {@link buildPageState}. It replicates the same
|
|
133
|
+
* shape (content with block-prop defaults + image meta filled, the
|
|
134
|
+
* site‹folder‹page data merge, and `page.locale`/`page.locales`), but reads its
|
|
135
|
+
* content/data/meta from the baked page rather than a `.page.md`. `version` is
|
|
136
|
+
* null and `generated` is set, so the editor treats it as read-only and never
|
|
137
|
+
* queues a save against a file that doesn't exist.
|
|
138
|
+
*/
|
|
139
|
+
export function buildGeneratedState(mechDir: string, vp: VirtualPage, config?: LocalesConfig | null) {
|
|
140
|
+
const content = vp.content ?? []
|
|
141
|
+
fillContentDefaults(content)
|
|
142
|
+
fillImageMeta(mechDir, content)
|
|
143
|
+
|
|
144
|
+
const isDefaultLocale = !config || !vp.locale || vp.locale === config.default
|
|
145
|
+
const localeCode = isDefaultLocale ? undefined : vp.locale
|
|
146
|
+
const folder = folderOf(mechDir, vp.path)
|
|
147
|
+
const siteData = readSiteData(mechDir, localeCode)
|
|
148
|
+
const folderData = readFolderData(mechDir, folder, localeCode)
|
|
149
|
+
const pageData = vp.data ?? {}
|
|
150
|
+
const pageMeta: PageMeta = { path: vp.path, meta: vp.meta ?? {} }
|
|
151
|
+
if (config) {
|
|
152
|
+
pageMeta.locale = vp.locale ?? config.default
|
|
153
|
+
pageMeta.locales = vp.locales ?? [config.default]
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
content,
|
|
157
|
+
data: { ...siteData, ...folderData, ...pageData },
|
|
158
|
+
siteData,
|
|
159
|
+
folderData,
|
|
160
|
+
pageData,
|
|
161
|
+
folder,
|
|
162
|
+
page: pageMeta,
|
|
163
|
+
version: null,
|
|
164
|
+
generated: true,
|
|
165
|
+
...(config ? { locales: config } : {}),
|
|
166
|
+
}
|
|
167
|
+
}
|
package/src/vite/entries.ts
CHANGED
|
@@ -153,6 +153,9 @@ export interface SsrEntryOptions {
|
|
|
153
153
|
/** The site's locale config (multi-language) — rides the bundle so the export
|
|
154
154
|
* can walk translations, and gets baked into each page's `state.locales`. */
|
|
155
155
|
locales?: import('mechanica-shared').LocalesConfig | null
|
|
156
|
+
/** Programmatically generated pages (plugin `generatePages`), run at build and
|
|
157
|
+
* baked as plain data so `mechanica export` renders them without a file. */
|
|
158
|
+
generatedPages?: import('mechanica-shared').VirtualPage[]
|
|
156
159
|
}
|
|
157
160
|
|
|
158
161
|
/**
|
|
@@ -176,6 +179,7 @@ export function generateSsrEntry(options: SsrEntryOptions): string {
|
|
|
176
179
|
`registerComponents(blocksMap)`,
|
|
177
180
|
`export const site = ${JSON.stringify(options.site ?? {})}`,
|
|
178
181
|
`export const locales = ${JSON.stringify(options.locales ?? null)}`,
|
|
182
|
+
`export const generatedPages = ${JSON.stringify(options.generatedPages ?? [])}`,
|
|
179
183
|
`export const dataEntries = getDataEntries()`,
|
|
180
184
|
``,
|
|
181
185
|
`export async function render(state, context = {}) {`,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { localePath, type LocalesConfig, type VirtualPage } from 'mechanica-shared'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Programmatic route generation (`generatePages`).
|
|
5
|
+
*
|
|
6
|
+
* A provider turns some source — a glob of Markdown, a CMS, anything — into a
|
|
7
|
+
* list of {@link VirtualPage}s: pages with no `.page.md` file, rendered through
|
|
8
|
+
* the normal pipeline. The plugin runs `list` at build and bakes the output
|
|
9
|
+
* into the SSR bundle for the static export (see `generateSsrEntry`), and runs
|
|
10
|
+
* it at dev-server start into a route map.
|
|
11
|
+
*
|
|
12
|
+
* These are build-side types (a provider gets fs paths) — they live with the
|
|
13
|
+
* plugin options, not in the `mechanica-shared` barrel the client imports.
|
|
14
|
+
* `resolve`/`revalidate` are reserved for the future render backend (render one
|
|
15
|
+
* path on a webhook without enumerating the whole source); Phase 1 uses `list`.
|
|
16
|
+
*/
|
|
17
|
+
export interface PageProviderCtx {
|
|
18
|
+
/** The Vite project root (absolute). */
|
|
19
|
+
root: string
|
|
20
|
+
/** The project's `.mech` directory (absolute). */
|
|
21
|
+
mechDir: string
|
|
22
|
+
/** The site's locale config, or null when i18n is off. */
|
|
23
|
+
locales: LocalesConfig | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type PageProvider =
|
|
27
|
+
| ((ctx: PageProviderCtx) => VirtualPage[] | Promise<VirtualPage[]>)
|
|
28
|
+
| {
|
|
29
|
+
/** Enumerate every page (static export, sitemap, dev route map). */
|
|
30
|
+
list: (ctx: PageProviderCtx) => VirtualPage[] | Promise<VirtualPage[]>
|
|
31
|
+
/** Reserved: render one path without enumerating — backend ISR / lazy dev. */
|
|
32
|
+
resolve?: (path: string, ctx: PageProviderCtx) => VirtualPage | null | Promise<VirtualPage | null>
|
|
33
|
+
/** Reserved: cache/revalidation hint for the backend. */
|
|
34
|
+
revalidate?: number | ((page: VirtualPage) => number)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Run every provider's `list` once and flatten the results. */
|
|
38
|
+
export async function collectGeneratedPages(
|
|
39
|
+
providers: PageProvider[] | undefined,
|
|
40
|
+
ctx: PageProviderCtx,
|
|
41
|
+
): Promise<VirtualPage[]> {
|
|
42
|
+
if (!providers?.length) return []
|
|
43
|
+
const lists = await Promise.all(providers.map((p) => (typeof p === 'function' ? p : p.list)(ctx)))
|
|
44
|
+
return lists.flat()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The served (locale-prefixed) URL a generated page renders at. */
|
|
48
|
+
export function generatedServedPath(page: VirtualPage, config: LocalesConfig | null): string {
|
|
49
|
+
if (!config || !page.locale || page.locale === config.default) return page.path
|
|
50
|
+
return localePath(page.path, page.locale, config)
|
|
51
|
+
}
|
package/src/vite/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ export {
|
|
|
5
5
|
WIDGETS_MODULE_ID,
|
|
6
6
|
type MechanicaPluginOptions,
|
|
7
7
|
} from './plugin'
|
|
8
|
+
export { type PageProvider, type PageProviderCtx } from './generate-pages'
|
|
8
9
|
export { default as svgGlob } from '../svg-plugin'
|
|
9
10
|
export { collectBlocks } from './collect-blocks'
|
|
10
11
|
export { collectWidgets } from './collect-widgets'
|
package/src/vite/plugin.ts
CHANGED
|
@@ -2,7 +2,13 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
2
2
|
import { join, relative } from 'node:path'
|
|
3
3
|
import type { Plugin } from 'vite'
|
|
4
4
|
import { parseVueRequest } from '@vitejs/plugin-vue'
|
|
5
|
-
import { passDataToHTML, serializeState, normalizeLocales, type LocalesConfig } from 'mechanica-shared'
|
|
5
|
+
import { passDataToHTML, serializeState, normalizeLocales, type LocalesConfig, type VirtualPage } from 'mechanica-shared'
|
|
6
|
+
import {
|
|
7
|
+
collectGeneratedPages,
|
|
8
|
+
generatedServedPath,
|
|
9
|
+
type PageProvider,
|
|
10
|
+
type PageProviderCtx,
|
|
11
|
+
} from './generate-pages'
|
|
6
12
|
import { compileBlock } from '../compiler/compile-block'
|
|
7
13
|
import { emitElementsCss } from '../elements/emit-css'
|
|
8
14
|
import { parseComposerBreakpoints } from './read-breakpoints'
|
|
@@ -21,7 +27,7 @@ import { createPreviewMiddleware } from './dev/preview'
|
|
|
21
27
|
import { createComposerMiddleware } from './dev/composer'
|
|
22
28
|
import { setPageCodec, setPageBlocks, pageUrlOf } from './dev/pages-store'
|
|
23
29
|
import { toBlockMeta, composedBlockMeta } from '../editor/lib/block-meta'
|
|
24
|
-
import { buildPageState } from './dev/page-state'
|
|
30
|
+
import { buildPageState, buildGeneratedState } from './dev/page-state'
|
|
25
31
|
import { wasRecentlyMutated } from './dev/fs-utils'
|
|
26
32
|
import { buildRichTextCodec } from './rich-text-codec'
|
|
27
33
|
import { BLOCKS_MANIFEST_FILE } from '../cli/page-assets'
|
|
@@ -95,6 +101,14 @@ export interface MechanicaPluginOptions {
|
|
|
95
101
|
* locale) = i18n off — nothing changes for existing sites.
|
|
96
102
|
*/
|
|
97
103
|
locales?: import('mechanica-shared').LocalesOption
|
|
104
|
+
/**
|
|
105
|
+
* Programmatic route generation. Each provider returns a list of pages built
|
|
106
|
+
* from any source (a content glob, a CMS, …) — rendered through the normal
|
|
107
|
+
* pipeline with no `.page.md` file per route. Providers run at build (their
|
|
108
|
+
* output is baked into the static export) and at dev-server start; generated
|
|
109
|
+
* pages are read-only. See GENERATED-PAGES.md.
|
|
110
|
+
*/
|
|
111
|
+
generatePages?: PageProvider[]
|
|
98
112
|
/**
|
|
99
113
|
* How the production client build chunks block code.
|
|
100
114
|
*
|
|
@@ -139,6 +153,34 @@ export function mechanica(options: MechanicaPluginOptions = {}): Plugin {
|
|
|
139
153
|
// emitted as `mechanica-blocks.json` for `mechanica export`.
|
|
140
154
|
let lazyBlockFiles: Map<string, string> | null = null
|
|
141
155
|
|
|
156
|
+
// Generated pages (`generatePages`): run each provider's `list` once, cached.
|
|
157
|
+
// The output is baked into the SSR bundle for export; in dev a served-path →
|
|
158
|
+
// page map resolves navigations and a logical-path set backs the read-only
|
|
159
|
+
// save guard.
|
|
160
|
+
let generatedPagesCache: Promise<VirtualPage[]> | null = null
|
|
161
|
+
const loadGeneratedPages = (): Promise<VirtualPage[]> => {
|
|
162
|
+
if (!generatedPagesCache) {
|
|
163
|
+
const ctx: PageProviderCtx = { root, mechDir, locales }
|
|
164
|
+
generatedPagesCache = collectGeneratedPages(options.generatePages, ctx)
|
|
165
|
+
}
|
|
166
|
+
return generatedPagesCache
|
|
167
|
+
}
|
|
168
|
+
let generatedIndexCache: Promise<{ byServed: Map<string, VirtualPage>; logical: Set<string> }> | null = null
|
|
169
|
+
const loadGeneratedIndex = (): Promise<{ byServed: Map<string, VirtualPage>; logical: Set<string> }> => {
|
|
170
|
+
if (!generatedIndexCache) {
|
|
171
|
+
generatedIndexCache = loadGeneratedPages().then((pages) => {
|
|
172
|
+
const byServed = new Map<string, VirtualPage>()
|
|
173
|
+
const logical = new Set<string>()
|
|
174
|
+
for (const page of pages) {
|
|
175
|
+
byServed.set(generatedServedPath(page, locales), page)
|
|
176
|
+
logical.add(page.path)
|
|
177
|
+
}
|
|
178
|
+
return { byServed, logical }
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
return generatedIndexCache
|
|
182
|
+
}
|
|
183
|
+
|
|
142
184
|
// Last compiled `blockSchema` literal per block file (normalized path), so a
|
|
143
185
|
// hot update can tell schema edits (need a re-collect + reload so the editor
|
|
144
186
|
// sees fresh metadata) from template/style edits (normal HMR).
|
|
@@ -419,6 +461,7 @@ export function mechanica(options: MechanicaPluginOptions = {}): Plugin {
|
|
|
419
461
|
userEntry,
|
|
420
462
|
site: { url: options.siteUrl, name: options.siteName },
|
|
421
463
|
locales,
|
|
464
|
+
generatedPages: await loadGeneratedPages(),
|
|
422
465
|
})
|
|
423
466
|
}
|
|
424
467
|
if (id === RESOLVED_PREVIEW_ID) {
|
|
@@ -479,6 +522,9 @@ export function mechanica(options: MechanicaPluginOptions = {}): Plugin {
|
|
|
479
522
|
'/@mechanica',
|
|
480
523
|
createDevMiddleware(mechDir, {
|
|
481
524
|
locales,
|
|
525
|
+
// Resolve generated routes for `/state` and guard them read-only on
|
|
526
|
+
// `/save` — they have no file to write.
|
|
527
|
+
generated: () => loadGeneratedIndex(),
|
|
482
528
|
ready: () => ensurePageCodec(server),
|
|
483
529
|
// Block listing for `mechanica thumbs --blocks` — loaded fresh so a
|
|
484
530
|
// re-collected blocks module (HMR add/remove) is reflected. Composed
|
|
@@ -598,10 +644,16 @@ export function mechanica(options: MechanicaPluginOptions = {}): Plugin {
|
|
|
598
644
|
|
|
599
645
|
// Ensure richText fields hydrate as Block[] (not raw Markdown).
|
|
600
646
|
if (ctx.server) await ensurePageCodec(ctx.server)
|
|
647
|
+
// A programmatically generated route (plugin `generatePages`) has no
|
|
648
|
+
// page file — synthesize its state from the baked page instead. Falls
|
|
649
|
+
// through to the file-based resolver for authored pages.
|
|
650
|
+
const generated = (await loadGeneratedIndex()).byServed.get(urlPath)
|
|
601
651
|
// Site < folder < page resolution plus the editor's scope buckets and
|
|
602
652
|
// the page's on-disk version (for optimistic-concurrency saves). A
|
|
603
653
|
// locale-prefixed URL (`/ru/about`) resolves to that translation.
|
|
604
|
-
const state =
|
|
654
|
+
const state = generated
|
|
655
|
+
? buildGeneratedState(mechDir, generated, locales)
|
|
656
|
+
: buildPageState(mechDir, urlPath, locales)
|
|
605
657
|
const inject = [
|
|
606
658
|
`<script>window.state=${serializeState(state)}</script>`,
|
|
607
659
|
`<script type="module">`,
|