mechanica 2.0.2 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/export.js ADDED
@@ -0,0 +1,580 @@
1
+ import { B as readImageManifest, C as getPagePath, I as UPLOADS_PREFIX, J as hasSharp, L as assetFileOf, N as setPageCodec, R as isDerivedAsset, T as listPages, U as updateImageManifest, a as toBlockMeta, d as readFolderData, f as readFoldersData, i as composedBlockMeta, m as readSiteLocaleOverride, n as blockAssetLinks, p as readSiteData, q as analyzeImageBuffer, r as buildRichTextCodec, t as BLOCKS_MANIFEST_FILE } from "./chunks/page-assets-CM_MCuoa.js";
2
+ import { dirname, join, parse, relative, resolve } from "node:path";
3
+ import { applySeoTags, auditPageHtml, buildRobotsTxt, buildSitemap, findUnknownBlocks, generateProject, isPaginatedQuery, localePath, mergeTranslation, migrateContent, paginationVariantPath, registerFieldSchemas, resolveQueryKey, templateBlockIds, validateLinks, walkSchema, walkTree } from "mechanica-shared";
4
+ import { parsePage } from "mechanica-shared/page-format";
5
+ import { access, copyFile, cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
6
+ import { pathToFileURL } from "node:url";
7
+ //#region src/cli/export.ts
8
+ /** A page filename stem ending in `@<locale>` is a translation, handled per-locale. */
9
+ var VARIANT_STEM_RE = /@[A-Za-z][A-Za-z0-9_-]*$/;
10
+ /** Set `<html lang>` unless the template already declares one (author wins). */
11
+ function setHtmlLang(html, lang) {
12
+ if (/<html\b[^>]*\blang\s*=/i.test(html)) return html;
13
+ return html.replace(/<html\b([^>]*)>/i, `<html$1 lang="${lang}">`);
14
+ }
15
+ /** Read every page JSON under `<mech>/pages` as an exportable page. */
16
+ async function readPages(pagesDir, foldersData = {}, richText) {
17
+ let entries;
18
+ try {
19
+ entries = await readdir(pagesDir, { recursive: true });
20
+ } catch {
21
+ return [];
22
+ }
23
+ const EXT = ".page.md";
24
+ const pages = [];
25
+ for (const relative of entries) {
26
+ if (!relative.endsWith(EXT)) continue;
27
+ const parsed = parse(relative);
28
+ const dir = parsed.dir.replace(/\\/g, "/");
29
+ const base = parsed.base.slice(0, -8);
30
+ if (VARIANT_STEM_RE.test(base)) continue;
31
+ const filePath = join(pagesDir, relative);
32
+ const file = parsePage(await readFile(filePath, "utf-8"), richText ? { richText } : void 0);
33
+ if (file.draft) continue;
34
+ const path = `/${dir}/${base === "index" ? "" : base}`.replace("//", "/").replace(/\/$/, "") || "/";
35
+ const data = {
36
+ ...dir ? foldersData[dir] : void 0,
37
+ ...file.data ?? {}
38
+ };
39
+ const lastmod = typeof file.meta?.lastmod === "string" ? file.meta.lastmod : await stat(filePath).then((s) => s.mtime.toISOString().slice(0, 10)).catch(() => void 0);
40
+ const meta = path === "/404" ? {
41
+ noindex: true,
42
+ ...file.meta
43
+ } : file.meta;
44
+ pages.push({
45
+ path,
46
+ logicalPath: path,
47
+ content: file.content ?? [],
48
+ data,
49
+ name: file.name,
50
+ lastmod,
51
+ page: {
52
+ meta,
53
+ ...file.layout ? { layout: file.layout } : {}
54
+ }
55
+ });
56
+ }
57
+ return pages;
58
+ }
59
+ /**
60
+ * Read a page's translation for `locale`, returning an {@link ExportPage} at the
61
+ * locale-prefixed path — or `null` when the translation file is missing or is a
62
+ * per-locale draft. Content/data come from the variant file; folder-scoped data
63
+ * merges the same as the base page.
64
+ */
65
+ async function readTranslation(mechDir, base, locale, config, richText) {
66
+ const file = getPagePath(mechDir, base.logicalPath, locale);
67
+ let raw;
68
+ try {
69
+ raw = await readFile(file, "utf-8");
70
+ } catch {
71
+ return null;
72
+ }
73
+ const doc = parsePage(raw, richText ? { richText } : void 0);
74
+ if (doc.draft) return null;
75
+ const rel = relative(join(mechDir, "pages"), file).replace(/\\/g, "/");
76
+ const dir = dirname(rel) === "." ? "" : dirname(rel);
77
+ let content = doc.content ?? [];
78
+ let pageData = doc.data ?? {};
79
+ let pageMeta = doc.meta;
80
+ try {
81
+ const baseDoc = parsePage(await readFile(getPagePath(mechDir, base.logicalPath), "utf-8"), richText ? { richText } : void 0);
82
+ const merged = mergeTranslation({
83
+ content: baseDoc.content ?? [],
84
+ data: baseDoc.data ?? {},
85
+ meta: baseDoc.meta ?? {}
86
+ }, {
87
+ content: doc.content ?? [],
88
+ data: doc.data ?? {},
89
+ meta: doc.meta ?? {}
90
+ });
91
+ content = merged.content;
92
+ pageData = merged.data;
93
+ pageMeta = merged.meta;
94
+ } catch {}
95
+ const folderData = readFolderData(mechDir, dir || null, locale);
96
+ const data = {
97
+ ...readSiteLocaleOverride(mechDir, locale),
98
+ ...folderData,
99
+ ...pageData
100
+ };
101
+ const lastmod = typeof doc.meta?.lastmod === "string" ? doc.meta.lastmod : await stat(file).then((s) => s.mtime.toISOString().slice(0, 10)).catch(() => void 0);
102
+ const meta = base.logicalPath === "/404" ? {
103
+ noindex: true,
104
+ ...pageMeta
105
+ } : pageMeta;
106
+ return {
107
+ path: localePath(base.logicalPath, locale, config),
108
+ logicalPath: base.logicalPath,
109
+ locale,
110
+ content,
111
+ data,
112
+ name: doc.name ?? base.name,
113
+ lastmod,
114
+ page: {
115
+ meta,
116
+ locale,
117
+ path: base.logicalPath,
118
+ ...base.page?.layout ? { layout: base.page.layout } : {}
119
+ }
120
+ };
121
+ }
122
+ /**
123
+ * The breadcrumb trail for a path — every ancestor (root first, the page last)
124
+ * that actually exists as a page, named by its editor label. Feeds the auto
125
+ * BreadcrumbList JSON-LD.
126
+ */
127
+ function breadcrumbsFor(path, names) {
128
+ if (path === "/") return [];
129
+ const trail = ["/"];
130
+ let current = "";
131
+ for (const segment of path.slice(1).split("/")) {
132
+ current += `/${segment}`;
133
+ trail.push(current);
134
+ }
135
+ return trail.filter((p) => names.has(p)).map((p) => ({
136
+ name: names.get(p),
137
+ path: p
138
+ }));
139
+ }
140
+ /**
141
+ * Map a baked {@link VirtualPage} to an {@link ExportPage}. The provider emits
142
+ * one per (logical page × locale) and self-declares `locale`/`locales`, so the
143
+ * mapping just localizes the served `path` and carries the alternates — it never
144
+ * goes through the file-based `readTranslation`.
145
+ */
146
+ function virtualToExportPage(v, config) {
147
+ const isDefault = !config || !v.locale || v.locale === config.default;
148
+ const locale = config ? v.locale ?? config.default : void 0;
149
+ const path = isDefault ? v.path : localePath(v.path, v.locale, config);
150
+ const name = typeof v.meta?.title === "string" ? v.meta.title : void 0;
151
+ return {
152
+ path,
153
+ logicalPath: v.path,
154
+ locale,
155
+ translations: v.locales,
156
+ content: v.content ?? [],
157
+ data: v.data ?? {},
158
+ name,
159
+ lastmod: v.lastmod,
160
+ page: {
161
+ meta: v.meta ?? {},
162
+ locale,
163
+ locales: v.locales,
164
+ path: v.path,
165
+ ...v.layout ? { layout: v.layout } : {}
166
+ }
167
+ };
168
+ }
169
+ /**
170
+ * Load the pieces needed to preload per-page block chunks: the client build's
171
+ * Vite manifest and the plugin's block map. Older builds (or builds outside
172
+ * `mechanica build`) may not have them — links are simply skipped then.
173
+ */
174
+ async function readBlockAssets(cwd) {
175
+ try {
176
+ const [manifest, blockFiles] = await Promise.all([readFile(join(cwd, "dist/.vite/manifest.json"), "utf-8").then(JSON.parse), readFile(join(cwd, "dist", BLOCKS_MANIFEST_FILE), "utf-8").then(JSON.parse)]);
177
+ return {
178
+ manifest,
179
+ blockFiles
180
+ };
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+ /** Where uploaded assets land in the static export. */
186
+ var MEDIA_DIR = "media";
187
+ /**
188
+ * Rewrite the uploaded-asset URLs a composed-block template carries — a
189
+ * `mech:image` node's `src`, any node's `bgImage` fill — through `onFile`, in
190
+ * the base data and every `$bp` layer. Page data goes through `collectFiles`
191
+ * (schema-driven); templates live in the block definition, so they need their
192
+ * own pass. Mutates the defs in place: they are the same objects the SSR
193
+ * runtime's registered components render from.
194
+ */
195
+ function rewriteComposedAssets(defs, onFile) {
196
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
197
+ const rewriteLayer = (blockId, layer) => {
198
+ if (blockId === "mech:image" && typeof layer.src === "string") layer.src = onFile(layer.src);
199
+ const bg = layer.bgImage;
200
+ if (isRecord(bg)) {
201
+ if (typeof bg.src === "string") bg.src = onFile(bg.src);
202
+ if (typeof bg.croppedSrc === "string") bg.croppedSrc = onFile(bg.croppedSrc);
203
+ }
204
+ };
205
+ for (const def of defs) walkTree(def.template ?? [], (node) => {
206
+ if (!node.data) return;
207
+ rewriteLayer(node.blockId, node.data);
208
+ const bp = node.data.$bp;
209
+ if (isRecord(bp)) {
210
+ for (const layer of Object.values(bp)) if (isRecord(layer)) rewriteLayer(node.blockId, layer);
211
+ }
212
+ });
213
+ }
214
+ /** Fill a value's missing preview/dimensions from a manifest entry. */
215
+ function fillImageValue(value, entry) {
216
+ if (entry.previewSrc && (!value.previewSrc || value.previewSrc === value.src)) value.previewSrc = entry.previewSrc;
217
+ if (entry.width && !value.width) value.width = entry.width;
218
+ if (entry.height && !value.height) value.height = entry.height;
219
+ }
220
+ /**
221
+ * Fill missing image metadata — LQIP `previewSrc` + intrinsic dimensions —
222
+ * into the state being exported (page files are never touched). Sources, in
223
+ * order: the `.mech/images.json` manifest (written by uploads, editor saves
224
+ * and `mechanica images`), then the optional `sharp` dependency for anything
225
+ * still missing — those computed entries are cached back into the manifest.
226
+ * Without sharp, leftover gaps get a one-time hint.
227
+ */
228
+ async function backfillImageMeta(pages, blocksMap, mechDir, warn) {
229
+ const manifest = readImageManifest(mechDir);
230
+ const incomplete = /* @__PURE__ */ new Map();
231
+ for (const page of pages) walkTree(page.content, (block) => {
232
+ const meta = blocksMap.get(block.blockId);
233
+ if (!meta) return;
234
+ walkSchema(block.data, meta.props, (value, schema) => {
235
+ if (schema.format !== "image") return;
236
+ const file = assetFileOf(value?.src);
237
+ if (!file) return;
238
+ const entry = manifest[file];
239
+ if (entry) fillImageValue(value, entry);
240
+ if (!value.previewSrc || value.previewSrc === value.src || !value.width || !value.height) incomplete.set(file, [...incomplete.get(file) ?? [], value]);
241
+ });
242
+ });
243
+ if (!incomplete.size) return;
244
+ if (!await hasSharp()) {
245
+ warn(`[mechanica] ${incomplete.size} image(s) lack a preview or dimensions — install the optional "sharp" dependency or run \`mechanica images\` to generate them`);
246
+ return;
247
+ }
248
+ const computed = {};
249
+ for (const [file, values] of incomplete) {
250
+ const buffer = await readFile(join(mechDir, "assets", file)).catch(() => null);
251
+ const info = buffer ? await analyzeImageBuffer(buffer) : null;
252
+ if (!info) continue;
253
+ computed[file] = info;
254
+ for (const value of values) fillImageValue(value, info);
255
+ }
256
+ try {
257
+ updateImageManifest(mechDir, computed);
258
+ } catch {}
259
+ }
260
+ /**
261
+ * Statically render every page of an already-built project into `export/`,
262
+ * returning the generated page paths. This is the post-build core of
263
+ * {@link runExport}, isolated so it can be tested without a Vite build: it
264
+ * expects `<cwd>/dist` (index.html, assets, the loaded `ssr` bundle) and that
265
+ * field schemas have been registered; it reads pages + scoped data from
266
+ * `<cwd>/.mech`.
267
+ *
268
+ * Beyond the pages it also: rewrites + copies uploaded assets
269
+ * (`/@mechanica/assets/…` → `/media/…`, or `<assetsUrl>/media/…` when a CDN
270
+ * base is set), warns about dead internal links,
271
+ * orphaned uploads and page-level SEO issues, emits `404.html` when a `/404`
272
+ * page exists, and — when a site url is known (plugin `siteUrl` option or
273
+ * `--site-url`) — injects the automatic SEO tags (canonical, `og:url`,
274
+ * absolute social-image URLs, pagination prev/next, JSON-LD) and emits
275
+ * `sitemap.xml` + `robots.txt`.
276
+ */
277
+ async function exportProject(cwd, ssr, options = {}) {
278
+ const warn = options.onWarn ?? ((message) => console.warn(message));
279
+ const index = await readFile(join(cwd, "dist/index.html"), "utf-8");
280
+ const siteUrl = options.siteUrl ?? ssr.site?.url;
281
+ const siteName = options.siteName ?? ssr.site?.name;
282
+ const assetsBase = (options.assetsUrl ?? ssr.assetsUrl ?? "").replace(/\/+$/, "");
283
+ const composedDefs = ssr.composedList ?? [];
284
+ const blocks = [...ssr.blocksList.map(toBlockMeta), ...composedDefs.map(composedBlockMeta)];
285
+ const blocksMap = new Map(blocks.map((block) => [block.id, block]));
286
+ const composedMap = new Map(composedDefs.map((def) => [def.id, def]));
287
+ const richText = buildRichTextCodec(ssr.blocksList);
288
+ const foldersData = readFoldersData(join(cwd, ".mech"));
289
+ const defaultPages = await readPages(join(cwd, ".mech/pages"), foldersData, richText);
290
+ const projectData = readSiteData(join(cwd, ".mech"));
291
+ const config = ssr.locales ?? null;
292
+ const mechDir = join(cwd, ".mech");
293
+ const pages = [...defaultPages];
294
+ if (config) {
295
+ const missing = {};
296
+ for (const base of defaultPages) {
297
+ const present = [config.default];
298
+ for (const locale of config.all) {
299
+ if (locale === config.default) continue;
300
+ const translation = await readTranslation(mechDir, base, locale, config, richText);
301
+ if (translation) {
302
+ present.push(locale);
303
+ pages.push(translation);
304
+ } else if (!base.page?.meta?.noindex) (missing[locale] ??= []).push(base.logicalPath);
305
+ }
306
+ base.translations = present;
307
+ if (base.page) base.locale = config.default;
308
+ base.page = {
309
+ ...base.page,
310
+ locale: config.default,
311
+ locales: present
312
+ };
313
+ }
314
+ const translationsByLogical = new Map(defaultPages.map((p) => [p.logicalPath, p.translations]));
315
+ for (const page of pages) if (page.locale && page.locale !== config.default) {
316
+ const present = translationsByLogical.get(page.logicalPath);
317
+ page.translations = present;
318
+ page.page = {
319
+ ...page.page,
320
+ locales: present
321
+ };
322
+ }
323
+ 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(", ")}`);
324
+ }
325
+ if (ssr.generatedPages?.length) {
326
+ const taken = new Set(pages.map((page) => page.path));
327
+ for (const vp of ssr.generatedPages) {
328
+ const generated = virtualToExportPage(vp, config);
329
+ 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.`);
330
+ taken.add(generated.path);
331
+ pages.push(generated);
332
+ }
333
+ }
334
+ setPageCodec(richText);
335
+ const querySource = {
336
+ listPages: (opts) => listPages(join(cwd, ".mech"), {
337
+ ...opts,
338
+ generated: ssr.generatedPages
339
+ }),
340
+ fetchJson: options.fetchJson ?? (async ({ url, ...init }) => {
341
+ const res = await fetch(url, init);
342
+ if (!res.ok) throw new Error(`${url} responded ${res.status}`);
343
+ return res.json();
344
+ })
345
+ };
346
+ const queryCache = /* @__PURE__ */ new Map();
347
+ const resolveForPage = (pageNumber, pageLocale) => (key) => {
348
+ const locale = config && pageLocale && pageLocale !== config.default ? pageLocale : void 0;
349
+ const cacheKey = `${pageNumber} ${locale ?? ""} ${key}`;
350
+ let hit = queryCache.get(cacheKey);
351
+ if (!hit) {
352
+ hit = resolveQueryKey(querySource, key, {
353
+ page: pageNumber,
354
+ locale
355
+ });
356
+ queryCache.set(cacheKey, hit);
357
+ }
358
+ return hit;
359
+ };
360
+ for (const page of pages) {
361
+ migrateContent(page.content, blocksMap);
362
+ for (const id of findUnknownBlocks(page.content, blocksMap)) warn(`[mechanica] Page ${page.path} references unknown block "${id}" — it renders as nothing`);
363
+ }
364
+ if (ssr.layoutNames) {
365
+ const layoutNames = ssr.layoutNames;
366
+ const seen = /* @__PURE__ */ new Set();
367
+ for (const page of pages) {
368
+ const layout = page.page?.layout;
369
+ if (!layout || layoutNames.includes(layout) || seen.has(page.logicalPath)) continue;
370
+ seen.add(page.logicalPath);
371
+ warn(layoutNames.length === 0 ? `[mechanica] Page ${page.logicalPath} sets layout "${layout}", but the app declares no layouts (defineMechanicaApp({ layouts }))` : `[mechanica] Page ${page.logicalPath} uses unknown layout "${layout}" — it falls back to "${layoutNames[0]}" (declared: ${layoutNames.join(", ")})`);
372
+ }
373
+ }
374
+ if (!pages.some((page) => page.logicalPath === "/404")) warn("[mechanica] No /404 page — the static host will show its own error page. Create a page at /404 (it exports as 404.html and is noindexed automatically).");
375
+ for (const issue of validateLinks(pages, blocksMap)) warn(`[mechanica] Broken link on ${issue.page}: ${issue.url} has no matching page`);
376
+ await backfillImageMeta(pages, blocksMap, join(cwd, ".mech"), warn);
377
+ const exportDir = resolve(cwd, options.outDir ?? "export");
378
+ await mkdir(exportDir, { recursive: true });
379
+ for (const entry of await readdir(exportDir)) await rm(join(exportDir, entry), {
380
+ recursive: true,
381
+ force: true
382
+ });
383
+ const DIST_PRIVATE = /* @__PURE__ */ new Set([
384
+ "index.html",
385
+ "ssr.js",
386
+ "ssr.js.map",
387
+ ".vite",
388
+ BLOCKS_MANIFEST_FILE
389
+ ]);
390
+ const distEntries = await readdir(join(cwd, "dist")).catch(() => []);
391
+ for (const entry of distEntries) {
392
+ if (DIST_PRIVATE.has(entry)) continue;
393
+ await cp(join(cwd, "dist", entry), join(exportDir, entry), { recursive: true });
394
+ }
395
+ const referenced = /* @__PURE__ */ new Set();
396
+ const onFile = (src) => {
397
+ if (typeof src !== "string" || !src.startsWith("/@mechanica/assets/")) return src;
398
+ const relative = decodeURIComponent(src.slice(UPLOADS_PREFIX.length));
399
+ referenced.add(relative);
400
+ return `${assetsBase}/${MEDIA_DIR}/${relative}`;
401
+ };
402
+ rewriteComposedAssets(composedDefs, onFile);
403
+ const blockAssets = await readBlockAssets(cwd);
404
+ const pageLinks = blockAssets ? (content) => {
405
+ const blockIds = /* @__PURE__ */ new Set();
406
+ const add = (id) => {
407
+ if (blockIds.has(id)) return;
408
+ blockIds.add(id);
409
+ const def = composedMap.get(id);
410
+ if (def) for (const inner of templateBlockIds(def)) add(inner);
411
+ };
412
+ walkTree(content, (block) => add(block.blockId));
413
+ return blockAssetLinks({
414
+ blockIds,
415
+ ...blockAssets,
416
+ base: assetsBase || void 0,
417
+ alreadyLinked: (file) => index.includes(file)
418
+ });
419
+ } : void 0;
420
+ const projectOptions = {
421
+ index,
422
+ blocksMap,
423
+ dataEntries: ssr.dataEntries ?? [],
424
+ projectData,
425
+ site: {
426
+ url: siteUrl,
427
+ name: siteName
428
+ },
429
+ assetsUrl: assetsBase || void 0,
430
+ locales: config ?? void 0,
431
+ render: (state) => ssr.render(state, { resolveQuery: resolveForPage(state.page?.pagination?.page ?? 1, state.page?.locale) }),
432
+ onFile,
433
+ pageLinks
434
+ };
435
+ const written = [];
436
+ const writePage = async (path, html) => {
437
+ const dir = path === "/" ? exportDir : join(exportDir, path);
438
+ await mkdir(dir, { recursive: true });
439
+ await writeFile(join(dir, "index.html"), html);
440
+ written.push(path);
441
+ };
442
+ const templateHandlesPagination = index.includes("page.pagination");
443
+ const pageNames = new Map(defaultPages.map((page) => [page.logicalPath, page.name ?? (page.logicalPath === "/" ? "Home" : page.logicalPath.split("/").pop())]));
444
+ for (const vp of ssr.generatedPages ?? []) pageNames.set(vp.path, typeof vp.meta?.title === "string" ? vp.meta.title : vp.path.split("/").pop());
445
+ /** hreflang alternates for a translated page (each locale + x-default), at page N. */
446
+ const alternatesFor = (source, pageNum = 1) => {
447
+ if (!config || !source.translations || source.translations.length < 2) return void 0;
448
+ const at = (loc) => paginationVariantPath(localePath(source.logicalPath, loc, config), pageNum);
449
+ return [{
450
+ hreflang: "x-default",
451
+ path: at(config.default)
452
+ }, ...source.translations.map((loc) => ({
453
+ hreflang: loc,
454
+ path: at(loc)
455
+ }))];
456
+ };
457
+ const finishPage = (html, path, source, pagination) => {
458
+ let out = applySeoTags(html, {
459
+ siteUrl,
460
+ siteName,
461
+ path,
462
+ noindex: source.page?.meta?.noindex === true,
463
+ pagination,
464
+ templateHandlesPagination,
465
+ breadcrumbs: breadcrumbsFor(source.logicalPath, pageNames),
466
+ alternates: alternatesFor(source, pagination?.page ?? 1)
467
+ });
468
+ if (config) out = setHtmlLang(out, source.locale ?? config.default);
469
+ return out;
470
+ };
471
+ const sitemapEntries = [];
472
+ const recordSitemap = (path, source, pageNum = 1) => {
473
+ if (path === "/404" || source.page?.meta?.noindex === true) return;
474
+ sitemapEntries.push({
475
+ path,
476
+ lastmod: source.lastmod,
477
+ alternates: alternatesFor(source, pageNum)
478
+ });
479
+ };
480
+ const knownPaths = new Set(pages.map((page) => page.path));
481
+ const pageByPath = new Map(pages.map((page) => [page.path, page]));
482
+ const variants = [];
483
+ const titlePages = /* @__PURE__ */ new Map();
484
+ for await (const { html, path, query } of generateProject({
485
+ ...projectOptions,
486
+ pages
487
+ })) {
488
+ const source = pageByPath.get(path);
489
+ let pageCount = 1;
490
+ for (const [key, result] of Object.entries(query)) {
491
+ if (!isPaginatedQuery(key)) continue;
492
+ pageCount = Math.max(pageCount, result?.pageCount ?? 1);
493
+ }
494
+ const pagination = pageCount > 1 ? {
495
+ page: 1,
496
+ pageCount,
497
+ basePath: path
498
+ } : void 0;
499
+ const finished = finishPage(html, path, source, pagination);
500
+ await writePage(path, finished);
501
+ recordSitemap(path, source);
502
+ for (const issue of auditPageHtml(finished)) warn(`[mechanica] SEO on ${path}: ${issue}`);
503
+ const title = finished.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.trim();
504
+ if (title) titlePages.set(title, [...titlePages.get(title) ?? [], path]);
505
+ if (!pagination) continue;
506
+ const base = path === "/" ? "" : path;
507
+ for (let n = 2; n <= pageCount; n++) {
508
+ const variantPath = `${base}/${n}`;
509
+ if (knownPaths.has(variantPath)) throw new Error(`[mechanica] Paginated page ${path} needs ${variantPath}, but a real page already exists at that path — rename that page or adjust the pagination.`);
510
+ variants.push({
511
+ ...source,
512
+ path: variantPath,
513
+ page: {
514
+ ...source.page,
515
+ pagination: {
516
+ page: n,
517
+ pageCount
518
+ }
519
+ }
520
+ });
521
+ }
522
+ }
523
+ for (const [title, paths] of titlePages) if (paths.length > 1) warn(`[mechanica] SEO: ${paths.length} pages share the title "${title}": ${paths.join(", ")}`);
524
+ for await (const { html, path } of generateProject({
525
+ ...projectOptions,
526
+ pages: variants
527
+ })) {
528
+ const source = variants.find((page) => page.path === path);
529
+ const basePath = path.replace(/\/\d+$/, "") || "/";
530
+ await writePage(path, finishPage(html, path, source, {
531
+ ...source.page.pagination,
532
+ basePath
533
+ }));
534
+ recordSitemap(path, source, source.page.pagination.page);
535
+ }
536
+ await copyUploads(join(cwd, ".mech/assets"), join(exportDir, MEDIA_DIR), referenced, warn);
537
+ if (written.includes("/404")) await copyFile(join(exportDir, "404/index.html"), join(exportDir, "404.html"));
538
+ if (siteUrl) {
539
+ await writeFile(join(exportDir, "sitemap.xml"), buildSitemap(siteUrl, sitemapEntries));
540
+ const robotsPath = join(exportDir, "robots.txt");
541
+ if (!await access(robotsPath).then(() => true, () => false)) await writeFile(robotsPath, buildRobotsTxt(siteUrl));
542
+ }
543
+ return written;
544
+ }
545
+ /** Copy referenced uploads into the export; warn about missing + orphaned files. */
546
+ async function copyUploads(assetsDir, outDir, referenced, warn) {
547
+ for (const relative of referenced) {
548
+ const target = join(outDir, relative);
549
+ await mkdir(dirname(target), { recursive: true });
550
+ await copyFile(join(assetsDir, relative), target).catch(() => {
551
+ warn(`[mechanica] Missing upload: ${relative} is referenced by a page but not in .mech/assets`);
552
+ });
553
+ }
554
+ const orphans = (await readdir(assetsDir, { recursive: true }).catch(() => [])).map((entry) => entry.replace(/\\/g, "/")).filter((entry) => /\.\w+$/.test(entry) && !referenced.has(entry) && !isDerivedAsset(entry));
555
+ if (orphans.length) warn(`[mechanica] ${orphans.length} unused upload(s) in .mech/assets (not exported): ${orphans.join(", ")}`);
556
+ }
557
+ /**
558
+ * Statically render an already-built project: load `<cwd>/dist/ssr.js` and run
559
+ * {@link exportProject} over `<cwd>/dist` + `<cwd>/.mech`. Never builds and
560
+ * never loads Vite — this is the entry a hosted render service calls on a
561
+ * bundle a developer pushed (`import { exportBuilt } from 'mechanica/export'`).
562
+ * Warnings are collected into the result and still forwarded to `onWarn`.
563
+ */
564
+ async function exportBuilt(cwd, options = {}) {
565
+ registerFieldSchemas();
566
+ const warnings = [];
567
+ const onWarn = (message) => {
568
+ warnings.push(message);
569
+ (options.onWarn ?? console.warn)(message);
570
+ };
571
+ return {
572
+ pages: await exportProject(cwd, await import(pathToFileURL(join(cwd, "dist/ssr.js")).href), {
573
+ ...options,
574
+ onWarn
575
+ }),
576
+ warnings
577
+ };
578
+ }
579
+ //#endregion
580
+ export { exportBuilt, exportProject };
@@ -53,6 +53,22 @@ export interface ExportOptions {
53
53
  assetsUrl?: string;
54
54
  /** Warning sink (broken links, orphaned assets). Defaults to `console.warn`. */
55
55
  onWarn?: (message: string) => void;
56
+ /**
57
+ * Where the static site is written — absolute, or relative to the project
58
+ * root. Defaults to `export`. The directory is emptied, never removed, so it
59
+ * may be a mount point (a hosted render writes into a volume).
60
+ */
61
+ outDir?: string;
62
+ /**
63
+ * How `useFetch` queries get their JSON: receives the options the block
64
+ * passed (`url` plus any `RequestInit` fields) and resolves with the parsed
65
+ * body. Defaults to a plain `fetch`. A hosted render — which runs with no
66
+ * network — supplies its own, so the request is made (and limited) by the
67
+ * service around it.
68
+ */
69
+ fetchJson?: (options: {
70
+ url: string;
71
+ } & Record<string, unknown>) => Promise<unknown>;
56
72
  }
