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 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) => saveQueue.push(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 state = buildPageState(mechDir, urlPath, locales);
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.5",
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": "workspace:*",
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 => {
@@ -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) => saveQueue.push(snapshot),
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
+ }
@@ -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'
@@ -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 = buildPageState(mechDir, urlPath, locales)
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">`,