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/README.md +4 -2
- package/bin/mechanica.js +3 -1
- package/dist/cli.js +448 -620
- package/dist/export.js +580 -0
- package/dist/types/cli/export.d.ts +31 -2
- package/dist/types/cli/platform/api.d.ts +33 -0
- package/dist/types/cli/platform/bundle-format.d.ts +40 -0
- package/dist/types/cli/platform/credentials.d.ts +11 -0
- package/dist/types/cli/platform/engine-version.d.ts +6 -0
- package/dist/types/cli/platform/git.d.ts +15 -0
- package/dist/types/cli/platform/link.d.ts +11 -0
- package/dist/types/cli/platform/login.d.ts +9 -0
- package/dist/types/cli/platform/push.d.ts +12 -0
- package/dist/types/cli/run-export.d.ts +7 -0
- package/package.json +6 -2
- package/dist/types/cli/push.d.ts +0 -10
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
|
-
/**
|
|
76
|
-
export
|
|
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;
|