57
73
  /**
58
74
  * Statically render every page of an already-built project into `export/`,
@@ -72,5 +88,18 @@ export interface ExportOptions {
72
88
  * `sitemap.xml` + `robots.txt`.
73
89
  */
74
90
  export declare function exportProject(cwd: string, ssr: SsrBundle, options?: ExportOptions): Promise<string[]>;
75
- /** Build the project, then statically render every page into `export/`. */
76
- export declare function runExport(options?: ExportOptions): Promise<void>;
91
+ /** What {@link exportBuilt} produced. */
92
+ export interface ExportResult {
93
+ /** The generated page paths, in render order. */
94
+ pages: string[];
95
+ /** Everything the export warned about (broken links, missing uploads, SEO lint). */
96
+ warnings: string[];
97
+ }
98
+ /**
99
+ * Statically render an already-built project: load `<cwd>/dist/ssr.js` and run
100
+ * {@link exportProject} over `<cwd>/dist` + `<cwd>/.mech`. Never builds and
101
+ * never loads Vite — this is the entry a hosted render service calls on a
102
+ * bundle a developer pushed (`import { exportBuilt } from 'mechanica/export'`).
103
+ * Warnings are collected into the result and still forwarded to `onWarn`.
104
+ */
105
+ export declare function exportBuilt(cwd: string, options?: ExportOptions): Promise<ExportResult>;
@@ -0,0 +1,33 @@
1
+ /** A failed platform request: the HTTP status plus the message the platform gave. */
2
+ export declare class PlatformError extends Error {
3
+ status: number;
4
+ constructor(status: number, message: string);
5
+ }
6
+ export interface RequestOptions {
7
+ token?: string | null;
8
+ /** A JSON body… */
9
+ body?: unknown;
10
+ /** …or raw bytes (a bundle). */
11
+ raw?: Uint8Array;
12
+ }
13
+ /** One request to the platform API; resolves with the parsed JSON body (or null). */
14
+ export declare function request<T = any>(origin: string, method: string, path: string, options?: RequestOptions): Promise<T>;
15
+ /** The saved token for `origin`, or an error telling the user to log in. */
16
+ export declare function requireToken(origin: string): Promise<string>;
17
+ export interface PlatformSite {
18
+ uuid: string;
19
+ slug: string;
20
+ name: string;
21
+ role: string;
22
+ gitUrl: string;
23
+ }
24
+ /** The name of the git remote `mechanica link` manages. */
25
+ export declare const REMOTE_NAME = "mechanica";
26
+ /**
27
+ * Split a site's git remote URL (`https://host/git/<slug>.git`) into the
28
+ * platform origin and the slug; null for a URL that is not one of ours.
29
+ */
30
+ export declare function parseRemoteUrl(url: string): {
31
+ origin: string;
32
+ slug: string;
33
+ } | null;