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/cli.js CHANGED
@@ -1,13 +1,13 @@
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, Y as isRasterImage, 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 fs from "node:fs";
3
- import { dirname, join, parse, relative } from "node:path";
4
- import { applySeoTags, auditPageHtml, buildRobotsTxt, buildSitemap, findUnknownBlocks, generateProject, isPaginatedQuery, localePath, mergeTranslation, migrateContent, paginationVariantPath, registerFieldSchemas, resolveQueryKey, templateBlockIds, validateLinks, walkSchema, walkTree } from "mechanica-shared";
5
- import { parsePage } from "mechanica-shared/page-format";
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 { pathToFileURL } from "node:url";
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
- * Statically render every page of an already-built project into `export/`,
330
- * returning the generated page paths. This is the post-build core of
331
- * {@link runExport}, isolated so it can be tested without a Vite build: it
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
- registerFieldSchemas();
629
- const cwd = process.cwd();
630
- const written = await exportProject(cwd, await import(pathToFileURL(join(cwd, "dist/ssr.js")).href), options);
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/push.ts
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
- * Upload a built project to a backend. Preserves v1's binary body format
680
- * (a JSON tree header + concatenated file buffers) so the existing backend
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
- async function runPush(options) {
684
- const apiKey = options.key ?? process.env.BUILDER_API_KEY;
685
- const host = options.host ?? process.env.BUILDER_HOST ?? "https://builder.jt3.ru";
686
- if (!apiKey) {
687
- console.error("[mechanica push] missing --key (or BUILDER_API_KEY)");
688
- process.exitCode = 1;
689
- return;
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
- const root = join(process.cwd(), options.dir ?? "dist");
692
- const buffers = [];
693
- let written = 0;
694
- const walk = async (dir) => {
695
- const result = [];
696
- for (const name of await readdir(dir)) {
697
- const full = join(dir, name);
698
- const info = await stat(full);
699
- if (info.isDirectory()) result.push({
700
- type: "folder",
701
- name,
702
- mtime: info.mtimeMs,
703
- children: await walk(full)
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
- else {
706
- const file = await readFile(full);
707
- result.push({
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
- return result;
720
- };
721
- const tree = await walk(root);
722
- const header = Buffer.from(JSON.stringify(tree), "utf-8");
723
- const controls = new Uint32Array([header.length, written]);
724
- const body = Buffer.concat([
725
- Buffer.from(controls.buffer),
726
- header,
727
- ...buffers
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
- console.info(`Pushing ${root} → ${host}…`);
730
- const response = await fetch(`${host}/api/projects/push`, {
731
- method: "POST",
732
- headers: {
733
- authorization: `Bearer ${apiKey}`,
734
- "content-type": "application/octet-stream"
735
- },
736
- body
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
- const text = await response.text();
739
- if (!response.ok) throw new Error(`Push failed (${response.status}): ${text}`);
740
- console.info("Project pushed successfully.", text);
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 push [--key] Upload dist/ to a backend (--key, --host, --dir)
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 "push": return runPush({
1447
- key: str(flags.key),
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
- dir: str(flags.dir)
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),