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/cli.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { B as readImageManifest,
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { access, copyFile, cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
1
|
+
import { B as readImageManifest, J as hasSharp, R as isDerivedAsset, U as updateImageManifest, Y as isRasterImage, q as analyzeImageBuffer } from "./chunks/page-assets-CM_MCuoa.js";
|
|
2
|
+
import { exportBuilt } from "./export.js";
|
|
3
|
+
import fs, { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { chmod, mkdir, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises";
|
|
7
6
|
import { build } from "vite";
|
|
8
|
-
import {
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
9
8
|
import os from "node:os";
|
|
10
9
|
import { spawn } from "node:child_process";
|
|
10
|
+
import readline from "node:readline";
|
|
11
11
|
//#region src/cli/args.ts
|
|
12
12
|
/** Parse `argv` (without node/script) into a command, positionals and a `--flag[=value]` map. */
|
|
13
13
|
function parseArgs(argv) {
|
|
@@ -74,562 +74,17 @@ async function runBuild() {
|
|
|
74
74
|
console.info("Build complete → dist/");
|
|
75
75
|
}
|
|
76
76
|
//#endregion
|
|
77
|
-
//#region src/cli/export.ts
|
|
78
|
-
/** A page filename stem ending in `@<locale>` is a translation, handled per-locale. */
|
|
79
|
-
var VARIANT_STEM_RE = /@[A-Za-z][A-Za-z0-9_-]*$/;
|
|
80
|
-
/** Set `<html lang>` unless the template already declares one (author wins). */
|
|
81
|
-
function setHtmlLang(html, lang) {
|
|
82
|
-
if (/<html\b[^>]*\blang\s*=/i.test(html)) return html;
|
|
83
|
-
return html.replace(/<html\b([^>]*)>/i, `<html$1 lang="${lang}">`);
|
|
84
|
-
}
|
|
85
|
-
/** Read every page JSON under `<mech>/pages` as an exportable page. */
|
|
86
|
-
async function readPages(pagesDir, foldersData = {}, richText) {
|
|
87
|
-
let entries;
|
|
88
|
-
try {
|
|
89
|
-
entries = await readdir(pagesDir, { recursive: true });
|
|
90
|
-
} catch {
|
|
91
|
-
return [];
|
|
92
|
-
}
|
|
93
|
-
const EXT = ".page.md";
|
|
94
|
-
const pages = [];
|
|
95
|
-
for (const relative of entries) {
|
|
96
|
-
if (!relative.endsWith(EXT)) continue;
|
|
97
|
-
const parsed = parse(relative);
|
|
98
|
-
const dir = parsed.dir.replace(/\\/g, "/");
|
|
99
|
-
const base = parsed.base.slice(0, -8);
|
|
100
|
-
if (VARIANT_STEM_RE.test(base)) continue;
|
|
101
|
-
const filePath = join(pagesDir, relative);
|
|
102
|
-
const file = parsePage(await readFile(filePath, "utf-8"), richText ? { richText } : void 0);
|
|
103
|
-
if (file.draft) continue;
|
|
104
|
-
const path = `/${dir}/${base === "index" ? "" : base}`.replace("//", "/").replace(/\/$/, "") || "/";
|
|
105
|
-
const data = {
|
|
106
|
-
...dir ? foldersData[dir] : void 0,
|
|
107
|
-
...file.data ?? {}
|
|
108
|
-
};
|
|
109
|
-
const lastmod = typeof file.meta?.lastmod === "string" ? file.meta.lastmod : await stat(filePath).then((s) => s.mtime.toISOString().slice(0, 10)).catch(() => void 0);
|
|
110
|
-
const meta = path === "/404" ? {
|
|
111
|
-
noindex: true,
|
|
112
|
-
...file.meta
|
|
113
|
-
} : file.meta;
|
|
114
|
-
pages.push({
|
|
115
|
-
path,
|
|
116
|
-
logicalPath: path,
|
|
117
|
-
content: file.content ?? [],
|
|
118
|
-
data,
|
|
119
|
-
name: file.name,
|
|
120
|
-
lastmod,
|
|
121
|
-
page: {
|
|
122
|
-
meta,
|
|
123
|
-
...file.layout ? { layout: file.layout } : {}
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
return pages;
|
|
128
|
-
}
|
|
129
|
-
/**
|
|
130
|
-
* Read a page's translation for `locale`, returning an {@link ExportPage} at the
|
|
131
|
-
* locale-prefixed path — or `null` when the translation file is missing or is a
|
|
132
|
-
* per-locale draft. Content/data come from the variant file; folder-scoped data
|
|
133
|
-
* merges the same as the base page.
|
|
134
|
-
*/
|
|
135
|
-
async function readTranslation(mechDir, base, locale, config, richText) {
|
|
136
|
-
const file = getPagePath(mechDir, base.logicalPath, locale);
|
|
137
|
-
let raw;
|
|
138
|
-
try {
|
|
139
|
-
raw = await readFile(file, "utf-8");
|
|
140
|
-
} catch {
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
|
-
const doc = parsePage(raw, richText ? { richText } : void 0);
|
|
144
|
-
if (doc.draft) return null;
|
|
145
|
-
const rel = relative(join(mechDir, "pages"), file).replace(/\\/g, "/");
|
|
146
|
-
const dir = dirname(rel) === "." ? "" : dirname(rel);
|
|
147
|
-
let content = doc.content ?? [];
|
|
148
|
-
let pageData = doc.data ?? {};
|
|
149
|
-
let pageMeta = doc.meta;
|
|
150
|
-
try {
|
|
151
|
-
const baseDoc = parsePage(await readFile(getPagePath(mechDir, base.logicalPath), "utf-8"), richText ? { richText } : void 0);
|
|
152
|
-
const merged = mergeTranslation({
|
|
153
|
-
content: baseDoc.content ?? [],
|
|
154
|
-
data: baseDoc.data ?? {},
|
|
155
|
-
meta: baseDoc.meta ?? {}
|
|
156
|
-
}, {
|
|
157
|
-
content: doc.content ?? [],
|
|
158
|
-
data: doc.data ?? {},
|
|
159
|
-
meta: doc.meta ?? {}
|
|
160
|
-
});
|
|
161
|
-
content = merged.content;
|
|
162
|
-
pageData = merged.data;
|
|
163
|
-
pageMeta = merged.meta;
|
|
164
|
-
} catch {}
|
|
165
|
-
const folderData = readFolderData(mechDir, dir || null, locale);
|
|
166
|
-
const data = {
|
|
167
|
-
...readSiteLocaleOverride(mechDir, locale),
|
|
168
|
-
...folderData,
|
|
169
|
-
...pageData
|
|
170
|
-
};
|
|
171
|
-
const lastmod = typeof doc.meta?.lastmod === "string" ? doc.meta.lastmod : await stat(file).then((s) => s.mtime.toISOString().slice(0, 10)).catch(() => void 0);
|
|
172
|
-
const meta = base.logicalPath === "/404" ? {
|
|
173
|
-
noindex: true,
|
|
174
|
-
...pageMeta
|
|
175
|
-
} : pageMeta;
|
|
176
|
-
return {
|
|
177
|
-
path: localePath(base.logicalPath, locale, config),
|
|
178
|
-
logicalPath: base.logicalPath,
|
|
179
|
-
locale,
|
|
180
|
-
content,
|
|
181
|
-
data,
|
|
182
|
-
name: doc.name ?? base.name,
|
|
183
|
-
lastmod,
|
|
184
|
-
page: {
|
|
185
|
-
meta,
|
|
186
|
-
locale,
|
|
187
|
-
path: base.logicalPath,
|
|
188
|
-
...base.page?.layout ? { layout: base.page.layout } : {}
|
|
189
|
-
}
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
/**
|
|
193
|
-
* The breadcrumb trail for a path — every ancestor (root first, the page last)
|
|
194
|
-
* that actually exists as a page, named by its editor label. Feeds the auto
|
|
195
|
-
* BreadcrumbList JSON-LD.
|
|
196
|
-
*/
|
|
197
|
-
function breadcrumbsFor(path, names) {
|
|
198
|
-
if (path === "/") return [];
|
|
199
|
-
const trail = ["/"];
|
|
200
|
-
let current = "";
|
|
201
|
-
for (const segment of path.slice(1).split("/")) {
|
|
202
|
-
current += `/${segment}`;
|
|
203
|
-
trail.push(current);
|
|
204
|
-
}
|
|
205
|
-
return trail.filter((p) => names.has(p)).map((p) => ({
|
|
206
|
-
name: names.get(p),
|
|
207
|
-
path: p
|
|
208
|
-
}));
|
|
209
|
-
}
|
|
210
|
-
/**
|
|
211
|
-
* Map a baked {@link VirtualPage} to an {@link ExportPage}. The provider emits
|
|
212
|
-
* one per (logical page × locale) and self-declares `locale`/`locales`, so the
|
|
213
|
-
* mapping just localizes the served `path` and carries the alternates — it never
|
|
214
|
-
* goes through the file-based `readTranslation`.
|
|
215
|
-
*/
|
|
216
|
-
function virtualToExportPage(v, config) {
|
|
217
|
-
const isDefault = !config || !v.locale || v.locale === config.default;
|
|
218
|
-
const locale = config ? v.locale ?? config.default : void 0;
|
|
219
|
-
const path = isDefault ? v.path : localePath(v.path, v.locale, config);
|
|
220
|
-
const name = typeof v.meta?.title === "string" ? v.meta.title : void 0;
|
|
221
|
-
return {
|
|
222
|
-
path,
|
|
223
|
-
logicalPath: v.path,
|
|
224
|
-
locale,
|
|
225
|
-
translations: v.locales,
|
|
226
|
-
content: v.content ?? [],
|
|
227
|
-
data: v.data ?? {},
|
|
228
|
-
name,
|
|
229
|
-
lastmod: v.lastmod,
|
|
230
|
-
page: {
|
|
231
|
-
meta: v.meta ?? {},
|
|
232
|
-
locale,
|
|
233
|
-
locales: v.locales,
|
|
234
|
-
path: v.path,
|
|
235
|
-
...v.layout ? { layout: v.layout } : {}
|
|
236
|
-
}
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
/**
|
|
240
|
-
* Load the pieces needed to preload per-page block chunks: the client build's
|
|
241
|
-
* Vite manifest and the plugin's block map. Older builds (or builds outside
|
|
242
|
-
* `mechanica build`) may not have them — links are simply skipped then.
|
|
243
|
-
*/
|
|
244
|
-
async function readBlockAssets(cwd) {
|
|
245
|
-
try {
|
|
246
|
-
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)]);
|
|
247
|
-
return {
|
|
248
|
-
manifest,
|
|
249
|
-
blockFiles
|
|
250
|
-
};
|
|
251
|
-
} catch {
|
|
252
|
-
return null;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
/** Where uploaded assets land in the static export. */
|
|
256
|
-
var MEDIA_DIR = "media";
|
|
257
|
-
/**
|
|
258
|
-
* Rewrite the uploaded-asset URLs a composed-block template carries — a
|
|
259
|
-
* `mech:image` node's `src`, any node's `bgImage` fill — through `onFile`, in
|
|
260
|
-
* the base data and every `$bp` layer. Page data goes through `collectFiles`
|
|
261
|
-
* (schema-driven); templates live in the block definition, so they need their
|
|
262
|
-
* own pass. Mutates the defs in place: they are the same objects the SSR
|
|
263
|
-
* runtime's registered components render from.
|
|
264
|
-
*/
|
|
265
|
-
function rewriteComposedAssets(defs, onFile) {
|
|
266
|
-
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
267
|
-
const rewriteLayer = (blockId, layer) => {
|
|
268
|
-
if (blockId === "mech:image" && typeof layer.src === "string") layer.src = onFile(layer.src);
|
|
269
|
-
const bg = layer.bgImage;
|
|
270
|
-
if (isRecord(bg)) {
|
|
271
|
-
if (typeof bg.src === "string") bg.src = onFile(bg.src);
|
|
272
|
-
if (typeof bg.croppedSrc === "string") bg.croppedSrc = onFile(bg.croppedSrc);
|
|
273
|
-
}
|
|
274
|
-
};
|
|
275
|
-
for (const def of defs) walkTree(def.template ?? [], (node) => {
|
|
276
|
-
if (!node.data) return;
|
|
277
|
-
rewriteLayer(node.blockId, node.data);
|
|
278
|
-
const bp = node.data.$bp;
|
|
279
|
-
if (isRecord(bp)) {
|
|
280
|
-
for (const layer of Object.values(bp)) if (isRecord(layer)) rewriteLayer(node.blockId, layer);
|
|
281
|
-
}
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
/** Fill a value's missing preview/dimensions from a manifest entry. */
|
|
285
|
-
function fillImageValue(value, entry) {
|
|
286
|
-
if (entry.previewSrc && (!value.previewSrc || value.previewSrc === value.src)) value.previewSrc = entry.previewSrc;
|
|
287
|
-
if (entry.width && !value.width) value.width = entry.width;
|
|
288
|
-
if (entry.height && !value.height) value.height = entry.height;
|
|
289
|
-
}
|
|
290
|
-
/**
|
|
291
|
-
* Fill missing image metadata — LQIP `previewSrc` + intrinsic dimensions —
|
|
292
|
-
* into the state being exported (page files are never touched). Sources, in
|
|
293
|
-
* order: the `.mech/images.json` manifest (written by uploads, editor saves
|
|
294
|
-
* and `mechanica images`), then the optional `sharp` dependency for anything
|
|
295
|
-
* still missing — those computed entries are cached back into the manifest.
|
|
296
|
-
* Without sharp, leftover gaps get a one-time hint.
|
|
297
|
-
*/
|
|
298
|
-
async function backfillImageMeta(pages, blocksMap, mechDir, warn) {
|
|
299
|
-
const manifest = readImageManifest(mechDir);
|
|
300
|
-
const incomplete = /* @__PURE__ */ new Map();
|
|
301
|
-
for (const page of pages) walkTree(page.content, (block) => {
|
|
302
|
-
const meta = blocksMap.get(block.blockId);
|
|
303
|
-
if (!meta) return;
|
|
304
|
-
walkSchema(block.data, meta.props, (value, schema) => {
|
|
305
|
-
if (schema.format !== "image") return;
|
|
306
|
-
const file = assetFileOf(value?.src);
|
|
307
|
-
if (!file) return;
|
|
308
|
-
const entry = manifest[file];
|
|
309
|
-
if (entry) fillImageValue(value, entry);
|
|
310
|
-
if (!value.previewSrc || value.previewSrc === value.src || !value.width || !value.height) incomplete.set(file, [...incomplete.get(file) ?? [], value]);
|
|
311
|
-
});
|
|
312
|
-
});
|
|
313
|
-
if (!incomplete.size) return;
|
|
314
|
-
if (!await hasSharp()) {
|
|
315
|
-
warn(`[mechanica] ${incomplete.size} image(s) lack a preview or dimensions — install the optional "sharp" dependency or run \`mechanica images\` to generate them`);
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
const computed = {};
|
|
319
|
-
for (const [file, values] of incomplete) {
|
|
320
|
-
const buffer = await readFile(join(mechDir, "assets", file)).catch(() => null);
|
|
321
|
-
const info = buffer ? await analyzeImageBuffer(buffer) : null;
|
|
322
|
-
if (!info) continue;
|
|
323
|
-
computed[file] = info;
|
|
324
|
-
for (const value of values) fillImageValue(value, info);
|
|
325
|
-
}
|
|
326
|
-
updateImageManifest(mechDir, computed);
|
|
327
|
-
}
|
|
77
|
+
//#region src/cli/run-export.ts
|
|
328
78
|
/**
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
* expects `<cwd>/dist` (index.html, assets, the loaded `ssr` bundle) and that
|
|
333
|
-
* field schemas have been registered; it reads pages + scoped data from
|
|
334
|
-
* `<cwd>/.mech`.
|
|
335
|
-
*
|
|
336
|
-
* Beyond the pages it also: rewrites + copies uploaded assets
|
|
337
|
-
* (`/@mechanica/assets/…` → `/media/…`, or `<assetsUrl>/media/…` when a CDN
|
|
338
|
-
* base is set), warns about dead internal links,
|
|
339
|
-
* orphaned uploads and page-level SEO issues, emits `404.html` when a `/404`
|
|
340
|
-
* page exists, and — when a site url is known (plugin `siteUrl` option or
|
|
341
|
-
* `--site-url`) — injects the automatic SEO tags (canonical, `og:url`,
|
|
342
|
-
* absolute social-image URLs, pagination prev/next, JSON-LD) and emits
|
|
343
|
-
* `sitemap.xml` + `robots.txt`.
|
|
79
|
+
* `mechanica export`: build the project, then statically render every page
|
|
80
|
+
* into `export/`. Kept apart from `export.ts` so that module never imports
|
|
81
|
+
* Vite — `mechanica/export` runs where only a built bundle exists.
|
|
344
82
|
*/
|
|
345
|
-
async function exportProject(cwd, ssr, options = {}) {
|
|
346
|
-
const warn = options.onWarn ?? ((message) => console.warn(message));
|
|
347
|
-
const index = await readFile(join(cwd, "dist/index.html"), "utf-8");
|
|
348
|
-
const siteUrl = options.siteUrl ?? ssr.site?.url;
|
|
349
|
-
const siteName = options.siteName ?? ssr.site?.name;
|
|
350
|
-
const assetsBase = (options.assetsUrl ?? ssr.assetsUrl ?? "").replace(/\/+$/, "");
|
|
351
|
-
const composedDefs = ssr.composedList ?? [];
|
|
352
|
-
const blocks = [...ssr.blocksList.map(toBlockMeta), ...composedDefs.map(composedBlockMeta)];
|
|
353
|
-
const blocksMap = new Map(blocks.map((block) => [block.id, block]));
|
|
354
|
-
const composedMap = new Map(composedDefs.map((def) => [def.id, def]));
|
|
355
|
-
const richText = buildRichTextCodec(ssr.blocksList);
|
|
356
|
-
const foldersData = readFoldersData(join(cwd, ".mech"));
|
|
357
|
-
const defaultPages = await readPages(join(cwd, ".mech/pages"), foldersData, richText);
|
|
358
|
-
const projectData = readSiteData(join(cwd, ".mech"));
|
|
359
|
-
const config = ssr.locales ?? null;
|
|
360
|
-
const mechDir = join(cwd, ".mech");
|
|
361
|
-
const pages = [...defaultPages];
|
|
362
|
-
if (config) {
|
|
363
|
-
const missing = {};
|
|
364
|
-
for (const base of defaultPages) {
|
|
365
|
-
const present = [config.default];
|
|
366
|
-
for (const locale of config.all) {
|
|
367
|
-
if (locale === config.default) continue;
|
|
368
|
-
const translation = await readTranslation(mechDir, base, locale, config, richText);
|
|
369
|
-
if (translation) {
|
|
370
|
-
present.push(locale);
|
|
371
|
-
pages.push(translation);
|
|
372
|
-
} else if (!base.page?.meta?.noindex) (missing[locale] ??= []).push(base.logicalPath);
|
|
373
|
-
}
|
|
374
|
-
base.translations = present;
|
|
375
|
-
if (base.page) base.locale = config.default;
|
|
376
|
-
base.page = {
|
|
377
|
-
...base.page,
|
|
378
|
-
locale: config.default,
|
|
379
|
-
locales: present
|
|
380
|
-
};
|
|
381
|
-
}
|
|
382
|
-
const translationsByLogical = new Map(defaultPages.map((p) => [p.logicalPath, p.translations]));
|
|
383
|
-
for (const page of pages) if (page.locale && page.locale !== config.default) {
|
|
384
|
-
const present = translationsByLogical.get(page.logicalPath);
|
|
385
|
-
page.translations = present;
|
|
386
|
-
page.page = {
|
|
387
|
-
...page.page,
|
|
388
|
-
locales: present
|
|
389
|
-
};
|
|
390
|
-
}
|
|
391
|
-
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(", ")}`);
|
|
392
|
-
}
|
|
393
|
-
if (ssr.generatedPages?.length) {
|
|
394
|
-
const taken = new Set(pages.map((page) => page.path));
|
|
395
|
-
for (const vp of ssr.generatedPages) {
|
|
396
|
-
const generated = virtualToExportPage(vp, config);
|
|
397
|
-
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.`);
|
|
398
|
-
taken.add(generated.path);
|
|
399
|
-
pages.push(generated);
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
setPageCodec(richText);
|
|
403
|
-
const querySource = {
|
|
404
|
-
listPages: (opts) => listPages(join(cwd, ".mech"), {
|
|
405
|
-
...opts,
|
|
406
|
-
generated: ssr.generatedPages
|
|
407
|
-
}),
|
|
408
|
-
fetchJson: async ({ url, ...init }) => {
|
|
409
|
-
const res = await fetch(url, init);
|
|
410
|
-
if (!res.ok) throw new Error(`${url} responded ${res.status}`);
|
|
411
|
-
return res.json();
|
|
412
|
-
}
|
|
413
|
-
};
|
|
414
|
-
const queryCache = /* @__PURE__ */ new Map();
|
|
415
|
-
const resolveForPage = (pageNumber, pageLocale) => (key) => {
|
|
416
|
-
const locale = config && pageLocale && pageLocale !== config.default ? pageLocale : void 0;
|
|
417
|
-
const cacheKey = `${pageNumber} ${locale ?? ""} ${key}`;
|
|
418
|
-
let hit = queryCache.get(cacheKey);
|
|
419
|
-
if (!hit) {
|
|
420
|
-
hit = resolveQueryKey(querySource, key, {
|
|
421
|
-
page: pageNumber,
|
|
422
|
-
locale
|
|
423
|
-
});
|
|
424
|
-
queryCache.set(cacheKey, hit);
|
|
425
|
-
}
|
|
426
|
-
return hit;
|
|
427
|
-
};
|
|
428
|
-
for (const page of pages) {
|
|
429
|
-
migrateContent(page.content, blocksMap);
|
|
430
|
-
for (const id of findUnknownBlocks(page.content, blocksMap)) warn(`[mechanica] Page ${page.path} references unknown block "${id}" — it renders as nothing`);
|
|
431
|
-
}
|
|
432
|
-
if (ssr.layoutNames) {
|
|
433
|
-
const layoutNames = ssr.layoutNames;
|
|
434
|
-
const seen = /* @__PURE__ */ new Set();
|
|
435
|
-
for (const page of pages) {
|
|
436
|
-
const layout = page.page?.layout;
|
|
437
|
-
if (!layout || layoutNames.includes(layout) || seen.has(page.logicalPath)) continue;
|
|
438
|
-
seen.add(page.logicalPath);
|
|
439
|
-
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(", ")})`);
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
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).");
|
|
443
|
-
for (const issue of validateLinks(pages, blocksMap)) warn(`[mechanica] Broken link on ${issue.page}: ${issue.url} has no matching page`);
|
|
444
|
-
await backfillImageMeta(pages, blocksMap, join(cwd, ".mech"), warn);
|
|
445
|
-
const exportDir = join(cwd, "export");
|
|
446
|
-
await rm(exportDir, {
|
|
447
|
-
recursive: true,
|
|
448
|
-
force: true
|
|
449
|
-
});
|
|
450
|
-
await mkdir(exportDir, { recursive: true });
|
|
451
|
-
const DIST_PRIVATE = /* @__PURE__ */ new Set([
|
|
452
|
-
"index.html",
|
|
453
|
-
"ssr.js",
|
|
454
|
-
"ssr.js.map",
|
|
455
|
-
".vite",
|
|
456
|
-
BLOCKS_MANIFEST_FILE
|
|
457
|
-
]);
|
|
458
|
-
const distEntries = await readdir(join(cwd, "dist")).catch(() => []);
|
|
459
|
-
for (const entry of distEntries) {
|
|
460
|
-
if (DIST_PRIVATE.has(entry)) continue;
|
|
461
|
-
await cp(join(cwd, "dist", entry), join(exportDir, entry), { recursive: true });
|
|
462
|
-
}
|
|
463
|
-
const referenced = /* @__PURE__ */ new Set();
|
|
464
|
-
const onFile = (src) => {
|
|
465
|
-
if (typeof src !== "string" || !src.startsWith("/@mechanica/assets/")) return src;
|
|
466
|
-
const relative = decodeURIComponent(src.slice(UPLOADS_PREFIX.length));
|
|
467
|
-
referenced.add(relative);
|
|
468
|
-
return `${assetsBase}/${MEDIA_DIR}/${relative}`;
|
|
469
|
-
};
|
|
470
|
-
rewriteComposedAssets(composedDefs, onFile);
|
|
471
|
-
const blockAssets = await readBlockAssets(cwd);
|
|
472
|
-
const pageLinks = blockAssets ? (content) => {
|
|
473
|
-
const blockIds = /* @__PURE__ */ new Set();
|
|
474
|
-
const add = (id) => {
|
|
475
|
-
if (blockIds.has(id)) return;
|
|
476
|
-
blockIds.add(id);
|
|
477
|
-
const def = composedMap.get(id);
|
|
478
|
-
if (def) for (const inner of templateBlockIds(def)) add(inner);
|
|
479
|
-
};
|
|
480
|
-
walkTree(content, (block) => add(block.blockId));
|
|
481
|
-
return blockAssetLinks({
|
|
482
|
-
blockIds,
|
|
483
|
-
...blockAssets,
|
|
484
|
-
base: assetsBase || void 0,
|
|
485
|
-
alreadyLinked: (file) => index.includes(file)
|
|
486
|
-
});
|
|
487
|
-
} : void 0;
|
|
488
|
-
const projectOptions = {
|
|
489
|
-
index,
|
|
490
|
-
blocksMap,
|
|
491
|
-
dataEntries: ssr.dataEntries ?? [],
|
|
492
|
-
projectData,
|
|
493
|
-
site: {
|
|
494
|
-
url: siteUrl,
|
|
495
|
-
name: siteName
|
|
496
|
-
},
|
|
497
|
-
assetsUrl: assetsBase || void 0,
|
|
498
|
-
locales: config ?? void 0,
|
|
499
|
-
render: (state) => ssr.render(state, { resolveQuery: resolveForPage(state.page?.pagination?.page ?? 1, state.page?.locale) }),
|
|
500
|
-
onFile,
|
|
501
|
-
pageLinks
|
|
502
|
-
};
|
|
503
|
-
const written = [];
|
|
504
|
-
const writePage = async (path, html) => {
|
|
505
|
-
const dir = path === "/" ? exportDir : join(exportDir, path);
|
|
506
|
-
await mkdir(dir, { recursive: true });
|
|
507
|
-
await writeFile(join(dir, "index.html"), html);
|
|
508
|
-
written.push(path);
|
|
509
|
-
};
|
|
510
|
-
const templateHandlesPagination = index.includes("page.pagination");
|
|
511
|
-
const pageNames = new Map(defaultPages.map((page) => [page.logicalPath, page.name ?? (page.logicalPath === "/" ? "Home" : page.logicalPath.split("/").pop())]));
|
|
512
|
-
for (const vp of ssr.generatedPages ?? []) pageNames.set(vp.path, typeof vp.meta?.title === "string" ? vp.meta.title : vp.path.split("/").pop());
|
|
513
|
-
/** hreflang alternates for a translated page (each locale + x-default), at page N. */
|
|
514
|
-
const alternatesFor = (source, pageNum = 1) => {
|
|
515
|
-
if (!config || !source.translations || source.translations.length < 2) return void 0;
|
|
516
|
-
const at = (loc) => paginationVariantPath(localePath(source.logicalPath, loc, config), pageNum);
|
|
517
|
-
return [{
|
|
518
|
-
hreflang: "x-default",
|
|
519
|
-
path: at(config.default)
|
|
520
|
-
}, ...source.translations.map((loc) => ({
|
|
521
|
-
hreflang: loc,
|
|
522
|
-
path: at(loc)
|
|
523
|
-
}))];
|
|
524
|
-
};
|
|
525
|
-
const finishPage = (html, path, source, pagination) => {
|
|
526
|
-
let out = applySeoTags(html, {
|
|
527
|
-
siteUrl,
|
|
528
|
-
siteName,
|
|
529
|
-
path,
|
|
530
|
-
noindex: source.page?.meta?.noindex === true,
|
|
531
|
-
pagination,
|
|
532
|
-
templateHandlesPagination,
|
|
533
|
-
breadcrumbs: breadcrumbsFor(source.logicalPath, pageNames),
|
|
534
|
-
alternates: alternatesFor(source, pagination?.page ?? 1)
|
|
535
|
-
});
|
|
536
|
-
if (config) out = setHtmlLang(out, source.locale ?? config.default);
|
|
537
|
-
return out;
|
|
538
|
-
};
|
|
539
|
-
const sitemapEntries = [];
|
|
540
|
-
const recordSitemap = (path, source, pageNum = 1) => {
|
|
541
|
-
if (path === "/404" || source.page?.meta?.noindex === true) return;
|
|
542
|
-
sitemapEntries.push({
|
|
543
|
-
path,
|
|
544
|
-
lastmod: source.lastmod,
|
|
545
|
-
alternates: alternatesFor(source, pageNum)
|
|
546
|
-
});
|
|
547
|
-
};
|
|
548
|
-
const knownPaths = new Set(pages.map((page) => page.path));
|
|
549
|
-
const pageByPath = new Map(pages.map((page) => [page.path, page]));
|
|
550
|
-
const variants = [];
|
|
551
|
-
const titlePages = /* @__PURE__ */ new Map();
|
|
552
|
-
for await (const { html, path, query } of generateProject({
|
|
553
|
-
...projectOptions,
|
|
554
|
-
pages
|
|
555
|
-
})) {
|
|
556
|
-
const source = pageByPath.get(path);
|
|
557
|
-
let pageCount = 1;
|
|
558
|
-
for (const [key, result] of Object.entries(query)) {
|
|
559
|
-
if (!isPaginatedQuery(key)) continue;
|
|
560
|
-
pageCount = Math.max(pageCount, result?.pageCount ?? 1);
|
|
561
|
-
}
|
|
562
|
-
const pagination = pageCount > 1 ? {
|
|
563
|
-
page: 1,
|
|
564
|
-
pageCount,
|
|
565
|
-
basePath: path
|
|
566
|
-
} : void 0;
|
|
567
|
-
const finished = finishPage(html, path, source, pagination);
|
|
568
|
-
await writePage(path, finished);
|
|
569
|
-
recordSitemap(path, source);
|
|
570
|
-
for (const issue of auditPageHtml(finished)) warn(`[mechanica] SEO on ${path}: ${issue}`);
|
|
571
|
-
const title = finished.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.trim();
|
|
572
|
-
if (title) titlePages.set(title, [...titlePages.get(title) ?? [], path]);
|
|
573
|
-
if (!pagination) continue;
|
|
574
|
-
const base = path === "/" ? "" : path;
|
|
575
|
-
for (let n = 2; n <= pageCount; n++) {
|
|
576
|
-
const variantPath = `${base}/${n}`;
|
|
577
|
-
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.`);
|
|
578
|
-
variants.push({
|
|
579
|
-
...source,
|
|
580
|
-
path: variantPath,
|
|
581
|
-
page: {
|
|
582
|
-
...source.page,
|
|
583
|
-
pagination: {
|
|
584
|
-
page: n,
|
|
585
|
-
pageCount
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
});
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
for (const [title, paths] of titlePages) if (paths.length > 1) warn(`[mechanica] SEO: ${paths.length} pages share the title "${title}": ${paths.join(", ")}`);
|
|
592
|
-
for await (const { html, path } of generateProject({
|
|
593
|
-
...projectOptions,
|
|
594
|
-
pages: variants
|
|
595
|
-
})) {
|
|
596
|
-
const source = variants.find((page) => page.path === path);
|
|
597
|
-
const basePath = path.replace(/\/\d+$/, "") || "/";
|
|
598
|
-
await writePage(path, finishPage(html, path, source, {
|
|
599
|
-
...source.page.pagination,
|
|
600
|
-
basePath
|
|
601
|
-
}));
|
|
602
|
-
recordSitemap(path, source, source.page.pagination.page);
|
|
603
|
-
}
|
|
604
|
-
await copyUploads(join(cwd, ".mech/assets"), join(exportDir, MEDIA_DIR), referenced, warn);
|
|
605
|
-
if (written.includes("/404")) await copyFile(join(exportDir, "404/index.html"), join(exportDir, "404.html"));
|
|
606
|
-
if (siteUrl) {
|
|
607
|
-
await writeFile(join(exportDir, "sitemap.xml"), buildSitemap(siteUrl, sitemapEntries));
|
|
608
|
-
const robotsPath = join(exportDir, "robots.txt");
|
|
609
|
-
if (!await access(robotsPath).then(() => true, () => false)) await writeFile(robotsPath, buildRobotsTxt(siteUrl));
|
|
610
|
-
}
|
|
611
|
-
return written;
|
|
612
|
-
}
|
|
613
|
-
/** Copy referenced uploads into the export; warn about missing + orphaned files. */
|
|
614
|
-
async function copyUploads(assetsDir, outDir, referenced, warn) {
|
|
615
|
-
for (const relative of referenced) {
|
|
616
|
-
const target = join(outDir, relative);
|
|
617
|
-
await mkdir(dirname(target), { recursive: true });
|
|
618
|
-
await copyFile(join(assetsDir, relative), target).catch(() => {
|
|
619
|
-
warn(`[mechanica] Missing upload: ${relative} is referenced by a page but not in .mech/assets`);
|
|
620
|
-
});
|
|
621
|
-
}
|
|
622
|
-
const orphans = (await readdir(assetsDir, { recursive: true }).catch(() => [])).map((entry) => entry.replace(/\\/g, "/")).filter((entry) => /\.\w+$/.test(entry) && !referenced.has(entry) && !isDerivedAsset(entry));
|
|
623
|
-
if (orphans.length) warn(`[mechanica] ${orphans.length} unused upload(s) in .mech/assets (not exported): ${orphans.join(", ")}`);
|
|
624
|
-
}
|
|
625
|
-
/** Build the project, then statically render every page into `export/`. */
|
|
626
83
|
async function runExport(options = {}) {
|
|
627
84
|
await runBuild();
|
|
628
|
-
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
for (const path of written) console.info("Generated", path);
|
|
632
|
-
console.info(`Exported ${written.length} page(s) → export/`);
|
|
85
|
+
const { pages } = await exportBuilt(process.cwd(), options);
|
|
86
|
+
for (const path of pages) console.info("Generated", path);
|
|
87
|
+
console.info(`Exported ${pages.length} page(s) → ${options.outDir ?? "export"}/`);
|
|
633
88
|
}
|
|
634
89
|
//#endregion
|
|
635
90
|
//#region src/cli/images.ts
|
|
@@ -673,71 +128,433 @@ async function runImages(options = {}) {
|
|
|
673
128
|
const summary = skipped ? `, ${skipped} already complete` : "";
|
|
674
129
|
console.info(`Analyzed ${Object.keys(computed).length} image(s)${summary} → .mech/images.json`);
|
|
675
130
|
}
|
|
131
|
+
/** Normalize a host argument to an origin (`https://example.com`, no trailing slash or path). */
|
|
132
|
+
function hostOrigin(host) {
|
|
133
|
+
const value = host ?? process.env.MECHANICA_HOST ?? "https://mechanica.jt3.ru";
|
|
134
|
+
return new URL(/^https?:\/\//.test(value) ? value : `https://${value}`).origin;
|
|
135
|
+
}
|
|
136
|
+
/** Where access tokens are kept: one file per user, outside any project. */
|
|
137
|
+
function credentialsFile() {
|
|
138
|
+
return join(process.env.MECHANICA_CONFIG_DIR ?? join(os.homedir(), ".mechanica"), "credentials.json");
|
|
139
|
+
}
|
|
140
|
+
async function readAll() {
|
|
141
|
+
try {
|
|
142
|
+
const parsed = JSON.parse(await readFile(credentialsFile(), "utf-8"));
|
|
143
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
144
|
+
} catch {
|
|
145
|
+
return {};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The access token for a platform origin: `MECHANICA_TOKEN` (CI) wins over the
|
|
150
|
+
* one `mechanica login` saved.
|
|
151
|
+
*/
|
|
152
|
+
async function getToken(origin) {
|
|
153
|
+
if (process.env.MECHANICA_TOKEN) return process.env.MECHANICA_TOKEN;
|
|
154
|
+
return (await readAll())[origin] ?? null;
|
|
155
|
+
}
|
|
156
|
+
/** Remember the token for an origin, in a file only the user can read. */
|
|
157
|
+
async function saveToken(origin, token) {
|
|
158
|
+
const file = credentialsFile();
|
|
159
|
+
const all = await readAll();
|
|
160
|
+
all[origin] = token;
|
|
161
|
+
await mkdir(join(file, ".."), { recursive: true });
|
|
162
|
+
await writeFile(file, JSON.stringify(all, null, 2) + "\n", { mode: 384 });
|
|
163
|
+
await chmod(file, 384).catch(() => {});
|
|
164
|
+
return file;
|
|
165
|
+
}
|
|
676
166
|
//#endregion
|
|
677
|
-
//#region src/cli/
|
|
167
|
+
//#region src/cli/platform/api.ts
|
|
168
|
+
/** A failed platform request: the HTTP status plus the message the platform gave. */
|
|
169
|
+
var PlatformError = class extends Error {
|
|
170
|
+
status;
|
|
171
|
+
constructor(status, message) {
|
|
172
|
+
super(message);
|
|
173
|
+
this.status = status;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
/** The platform answers errors as plain text or as `{ error: { field: { message } } }`. */
|
|
177
|
+
function errorMessage(status, text) {
|
|
178
|
+
try {
|
|
179
|
+
const error = JSON.parse(text)?.error;
|
|
180
|
+
if (typeof error === "string") return error;
|
|
181
|
+
if (error && typeof error === "object") {
|
|
182
|
+
const messages = Object.values(error).map((item) => item?.message).filter((message) => typeof message === "string");
|
|
183
|
+
if (messages.length) return messages.join("; ");
|
|
184
|
+
}
|
|
185
|
+
} catch {}
|
|
186
|
+
return text.trim() || `Request failed (${status})`;
|
|
187
|
+
}
|
|
188
|
+
/** One request to the platform API; resolves with the parsed JSON body (or null). */
|
|
189
|
+
async function request(origin, method, path, options = {}) {
|
|
190
|
+
const headers = {};
|
|
191
|
+
if (options.token) headers.authorization = `Bearer ${options.token}`;
|
|
192
|
+
if (options.raw) headers["content-type"] = "application/octet-stream";
|
|
193
|
+
else if (options.body !== void 0) headers["content-type"] = "application/json";
|
|
194
|
+
let response;
|
|
195
|
+
try {
|
|
196
|
+
response = await fetch(origin + path, {
|
|
197
|
+
method,
|
|
198
|
+
headers,
|
|
199
|
+
body: options.raw ?? (options.body !== void 0 ? JSON.stringify(options.body) : void 0)
|
|
200
|
+
});
|
|
201
|
+
} catch (error) {
|
|
202
|
+
throw new PlatformError(0, `Could not reach ${origin} (${error.message})`);
|
|
203
|
+
}
|
|
204
|
+
const text = await response.text();
|
|
205
|
+
if (!response.ok) throw new PlatformError(response.status, errorMessage(response.status, text));
|
|
206
|
+
return text ? JSON.parse(text) : null;
|
|
207
|
+
}
|
|
208
|
+
/** The saved token for `origin`, or an error telling the user to log in. */
|
|
209
|
+
async function requireToken(origin) {
|
|
210
|
+
const token = await getToken(origin);
|
|
211
|
+
if (!token) throw new Error(`Not logged in to ${origin} — run \`mechanica login\` first`);
|
|
212
|
+
return token;
|
|
213
|
+
}
|
|
214
|
+
/** The name of the git remote `mechanica link` manages. */
|
|
215
|
+
var REMOTE_NAME = "mechanica";
|
|
678
216
|
/**
|
|
679
|
-
*
|
|
680
|
-
*
|
|
681
|
-
* keeps working; the contract is redesigned later with the SAAS service.
|
|
217
|
+
* Split a site's git remote URL (`https://host/git/<slug>.git`) into the
|
|
218
|
+
* platform origin and the slug; null for a URL that is not one of ours.
|
|
682
219
|
*/
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
220
|
+
function parseRemoteUrl(url) {
|
|
221
|
+
try {
|
|
222
|
+
const parsed = new URL(url);
|
|
223
|
+
const match = /^\/git\/([a-z0-9][a-z0-9-]*[a-z0-9])\.git\/?$/.exec(parsed.pathname);
|
|
224
|
+
if (!match || !/^https?:$/.test(parsed.protocol)) return null;
|
|
225
|
+
return {
|
|
226
|
+
origin: parsed.origin,
|
|
227
|
+
slug: match[1]
|
|
228
|
+
};
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
690
231
|
}
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
232
|
+
}
|
|
233
|
+
//#endregion
|
|
234
|
+
//#region src/cli/platform/git.ts
|
|
235
|
+
/**
|
|
236
|
+
* Environment that makes git send the access token to one origin only, as HTTP
|
|
237
|
+
* Basic credentials. Passed through `GIT_CONFIG_*` variables so the token never
|
|
238
|
+
* appears on a command line (visible to other processes) or in the repo config.
|
|
239
|
+
*/
|
|
240
|
+
function gitAuthEnv(origin, token) {
|
|
241
|
+
const basic = Buffer.from(`mechanica:${token}`).toString("base64");
|
|
242
|
+
return {
|
|
243
|
+
GIT_CONFIG_COUNT: "1",
|
|
244
|
+
GIT_CONFIG_KEY_0: `http.${origin}/.extraHeader`,
|
|
245
|
+
GIT_CONFIG_VALUE_0: `Authorization: Basic ${basic}`,
|
|
246
|
+
GIT_TERMINAL_PROMPT: "0"
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/** Run git in `cwd` and collect its output; never throws on a non-zero exit. */
|
|
250
|
+
function git(cwd, args, env = {}) {
|
|
251
|
+
return new Promise((resolve, reject) => {
|
|
252
|
+
const child = spawn("git", args, {
|
|
253
|
+
cwd,
|
|
254
|
+
env: {
|
|
255
|
+
...process.env,
|
|
256
|
+
...env
|
|
257
|
+
},
|
|
258
|
+
windowsHide: true
|
|
259
|
+
});
|
|
260
|
+
let stdout = "";
|
|
261
|
+
let stderr = "";
|
|
262
|
+
child.stdout.on("data", (chunk) => stdout += chunk);
|
|
263
|
+
child.stderr.on("data", (chunk) => stderr += chunk);
|
|
264
|
+
child.on("error", (error) => reject(error.code === "ENOENT" ? /* @__PURE__ */ new Error("git is not installed (or not on PATH)") : error));
|
|
265
|
+
child.on("close", (code) => resolve({
|
|
266
|
+
code: code ?? 1,
|
|
267
|
+
stdout: stdout.trim(),
|
|
268
|
+
stderr: stderr.trim()
|
|
269
|
+
}));
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
/** Like {@link git}, but a non-zero exit throws with git's own message. */
|
|
273
|
+
async function gitOk(cwd, args, env = {}) {
|
|
274
|
+
const result = await git(cwd, args, env);
|
|
275
|
+
if (result.code !== 0) throw new Error(result.stderr || result.stdout || `git ${args[0]} failed`);
|
|
276
|
+
return result.stdout;
|
|
277
|
+
}
|
|
278
|
+
//#endregion
|
|
279
|
+
//#region src/cli/platform/link.ts
|
|
280
|
+
/**
|
|
281
|
+
* `mechanica link <slug>`: attach this project to a site on the platform by
|
|
282
|
+
* pointing the `mechanica` git remote at it. With `--create` the site is made
|
|
283
|
+
* first. The remote is the whole link — `mechanica push` reads the platform
|
|
284
|
+
* origin and the site from its URL, so there is no extra config file.
|
|
285
|
+
*/
|
|
286
|
+
async function runLink(slug, options) {
|
|
287
|
+
if (!slug) throw new Error("Usage: mechanica link <slug> [--create] [--name \"Site name\"]");
|
|
288
|
+
const cwd = process.cwd();
|
|
289
|
+
const origin = hostOrigin(options.host);
|
|
290
|
+
const token = await requireToken(origin);
|
|
291
|
+
if ((await git(cwd, ["rev-parse", "--is-inside-work-tree"])).code !== 0) throw new Error("This folder is not a git repository — run `git init` first");
|
|
292
|
+
const sites = await request(origin, "GET", "/api/sites", { token });
|
|
293
|
+
let site = sites.find((item) => item.slug === slug);
|
|
294
|
+
if (!site) {
|
|
295
|
+
if (!options.create) {
|
|
296
|
+
const known = sites.length ? `Your sites: ${sites.map((item) => item.slug).join(", ")}.` : "You have no sites yet.";
|
|
297
|
+
throw new Error(`No site "${slug}" on ${origin}. ${known} Pass --create to make it.`);
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
site = await request(origin, "POST", "/api/sites", {
|
|
301
|
+
token,
|
|
302
|
+
body: {
|
|
303
|
+
slug,
|
|
304
|
+
name: options.name ?? slug
|
|
305
|
+
}
|
|
704
306
|
});
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
type: "file",
|
|
709
|
-
name,
|
|
710
|
-
size: info.size,
|
|
711
|
-
mtime: info.mtimeMs,
|
|
712
|
-
offset: written,
|
|
713
|
-
length: file.byteLength
|
|
714
|
-
});
|
|
715
|
-
buffers.push(file);
|
|
716
|
-
written += file.byteLength;
|
|
717
|
-
}
|
|
307
|
+
} catch (error) {
|
|
308
|
+
if (error instanceof PlatformError) throw new Error(`Could not create "${slug}": ${error.message}`);
|
|
309
|
+
throw error;
|
|
718
310
|
}
|
|
719
|
-
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
311
|
+
console.info(`Created site ${site.slug}`);
|
|
312
|
+
}
|
|
313
|
+
if ((await git(cwd, [
|
|
314
|
+
"remote",
|
|
315
|
+
"get-url",
|
|
316
|
+
"mechanica"
|
|
317
|
+
])).code === 0) await gitOk(cwd, [
|
|
318
|
+
"remote",
|
|
319
|
+
"set-url",
|
|
320
|
+
REMOTE_NAME,
|
|
321
|
+
site.gitUrl
|
|
728
322
|
]);
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
323
|
+
else await gitOk(cwd, [
|
|
324
|
+
"remote",
|
|
325
|
+
"add",
|
|
326
|
+
REMOTE_NAME,
|
|
327
|
+
site.gitUrl
|
|
328
|
+
]);
|
|
329
|
+
console.info(`Linked to ${site.slug} (${site.gitUrl})`);
|
|
330
|
+
console.info("Commit your work, then run `mechanica push` to deploy it.");
|
|
331
|
+
}
|
|
332
|
+
//#endregion
|
|
333
|
+
//#region src/cli/platform/login.ts
|
|
334
|
+
/** Ask one line on the terminal; with `hidden`, the typed characters are not echoed. */
|
|
335
|
+
function prompt(question, hidden = false) {
|
|
336
|
+
return new Promise((resolve) => {
|
|
337
|
+
const rl = readline.createInterface({
|
|
338
|
+
input: process.stdin,
|
|
339
|
+
output: process.stdout,
|
|
340
|
+
terminal: true
|
|
341
|
+
});
|
|
342
|
+
if (hidden) {
|
|
343
|
+
const write = rl._writeToOutput.bind(rl);
|
|
344
|
+
let asked = false;
|
|
345
|
+
rl._writeToOutput = (text) => {
|
|
346
|
+
if (!asked) write(text);
|
|
347
|
+
asked = true;
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
rl.question(question, (answer) => {
|
|
351
|
+
rl.close();
|
|
352
|
+
if (hidden) process.stdout.write("\n");
|
|
353
|
+
resolve(answer.trim());
|
|
354
|
+
});
|
|
737
355
|
});
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* `mechanica login`: sign in to the platform and keep an access token for this
|
|
359
|
+
* machine. With `--token` the given token is stored as is (CI, or a token made
|
|
360
|
+
* in the dashboard); otherwise it asks for email + password and creates one.
|
|
361
|
+
*/
|
|
362
|
+
async function runLogin(options) {
|
|
363
|
+
const origin = hostOrigin(options.host);
|
|
364
|
+
let token = options.token;
|
|
365
|
+
if (!token) {
|
|
366
|
+
if (!process.stdin.isTTY) throw new Error("No terminal to ask for credentials — pass --token <access token>");
|
|
367
|
+
console.info(`Signing in to ${origin}`);
|
|
368
|
+
token = (await request(origin, "POST", "/api/account/tokens", {
|
|
369
|
+
token: (await request(origin, "POST", "/api/account/login", { body: {
|
|
370
|
+
login: await prompt("Email: "),
|
|
371
|
+
password: await prompt("Password: ", true)
|
|
372
|
+
} })).accessToken,
|
|
373
|
+
body: { name: `CLI on ${os.hostname()}` }
|
|
374
|
+
})).token;
|
|
375
|
+
}
|
|
376
|
+
const account = await request(origin, "GET", "/api/account", { token });
|
|
377
|
+
const file = await saveToken(origin, token);
|
|
378
|
+
console.info(`Logged in to ${origin} as ${account.email}`);
|
|
379
|
+
console.info(`Access token saved to ${file}`);
|
|
380
|
+
}
|
|
381
|
+
//#endregion
|
|
382
|
+
//#region src/cli/platform/bundle-format.ts
|
|
383
|
+
/**
|
|
384
|
+
* The bundle `mechanica push` uploads: the built `dist/` for one commit, as one
|
|
385
|
+
* request body.
|
|
386
|
+
*
|
|
387
|
+
* "MCHB" | u32 LE header length | header JSON | the files' bytes, in header order
|
|
388
|
+
* header = { format: 1, commit, engine, files: [ { path, size } ] }
|
|
389
|
+
*
|
|
390
|
+
* `commit` ties the bundle to the source it was built from; `engine` (this
|
|
391
|
+
* package's version) tells the platform which export code to render it with.
|
|
392
|
+
* Paths are relative to `dist/`, forward-slashed, sorted — the same build always
|
|
393
|
+
* produces the same header.
|
|
394
|
+
*/
|
|
395
|
+
var BUNDLE_MAGIC = "MCHB";
|
|
396
|
+
/** Every file under `dir`, as forward-slashed paths relative to it, sorted. */
|
|
397
|
+
async function listBundleFiles(dir) {
|
|
398
|
+
return (await readdir(dir, {
|
|
399
|
+
recursive: true,
|
|
400
|
+
withFileTypes: true
|
|
401
|
+
})).filter((entry) => entry.isFile()).map((entry) => join(entry.parentPath, entry.name).slice(dir.length + 1).replace(/\\/g, "/")).sort();
|
|
402
|
+
}
|
|
403
|
+
/** Serialize already-read files into a bundle body. */
|
|
404
|
+
function encodeBundle(meta, files) {
|
|
405
|
+
const header = {
|
|
406
|
+
format: 1,
|
|
407
|
+
commit: meta.commit,
|
|
408
|
+
engine: meta.engine,
|
|
409
|
+
files: files.map((file) => ({
|
|
410
|
+
path: file.path,
|
|
411
|
+
size: file.data.length
|
|
412
|
+
}))
|
|
413
|
+
};
|
|
414
|
+
const head = new TextEncoder().encode(JSON.stringify(header));
|
|
415
|
+
const total = 8 + head.length + files.reduce((sum, file) => sum + file.data.length, 0);
|
|
416
|
+
const body = new Uint8Array(total);
|
|
417
|
+
body.set(new TextEncoder().encode(BUNDLE_MAGIC), 0);
|
|
418
|
+
new DataView(body.buffer).setUint32(4, head.length, true);
|
|
419
|
+
body.set(head, 8);
|
|
420
|
+
let offset = 8 + head.length;
|
|
421
|
+
for (const file of files) {
|
|
422
|
+
body.set(file.data, offset);
|
|
423
|
+
offset += file.data.length;
|
|
424
|
+
}
|
|
425
|
+
return body;
|
|
426
|
+
}
|
|
427
|
+
/** Read a built `dist/` and pack it for upload. */
|
|
428
|
+
async function packBundle(distDir, meta) {
|
|
429
|
+
const paths = await listBundleFiles(distDir);
|
|
430
|
+
return encodeBundle(meta, await Promise.all(paths.map(async (path) => ({
|
|
431
|
+
path,
|
|
432
|
+
data: new Uint8Array(await readFile(join(distDir, path)))
|
|
433
|
+
}))));
|
|
434
|
+
}
|
|
435
|
+
//#endregion
|
|
436
|
+
//#region src/cli/platform/engine-version.ts
|
|
437
|
+
/**
|
|
438
|
+
* This package's version, read from its own `package.json` — found by walking
|
|
439
|
+
* up from this module, which sits at a different depth in `src/` and in the
|
|
440
|
+
* built `dist/` (and its chunks).
|
|
441
|
+
*/
|
|
442
|
+
function engineVersion() {
|
|
443
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
444
|
+
for (let depth = 0; depth < 6; depth++) {
|
|
445
|
+
try {
|
|
446
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
|
|
447
|
+
if (pkg.name === "mechanica" && typeof pkg.version === "string") return pkg.version;
|
|
448
|
+
} catch {}
|
|
449
|
+
dir = dirname(dir);
|
|
450
|
+
}
|
|
451
|
+
throw new Error("Could not determine the mechanica version");
|
|
452
|
+
}
|
|
453
|
+
//#endregion
|
|
454
|
+
//#region src/cli/platform/push.ts
|
|
455
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
456
|
+
var POLL_MS = 1e3;
|
|
457
|
+
var DEPLOY_TIMEOUT_MS = 3e5;
|
|
458
|
+
var AUTO_DEPLOY_WAIT_MS = 1e4;
|
|
459
|
+
/**
|
|
460
|
+
* `mechanica push`: publish the committed project to the platform.
|
|
461
|
+
*
|
|
462
|
+
* 1. bring in what was published online since the last pull (`pull --rebase`),
|
|
463
|
+
* 2. `git push` the commits to the site's repository,
|
|
464
|
+
* 3. if the code changed, build locally and upload the bundle for that commit
|
|
465
|
+
* (content-only pushes reuse the bundle already there — no build at all),
|
|
466
|
+
* 4. wait for the deploy and report it.
|
|
467
|
+
*
|
|
468
|
+
* The build runs on this machine; the platform only ever renders the bundle.
|
|
469
|
+
*/
|
|
470
|
+
async function runPush() {
|
|
471
|
+
const cwd = process.cwd();
|
|
472
|
+
if ((await git(cwd, ["rev-parse", "--is-inside-work-tree"])).code !== 0) throw new Error("This folder is not a git repository — run `git init`, commit, then `mechanica link <slug>`");
|
|
473
|
+
const top = await realpath(await gitOk(cwd, ["rev-parse", "--show-toplevel"]));
|
|
474
|
+
if (top !== await realpath(cwd)) throw new Error(`Run \`mechanica push\` from the repository root (${top}) — the site must be the whole repository`);
|
|
475
|
+
const remote = await git(cwd, [
|
|
476
|
+
"remote",
|
|
477
|
+
"get-url",
|
|
478
|
+
REMOTE_NAME
|
|
479
|
+
]);
|
|
480
|
+
const link = remote.code === 0 ? parseRemoteUrl(remote.stdout) : null;
|
|
481
|
+
if (!link) throw new Error("This project is not linked to a site — run `mechanica link <slug>` first");
|
|
482
|
+
const token = await requireToken(link.origin);
|
|
483
|
+
const auth = gitAuthEnv(link.origin, token);
|
|
484
|
+
const site = (await request(link.origin, "GET", "/api/sites", { token })).find((item) => item.slug === link.slug);
|
|
485
|
+
if (!site) throw new Error(`You have no access to the site "${link.slug}" on ${link.origin}`);
|
|
486
|
+
if ((await git(cwd, [
|
|
487
|
+
"rev-parse",
|
|
488
|
+
"--verify",
|
|
489
|
+
"--quiet",
|
|
490
|
+
"HEAD"
|
|
491
|
+
])).code !== 0) throw new Error("Nothing to push yet — commit your project first");
|
|
492
|
+
const dirty = await gitOk(cwd, ["status", "--porcelain"]);
|
|
493
|
+
if (dirty) throw new Error(`Commit or stash your changes first — the working tree is not clean:\n${dirty}`);
|
|
494
|
+
if (await gitOk(cwd, [
|
|
495
|
+
"ls-remote",
|
|
496
|
+
"--heads",
|
|
497
|
+
"mechanica",
|
|
498
|
+
"main"
|
|
499
|
+
], auth)) {
|
|
500
|
+
console.info("Pulling changes from the platform…");
|
|
501
|
+
const pulled = await git(cwd, [
|
|
502
|
+
"pull",
|
|
503
|
+
"--rebase",
|
|
504
|
+
REMOTE_NAME,
|
|
505
|
+
"main"
|
|
506
|
+
], auth);
|
|
507
|
+
if (pulled.code !== 0) throw new Error(`Could not rebase onto the platform's changes:\n${pulled.stderr || pulled.stdout}\nResolve the conflict (\`git rebase --continue\`), then run \`mechanica push\` again.`);
|
|
508
|
+
}
|
|
509
|
+
const head = await gitOk(cwd, ["rev-parse", "HEAD"]);
|
|
510
|
+
console.info(`Pushing ${head.slice(0, 8)} to ${link.slug}…`);
|
|
511
|
+
const pushed = await git(cwd, [
|
|
512
|
+
"push",
|
|
513
|
+
REMOTE_NAME,
|
|
514
|
+
"HEAD:refs/heads/main"
|
|
515
|
+
], auth);
|
|
516
|
+
if (pushed.code !== 0) throw new Error(`git push failed:\n${pushed.stderr || pushed.stdout}`);
|
|
517
|
+
const statusPath = `/api/sites/${site.uuid}/status`;
|
|
518
|
+
let status = await request(link.origin, "GET", statusPath, { token });
|
|
519
|
+
let deployId = null;
|
|
520
|
+
if (status.needsBundle) {
|
|
521
|
+
await runBuild();
|
|
522
|
+
const body = await packBundle(join(cwd, "dist"), {
|
|
523
|
+
commit: head,
|
|
524
|
+
engine: engineVersion()
|
|
525
|
+
});
|
|
526
|
+
console.info(`Uploading the bundle (${(body.length / 1024 / 1024).toFixed(1)} MB)…`);
|
|
527
|
+
const uploaded = await request(link.origin, "POST", `/api/sites/${site.uuid}/bundles`, {
|
|
528
|
+
token,
|
|
529
|
+
raw: body
|
|
530
|
+
});
|
|
531
|
+
if (uploaded.deployId === null) throw new Error(`The bundle was not deployed: ${uploaded.reason}`);
|
|
532
|
+
deployId = uploaded.deployId;
|
|
533
|
+
} else {
|
|
534
|
+
console.info("Only content changed — the platform reuses the current bundle.");
|
|
535
|
+
const deadline = Date.now() + AUTO_DEPLOY_WAIT_MS;
|
|
536
|
+
while (status.latestDeploy?.contentCommit !== head && Date.now() < deadline) {
|
|
537
|
+
await sleep(POLL_MS / 2);
|
|
538
|
+
status = await request(link.origin, "GET", statusPath, { token });
|
|
539
|
+
}
|
|
540
|
+
if (status.latestDeploy?.contentCommit !== head) throw new Error("The platform did not start a deploy for this push — try `mechanica push` again");
|
|
541
|
+
deployId = status.latestDeploy.id;
|
|
542
|
+
}
|
|
543
|
+
const deployPath = `/api/sites/${site.uuid}/deploys/${deployId}`;
|
|
544
|
+
const deadline = Date.now() + DEPLOY_TIMEOUT_MS;
|
|
545
|
+
let deploy = await request(link.origin, "GET", deployPath, { token });
|
|
546
|
+
if (deploy.status === "rendering") console.info("Rendering…");
|
|
547
|
+
while (deploy.status === "rendering") {
|
|
548
|
+
if (Date.now() > deadline) throw new Error("Timed out waiting for the deploy — check the site later");
|
|
549
|
+
await sleep(POLL_MS);
|
|
550
|
+
deploy = await request(link.origin, "GET", deployPath, { token });
|
|
551
|
+
}
|
|
552
|
+
for (const warning of deploy.warnings ?? []) console.warn(warning);
|
|
553
|
+
if (deploy.status === "failed") {
|
|
554
|
+
if (deploy.log) console.error(`\n${deploy.log}\n`);
|
|
555
|
+
throw new Error(`Deploy failed: ${deploy.error ?? "unknown error"}. The previous version stays live.`);
|
|
556
|
+
}
|
|
557
|
+
console.info(`\nLive at ${deploy.url ?? status.url} — ${deploy.pages} page(s)`);
|
|
741
558
|
}
|
|
742
559
|
//#endregion
|
|
743
560
|
//#region src/cli/headless.ts
|
|
@@ -1415,7 +1232,13 @@ Usage:
|
|
|
1415
1232
|
--site-name <name> adds WebSite JSON-LD;
|
|
1416
1233
|
--assets-url <origin> serves /assets and /media from
|
|
1417
1234
|
a CDN base — files still land in export/)
|
|
1418
|
-
mechanica
|
|
1235
|
+
mechanica login Sign in to the Mechanica platform and save an access
|
|
1236
|
+
token for this machine (--host <url>, --token <token>)
|
|
1237
|
+
mechanica link <slug> Point this repository at a site on the platform
|
|
1238
|
+
(--create makes the site, --name "Site name")
|
|
1239
|
+
mechanica push Publish the committed project: pull what was edited
|
|
1240
|
+
online, git push, build + upload the bundle when the
|
|
1241
|
+
code changed, and wait for the deploy
|
|
1419
1242
|
mechanica shot <blockId> Screenshot one block via the dev preview route
|
|
1420
1243
|
mechanica shot </path> Screenshot a whole page (editor overlay stripped)
|
|
1421
1244
|
(--page </path>, --data <json|@file>, --width 1440,768,
|
|
@@ -1443,11 +1266,16 @@ async function run(argv) {
|
|
|
1443
1266
|
assetsUrl: str(flags["assets-url"])
|
|
1444
1267
|
});
|
|
1445
1268
|
case "images": return runImages({ force: flags.force === true });
|
|
1446
|
-
case "
|
|
1447
|
-
|
|
1269
|
+
case "login": return runLogin({
|
|
1270
|
+
host: str(flags.host),
|
|
1271
|
+
token: str(flags.token)
|
|
1272
|
+
});
|
|
1273
|
+
case "link": return runLink(args[0], {
|
|
1448
1274
|
host: str(flags.host),
|
|
1449
|
-
|
|
1275
|
+
create: flags.create === true,
|
|
1276
|
+
name: str(flags.name)
|
|
1450
1277
|
});
|
|
1278
|
+
case "push": return runPush();
|
|
1451
1279
|
case "shot": return runShot(args[0], {
|
|
1452
1280
|
page: str(flags.page),
|
|
1453
1281
|
data: str(flags.data),
|