@moku-labs/web 1.1.0 → 1.3.0
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 +1 -1
- package/dist/browser.d.mts +22 -2
- package/dist/browser.mjs +17 -4
- package/dist/index.cjs +1111 -193
- package/dist/index.d.cts +162 -14
- package/dist/index.d.mts +162 -14
- package/dist/index.mjs +1112 -194
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { t as __exportAll } from "./chunk-D7D4PA-g.mjs";
|
|
2
2
|
import { n as relativeDataFile, t as dataSuffix } from "./convention-CepUwWmT.mjs";
|
|
3
3
|
import { createCoreConfig, createCorePlugin } from "@moku-labs/core";
|
|
4
|
-
import { existsSync, readFileSync, readdirSync, statSync, watch } from "node:fs";
|
|
4
|
+
import { appendFileSync, existsSync, readFileSync, readdirSync, realpathSync, statSync, watch } from "node:fs";
|
|
5
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
6
6
|
import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { Feed } from "feed";
|
|
9
9
|
import { h } from "preact";
|
|
10
10
|
import { renderToString } from "preact-render-to-string";
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
11
12
|
import { createInterface } from "node:readline";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
12
14
|
import { networkInterfaces } from "node:os";
|
|
13
15
|
import matter from "gray-matter";
|
|
14
16
|
import rehypeShiki from "@shikijs/rehype";
|
|
@@ -1280,18 +1282,24 @@ function isPublished(article, isProduction) {
|
|
|
1280
1282
|
* locale collection: existing files only, drafts dropped in production, sorted
|
|
1281
1283
|
* date-descending. The single load+filter+sort step behind {@link createContentApi.loadAll}.
|
|
1282
1284
|
*
|
|
1285
|
+
* When a `cached` map is supplied (incremental dev rebuild), a slug already present in it
|
|
1286
|
+
* is reused as-is (skipping the re-read + Markdown/Shiki re-render); only slugs absent
|
|
1287
|
+
* from it — the ones a preceding `invalidate()` dropped, plus any never-loaded — are
|
|
1288
|
+
* resolved fresh. With no `cached` map every slug is resolved (the full load).
|
|
1289
|
+
*
|
|
1283
1290
|
* @param ctx - Kernel-free domain context (provider + i18n helpers + stage).
|
|
1284
1291
|
* @param slugs - Every known article slug from the provider.
|
|
1285
1292
|
* @param locale - The locale to resolve and collect.
|
|
1293
|
+
* @param cached - Optional per-locale article cache to reuse for unchanged slugs.
|
|
1286
1294
|
* @returns The published (date-descending) articles for this locale.
|
|
1287
1295
|
* @example
|
|
1288
1296
|
* ```ts
|
|
1289
1297
|
* const present = await loadAndFilterArticles(ctx, slugs, "en");
|
|
1290
1298
|
* ```
|
|
1291
1299
|
*/
|
|
1292
|
-
async function loadAndFilterArticles(ctx, slugs, locale) {
|
|
1300
|
+
async function loadAndFilterArticles(ctx, slugs, locale, cached) {
|
|
1293
1301
|
const isProduction = ctx.global.stage === "production";
|
|
1294
|
-
return (await Promise.all(slugs.map((slug) => resolveArticle(ctx, slug, locale)))).filter((article) => article !== null).filter((article) => isPublished(article, isProduction)).toSorted(byDateDescending);
|
|
1302
|
+
return (await Promise.all(slugs.map((slug) => cached?.get(slug) ?? resolveArticle(ctx, slug, locale)))).filter((article) => article !== null).filter((article) => isPublished(article, isProduction)).toSorted(byDateDescending);
|
|
1295
1303
|
}
|
|
1296
1304
|
/**
|
|
1297
1305
|
* Derive the article slug from a source file path — the parent directory name
|
|
@@ -1328,19 +1336,26 @@ function createContentApi(ctx) {
|
|
|
1328
1336
|
* Load every article across every active locale (locale fallback, production
|
|
1329
1337
|
* draft exclusion, date sort, `contentId` after sort), cache them, emit `content:ready`.
|
|
1330
1338
|
*
|
|
1339
|
+
* With `{ reuse: true }` (dev incremental rebuild) cached articles are reused for
|
|
1340
|
+
* every slug a preceding `invalidate()` did not drop, so only the dirty articles
|
|
1341
|
+
* re-read + re-run the Markdown/Shiki pipeline; the `contentId` ordinals are still
|
|
1342
|
+
* recomputed across the FULL sorted set, so ids + order match a full load.
|
|
1343
|
+
*
|
|
1344
|
+
* @param options - Optional load behaviour (`reuse`); omit for a full load.
|
|
1331
1345
|
* @returns A locale-keyed map of date-descending articles.
|
|
1332
1346
|
* @example
|
|
1333
1347
|
* ```ts
|
|
1334
1348
|
* const byLocale = await api.loadAll();
|
|
1335
1349
|
* ```
|
|
1336
1350
|
*/
|
|
1337
|
-
async loadAll() {
|
|
1351
|
+
async loadAll(options) {
|
|
1352
|
+
const reuse = options?.reuse === true;
|
|
1338
1353
|
const slugs = await ctx.provider.slugs();
|
|
1339
1354
|
const locales = ctx.locales();
|
|
1340
1355
|
const result = /* @__PURE__ */ new Map();
|
|
1341
1356
|
let total = 0;
|
|
1342
1357
|
for (const locale of locales) {
|
|
1343
|
-
const present = await loadAndFilterArticles(ctx, slugs, locale);
|
|
1358
|
+
const present = await loadAndFilterArticles(ctx, slugs, locale, reuse ? ctx.state.articles.get(locale) : void 0);
|
|
1344
1359
|
const cache = /* @__PURE__ */ new Map();
|
|
1345
1360
|
let index = 0;
|
|
1346
1361
|
for (const article of present) {
|
|
@@ -3341,7 +3356,11 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
|
|
|
3341
3356
|
/**
|
|
3342
3357
|
* Bundles CSS and JS into the output directory via two separate runner passes
|
|
3343
3358
|
* (dodging Bun's mixed-entrypoint segfault), honoring `config.minify`, and caches
|
|
3344
|
-
* the resulting hashed asset paths in `state.buildCache` for downstream phases.
|
|
3359
|
+
* the resulting hashed asset paths in `state.buildCache` for downstream phases. The
|
|
3360
|
+
* two passes run CONCURRENTLY (`Promise.all`) — they target disjoint hashed outputs
|
|
3361
|
+
* and distinct `buildCache` keys (`css`/`js`), so overlapping them ~halves bundle
|
|
3362
|
+
* wall-time with no shared-state hazard. The CSS pass is still dispatched first, so
|
|
3363
|
+
* the runner's invocation order stays css-then-js.
|
|
3345
3364
|
*
|
|
3346
3365
|
* @param ctx - Plugin context (provides `state`, `config`, `log`).
|
|
3347
3366
|
* @param options - Optional dependency-injection seam (runner + entrypoints).
|
|
@@ -3354,10 +3373,10 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
|
|
|
3354
3373
|
async function bundle(ctx, options = {}) {
|
|
3355
3374
|
const runner = options.runner ?? defaultRunner;
|
|
3356
3375
|
const { minify, outDir } = ctx.config;
|
|
3376
|
+
const assetsDir = path.join(outDir, "assets");
|
|
3357
3377
|
const cssEntrypoints = options.cssEntrypoints ?? resolveEntrypoints(CSS_ENTRY_CANDIDATES);
|
|
3358
3378
|
const jsEntrypoints = options.jsEntrypoints ?? resolveJsEntrypoints(ctx);
|
|
3359
|
-
await runOne(ctx, runner, "css", cssEntrypoints, outDir,
|
|
3360
|
-
await runOne(ctx, runner, "js", jsEntrypoints, outDir, path.join(outDir, "assets"), minify);
|
|
3379
|
+
await Promise.all([runOne(ctx, runner, "css", cssEntrypoints, outDir, assetsDir, minify), runOne(ctx, runner, "js", jsEntrypoints, outDir, assetsDir, minify)]);
|
|
3361
3380
|
}
|
|
3362
3381
|
//#endregion
|
|
3363
3382
|
//#region src/plugins/build/phases/content.ts
|
|
@@ -3374,15 +3393,24 @@ const CONTENT_CACHE_KEY = "content";
|
|
|
3374
3393
|
* pages/feeds/og-images phases. Performs NO Markdown parsing itself — the
|
|
3375
3394
|
* content plugin owns rendering (god-plugin invariant).
|
|
3376
3395
|
*
|
|
3396
|
+
* On a dev incremental rebuild (`options.changed` set) it first `invalidate()`s the
|
|
3397
|
+
* changed Markdown so `loadAll({ reuse: true })` re-reads + re-renders ONLY those
|
|
3398
|
+
* articles, reusing the cached HTML for the rest. With no options it does a full load.
|
|
3399
|
+
*
|
|
3377
3400
|
* @param ctx - Plugin context (provides `require`, `state`, `log`).
|
|
3401
|
+
* @param options - Optional incremental hints; omit for a full load.
|
|
3402
|
+
* @param options.reuse - Reuse cached content for slugs not invalidated (dev incremental rebuild).
|
|
3403
|
+
* @param options.changed - The changed Markdown paths to invalidate before loading.
|
|
3378
3404
|
* @returns The locale-keyed article map returned by the content plugin.
|
|
3379
3405
|
* @example
|
|
3380
3406
|
* ```ts
|
|
3381
3407
|
* const byLocale = await loadContent(ctx);
|
|
3382
3408
|
* ```
|
|
3383
3409
|
*/
|
|
3384
|
-
async function loadContent(ctx) {
|
|
3385
|
-
const
|
|
3410
|
+
async function loadContent(ctx, options) {
|
|
3411
|
+
const content = ctx.require(contentPlugin);
|
|
3412
|
+
if (options?.changed && options.changed.length > 0) content.invalidate(options.changed);
|
|
3413
|
+
const byLocale = await content.loadAll({ reuse: options?.reuse ?? false });
|
|
3386
3414
|
ctx.state.buildCache.set(CONTENT_CACHE_KEY, byLocale);
|
|
3387
3415
|
ctx.log.debug("build:content", { locales: byLocale.size });
|
|
3388
3416
|
return byLocale;
|
|
@@ -4736,6 +4764,71 @@ function renderBody(definition, routeContext) {
|
|
|
4736
4764
|
return renderToString(definition._handlers.layout ? definition._handlers.layout(layoutContext, vnode) : vnode);
|
|
4737
4765
|
}
|
|
4738
4766
|
/**
|
|
4767
|
+
* Hash a page's render inputs (its loaded data) for the render cache. `null` when the
|
|
4768
|
+
* data is not JSON-serializable — such a page is never cached and always re-renders.
|
|
4769
|
+
*
|
|
4770
|
+
* @param data - The route's loaded data (the only per-page input besides params/locale/code).
|
|
4771
|
+
* @returns The hex SHA-256 of the serialized data, or `null` when it cannot be serialized.
|
|
4772
|
+
* @example
|
|
4773
|
+
* ```ts
|
|
4774
|
+
* hashData({ title: "Hi" }); // "9f8e…"
|
|
4775
|
+
* ```
|
|
4776
|
+
*/
|
|
4777
|
+
function hashData(data) {
|
|
4778
|
+
try {
|
|
4779
|
+
const serialized = JSON.stringify(data) ?? "";
|
|
4780
|
+
return createHash("sha256").update(serialized).digest("hex");
|
|
4781
|
+
} catch {
|
|
4782
|
+
return null;
|
|
4783
|
+
}
|
|
4784
|
+
}
|
|
4785
|
+
/**
|
|
4786
|
+
* The render-cache key for one page instance: name + params + locale (the stable identity
|
|
4787
|
+
* that, together with the data hash, determines its body). NUL-joined so no value collides.
|
|
4788
|
+
*
|
|
4789
|
+
* @param instance - The page instance.
|
|
4790
|
+
* @returns The cache key string.
|
|
4791
|
+
* @example
|
|
4792
|
+
* ```ts
|
|
4793
|
+
* renderCacheKey(instance); // "article{\"slug\":\"x\"}en"
|
|
4794
|
+
* ```
|
|
4795
|
+
*/
|
|
4796
|
+
function renderCacheKey(instance) {
|
|
4797
|
+
return `${instance.name}${JSON.stringify(instance.params)}${instance.locale}`;
|
|
4798
|
+
}
|
|
4799
|
+
/**
|
|
4800
|
+
* Render one page's body, reusing the cached body when this page's data is unchanged.
|
|
4801
|
+
* The body is the synchronous, dominant-cost step ({@link renderBody}); an incremental
|
|
4802
|
+
* dev rebuild (`reuse`, code unchanged) skips it for every page whose data hash matches
|
|
4803
|
+
* the cache, and a changed page (or a non-`reuse` run) renders + refreshes the cache.
|
|
4804
|
+
*
|
|
4805
|
+
* @param ctx - Plugin context (provides the cross-run `state.renderCache`).
|
|
4806
|
+
* @param instance - The page instance being rendered.
|
|
4807
|
+
* @param routeContext - The route context passed to `.render()`/`.layout()`.
|
|
4808
|
+
* @param data - The route's loaded data (hashed to detect a change).
|
|
4809
|
+
* @param reuse - Whether this run may reuse a cached body (incremental, no code change).
|
|
4810
|
+
* @returns The SSR-rendered body HTML.
|
|
4811
|
+
* @example
|
|
4812
|
+
* ```ts
|
|
4813
|
+
* const body = renderBodyCached(ctx, instance, routeContext, data, true);
|
|
4814
|
+
* ```
|
|
4815
|
+
*/
|
|
4816
|
+
function renderBodyCached(ctx, instance, routeContext, data, reuse) {
|
|
4817
|
+
const cache = ctx.state.renderCache;
|
|
4818
|
+
const key = renderCacheKey(instance);
|
|
4819
|
+
const hash = hashData(data);
|
|
4820
|
+
if (reuse && hash !== null) {
|
|
4821
|
+
const hit = cache.get(key);
|
|
4822
|
+
if (hit?.dataHash === hash) return hit.body;
|
|
4823
|
+
}
|
|
4824
|
+
const body = renderBody(instance.definition, routeContext);
|
|
4825
|
+
if (hash !== null) cache.set(key, {
|
|
4826
|
+
dataHash: hash,
|
|
4827
|
+
body
|
|
4828
|
+
});
|
|
4829
|
+
return body;
|
|
4830
|
+
}
|
|
4831
|
+
/**
|
|
4739
4832
|
* Write a rendered page document to its on-disk path. The path comes from the
|
|
4740
4833
|
* compiled `TypedRoute.toFile(params)` (honoring any route-level `.toFile()`
|
|
4741
4834
|
* override), resolved under the build `outDir`; parent directories are created first.
|
|
@@ -4764,13 +4857,14 @@ async function writeDocument(outDir, entry, params, html) {
|
|
|
4764
4857
|
* @param ctx - Plugin context (provides `require`, `state`, `config`, `has`).
|
|
4765
4858
|
* @param instance - The concrete page instance to render.
|
|
4766
4859
|
* @param shell - Per-build wiring shared across instances (asset tags + template).
|
|
4860
|
+
* @param reuse - Whether this run may reuse a cached body (incremental, no code change).
|
|
4767
4861
|
* @returns The instance's URL, rendered HTML, loaded data, and client-nav flag.
|
|
4768
4862
|
* @example
|
|
4769
4863
|
* ```ts
|
|
4770
|
-
* await renderInstance(ctx, instance, { assets: "", template: null });
|
|
4864
|
+
* await renderInstance(ctx, instance, { assets: "", template: null }, false);
|
|
4771
4865
|
* ```
|
|
4772
4866
|
*/
|
|
4773
|
-
async function renderInstance(ctx, instance, shell) {
|
|
4867
|
+
async function renderInstance(ctx, instance, shell, reuse) {
|
|
4774
4868
|
const { definition, entry, params, locale } = instance;
|
|
4775
4869
|
const router = ctx.require(routerPlugin);
|
|
4776
4870
|
const data = await loadRouteData(definition, params, locale, ctx);
|
|
@@ -4783,7 +4877,7 @@ async function renderInstance(ctx, instance, shell) {
|
|
|
4783
4877
|
};
|
|
4784
4878
|
const parts = {
|
|
4785
4879
|
head: composeHeadHtml(ctx, instance, url, routeContext, data),
|
|
4786
|
-
body:
|
|
4880
|
+
body: renderBodyCached(ctx, instance, routeContext, data, reuse),
|
|
4787
4881
|
assets: shell.assets,
|
|
4788
4882
|
locale
|
|
4789
4883
|
};
|
|
@@ -4877,27 +4971,77 @@ function findRootHtml(rendered) {
|
|
|
4877
4971
|
return rendered.find((page) => page.url === "/" || page.url === "")?.html ?? null;
|
|
4878
4972
|
}
|
|
4879
4973
|
/**
|
|
4974
|
+
* Pages rendered concurrently per batch. Kept small so the macrotask yield between
|
|
4975
|
+
* batches fires frequently — a large batch renders for seconds before yielding, which
|
|
4976
|
+
* leaves a watching dev server's spinner repainting only every few seconds (sluggish).
|
|
4977
|
+
* Smaller batches trade a little write-concurrency for a smooth, responsive spinner.
|
|
4978
|
+
*/
|
|
4979
|
+
const RENDER_BATCH_SIZE = 2;
|
|
4980
|
+
/**
|
|
4981
|
+
* Batch size for an incremental (`reuse`) rebuild. Most instances are cheap cache hits, so
|
|
4982
|
+
* a larger batch cuts the per-batch `setImmediate` round-trips (which would otherwise add
|
|
4983
|
+
* pure latency to an otherwise-fast rebuild) without starving the dev spinner.
|
|
4984
|
+
*/
|
|
4985
|
+
const INCREMENTAL_BATCH_SIZE = 32;
|
|
4986
|
+
/**
|
|
4987
|
+
* Render `items` through `worker` in bounded-size batches, yielding a macrotask
|
|
4988
|
+
* (`setImmediate`) between batches. Beyond bounding peak concurrency/memory for large
|
|
4989
|
+
* sites, the yield lets the single JS thread breathe: one un-yielded `Promise.all` over
|
|
4990
|
+
* hundreds of synchronous `renderToString` calls starves the event loop, which freezes a
|
|
4991
|
+
* watching dev server's progress spinner until the whole phase resolves. Output order is
|
|
4992
|
+
* preserved (batch order + `Promise.all` order within a batch).
|
|
4993
|
+
*
|
|
4994
|
+
* @template Item - The input item type.
|
|
4995
|
+
* @template Out - The rendered output type.
|
|
4996
|
+
* @param items - The items to render.
|
|
4997
|
+
* @param batchSize - Maximum items rendered concurrently per batch.
|
|
4998
|
+
* @param worker - Renders one item to its output.
|
|
4999
|
+
* @returns All rendered outputs in input order.
|
|
5000
|
+
* @example
|
|
5001
|
+
* ```ts
|
|
5002
|
+
* const pages = await renderInBatches(instances, 32, i => renderInstance(ctx, i, shell));
|
|
5003
|
+
* ```
|
|
5004
|
+
*/
|
|
5005
|
+
async function renderInBatches(items, batchSize, worker) {
|
|
5006
|
+
const out = [];
|
|
5007
|
+
for (let start = 0; start < items.length; start += batchSize) {
|
|
5008
|
+
const batch = items.slice(start, start + batchSize);
|
|
5009
|
+
out.push(...await Promise.all(batch.map((item) => worker(item))));
|
|
5010
|
+
if (start + batchSize < items.length) await new Promise((resolve) => {
|
|
5011
|
+
setImmediate(resolve);
|
|
5012
|
+
});
|
|
5013
|
+
}
|
|
5014
|
+
return out;
|
|
5015
|
+
}
|
|
5016
|
+
/**
|
|
4880
5017
|
* Renders every route in the manifest to `outDir/<path>/index.html`. Reads as a
|
|
4881
|
-
* pipeline: resolve deps → prepare the shared shell → expand instances → render
|
|
4882
|
-
*
|
|
4883
|
-
*
|
|
5018
|
+
* pipeline: resolve deps → prepare the shared shell → expand instances → render in
|
|
5019
|
+
* bounded batches ({@link renderInBatches}) → write data sidecars (hybrid/spa) →
|
|
5020
|
+
* capture the root page's HTML for the root-index phase.
|
|
5021
|
+
*
|
|
5022
|
+
* On an incremental rebuild (`options.reuse`) the cross-run render cache is kept and each
|
|
5023
|
+
* unchanged-data page reuses its cached body; a full render clears the cache first so a
|
|
5024
|
+
* removed/renamed route's stale body never lingers.
|
|
4884
5025
|
*
|
|
4885
5026
|
* @param ctx - Plugin context (provides `require`, `state`, `config`, `log`, `has`).
|
|
5027
|
+
* @param options - Optional incremental hint; omit for a full render.
|
|
5028
|
+
* @param options.reuse - Reuse cached page bodies for unchanged-data pages (dev incremental rebuild).
|
|
4886
5029
|
* @returns The number of pages rendered and the captured default-page HTML.
|
|
4887
5030
|
* @example
|
|
4888
5031
|
* ```ts
|
|
4889
5032
|
* const { pageCount, rootHtml } = await renderPages(ctx);
|
|
4890
5033
|
* ```
|
|
4891
5034
|
*/
|
|
4892
|
-
async function renderPages(ctx) {
|
|
5035
|
+
async function renderPages(ctx, options) {
|
|
5036
|
+
const reuse = options?.reuse === true;
|
|
4893
5037
|
const router = ctx.require(routerPlugin);
|
|
4894
5038
|
const manifest = router.manifest();
|
|
4895
5039
|
ctx.state.manifest = [...manifest];
|
|
4896
5040
|
const locales = ctx.require(i18nPlugin).locales();
|
|
4897
5041
|
const byPattern = makeEntryMap(router);
|
|
5042
|
+
if (!reuse) ctx.state.renderCache.clear();
|
|
4898
5043
|
const shell = await prepareShell(ctx);
|
|
4899
|
-
const
|
|
4900
|
-
const rendered = await Promise.all(instances.map((instance) => renderInstance(ctx, instance, shell)));
|
|
5044
|
+
const rendered = await renderInBatches(await expandAllInstances(manifest, locales, byPattern, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse));
|
|
4901
5045
|
await writeDataSidecars(ctx, rendered, router.mode());
|
|
4902
5046
|
ctx.log.debug("build:pages", { count: rendered.length });
|
|
4903
5047
|
return {
|
|
@@ -5105,6 +5249,40 @@ async function generateSitemap(ctx) {
|
|
|
5105
5249
|
* @file build plugin — pipeline driver. Sequences the fixed multi-phase build,
|
|
5106
5250
|
* emits `build:phase` boundaries, and runs intra-phase work via `Promise.all`.
|
|
5107
5251
|
*/
|
|
5252
|
+
/** Matches a Markdown source path (a content edit). */
|
|
5253
|
+
const MARKDOWN_PATH = /\.md$/;
|
|
5254
|
+
/** Matches a stylesheet path (a CSS edit — does not change rendered page bodies). */
|
|
5255
|
+
const STYLE_PATH = /\.css$/;
|
|
5256
|
+
/** Matches a code path (TS/JS/JSON — may change ANY page's render output). */
|
|
5257
|
+
const CODE_PATH = /\.(?:tsx?|jsx?|mjs|cjs|json)$/;
|
|
5258
|
+
/**
|
|
5259
|
+
* Derive the {@link ChangePlan} for a run from its changed-path set (see the type docs
|
|
5260
|
+
* for the rules).
|
|
5261
|
+
*
|
|
5262
|
+
* @param changed - Absolute/relative changed paths, or `undefined` for a full build.
|
|
5263
|
+
* @returns The reuse plan for this run.
|
|
5264
|
+
* @example
|
|
5265
|
+
* ```ts
|
|
5266
|
+
* const plan = planIncrementalRebuild(options?.changed);
|
|
5267
|
+
* ```
|
|
5268
|
+
*/
|
|
5269
|
+
function planIncrementalRebuild(changed) {
|
|
5270
|
+
if (changed === void 0 || changed.length === 0) return {
|
|
5271
|
+
contentChanged: [],
|
|
5272
|
+
contentReuse: false,
|
|
5273
|
+
renderReuse: false
|
|
5274
|
+
};
|
|
5275
|
+
if (!changed.every((file) => MARKDOWN_PATH.test(file) || STYLE_PATH.test(file) || CODE_PATH.test(file))) return {
|
|
5276
|
+
contentChanged: [],
|
|
5277
|
+
contentReuse: false,
|
|
5278
|
+
renderReuse: false
|
|
5279
|
+
};
|
|
5280
|
+
return {
|
|
5281
|
+
contentChanged: changed.filter((file) => MARKDOWN_PATH.test(file)),
|
|
5282
|
+
contentReuse: true,
|
|
5283
|
+
renderReuse: !changed.some((file) => CODE_PATH.test(file))
|
|
5284
|
+
};
|
|
5285
|
+
}
|
|
5108
5286
|
/**
|
|
5109
5287
|
* The static ordered list of pipeline phase names.
|
|
5110
5288
|
*
|
|
@@ -5218,8 +5396,7 @@ async function runOutputs(ctx) {
|
|
|
5218
5396
|
* `build:phase` boundary per phase and `build:complete` once at the end.
|
|
5219
5397
|
*
|
|
5220
5398
|
* @param ctx - Plugin context (provides `require`, `emit`, `state`, `config`, `log`).
|
|
5221
|
-
* @param options - Optional run overrides.
|
|
5222
|
-
* @param options.outDir - Override the configured output directory for this run.
|
|
5399
|
+
* @param options - Optional per-run overrides ({@link RunOptions}).
|
|
5223
5400
|
* @returns The build result (outDir, pageCount, durationMs).
|
|
5224
5401
|
* @example
|
|
5225
5402
|
* ```ts
|
|
@@ -5234,17 +5411,22 @@ async function runPipeline(ctx, options) {
|
|
|
5234
5411
|
...ctx,
|
|
5235
5412
|
config: {
|
|
5236
5413
|
...ctx.config,
|
|
5237
|
-
outDir
|
|
5414
|
+
outDir,
|
|
5415
|
+
...options?.overrides
|
|
5238
5416
|
}
|
|
5239
5417
|
};
|
|
5240
|
-
|
|
5418
|
+
const plan = planIncrementalRebuild(options?.changed);
|
|
5419
|
+
if (!options?.skipClean) await rm(outDir, {
|
|
5241
5420
|
recursive: true,
|
|
5242
5421
|
force: true
|
|
5243
5422
|
});
|
|
5244
5423
|
await mkdir(outDir, { recursive: true });
|
|
5245
5424
|
await withPhase(phaseContext, "bundle", () => bundle(phaseContext));
|
|
5246
|
-
await Promise.all([withPhase(phaseContext, "content", () => loadContent(phaseContext
|
|
5247
|
-
|
|
5425
|
+
await Promise.all([withPhase(phaseContext, "content", () => loadContent(phaseContext, {
|
|
5426
|
+
reuse: plan.contentReuse,
|
|
5427
|
+
changed: plan.contentChanged
|
|
5428
|
+
})), withPhase(phaseContext, "images", () => processImages(phaseContext))]);
|
|
5429
|
+
const pages = await withPhase(phaseContext, "pages", () => renderPages(phaseContext, { reuse: plan.renderReuse }));
|
|
5248
5430
|
await withPhase(phaseContext, "content-images", () => copyContentImages(phaseContext));
|
|
5249
5431
|
await runOutputs(phaseContext);
|
|
5250
5432
|
await withPhase(phaseContext, "root-index", async () => {
|
|
@@ -5297,10 +5479,12 @@ const defaultConfig$2 = {
|
|
|
5297
5479
|
function createApi$3(ctx) {
|
|
5298
5480
|
return {
|
|
5299
5481
|
/**
|
|
5300
|
-
* Run the full SSG pipeline and write the site to disk.
|
|
5482
|
+
* Run the full SSG pipeline and write the site to disk. With no options a full
|
|
5483
|
+
* production build runs; dev callers pass `skipClean`/`overrides`/`changed` for a
|
|
5484
|
+
* fast incremental rebuild (all gated behind opt-in fields — the default path is
|
|
5485
|
+
* unchanged).
|
|
5301
5486
|
*
|
|
5302
|
-
* @param options - Optional run overrides.
|
|
5303
|
-
* @param options.outDir - Override the configured output directory for this run.
|
|
5487
|
+
* @param options - Optional per-run overrides (outDir / skipClean / overrides / changed).
|
|
5304
5488
|
* @returns The build result (outDir, pageCount, durationMs).
|
|
5305
5489
|
* @example
|
|
5306
5490
|
* ```ts
|
|
@@ -5393,8 +5577,8 @@ function createEvents(register) {
|
|
|
5393
5577
|
/**
|
|
5394
5578
|
* Creates initial `build` plugin state: a frozen config snapshot plus empty
|
|
5395
5579
|
* per-run caches (`manifest`, `buildCache`, `runId`) and the cross-run OG
|
|
5396
|
-
* content-hash
|
|
5397
|
-
* duplicated here (pulled fresh via `ctx.require` each run).
|
|
5580
|
+
* content-hash + page-render caches. Holds caches and config only — no domain data
|
|
5581
|
+
* is duplicated here (pulled fresh via `ctx.require` each run).
|
|
5398
5582
|
*
|
|
5399
5583
|
* @param ctx - Minimal context with global and config.
|
|
5400
5584
|
* @param ctx.global - Global plugin registry (unused; caches are config-driven).
|
|
@@ -5411,7 +5595,8 @@ function createState$3(ctx) {
|
|
|
5411
5595
|
manifest: null,
|
|
5412
5596
|
buildCache: /* @__PURE__ */ new Map(),
|
|
5413
5597
|
runId: null,
|
|
5414
|
-
ogImageHashCache: /* @__PURE__ */ new Map()
|
|
5598
|
+
ogImageHashCache: /* @__PURE__ */ new Map(),
|
|
5599
|
+
renderCache: /* @__PURE__ */ new Map()
|
|
5415
5600
|
};
|
|
5416
5601
|
}
|
|
5417
5602
|
//#endregion
|
|
@@ -5635,12 +5820,37 @@ function buildWranglerArgs(input) {
|
|
|
5635
5820
|
branch
|
|
5636
5821
|
];
|
|
5637
5822
|
}
|
|
5823
|
+
/**
|
|
5824
|
+
* Assemble the argv for `wrangler pages project create` (no shell). Guards the
|
|
5825
|
+
* production branch against flag injection; the slug is already a safe `toSlug` output.
|
|
5826
|
+
*
|
|
5827
|
+
* @param input - The resolved project-create inputs.
|
|
5828
|
+
* @param input.slug - Cloudflare project-name slug (`toSlug(site.name())`).
|
|
5829
|
+
* @param input.branch - Production branch (guarded by `/^[a-zA-Z0-9/_.-]+$/`).
|
|
5830
|
+
* @returns The wrangler argv array.
|
|
5831
|
+
* @throws {Error} `ERR_DEPLOY_INVALID_BRANCH` when the branch fails the guard.
|
|
5832
|
+
* @example
|
|
5833
|
+
* buildProjectCreateArgs({ slug: "my-site", branch: "main" });
|
|
5834
|
+
*/
|
|
5835
|
+
function buildProjectCreateArgs(input) {
|
|
5836
|
+
const branch = guardBranch(input.branch);
|
|
5837
|
+
return [
|
|
5838
|
+
"bunx",
|
|
5839
|
+
"wrangler",
|
|
5840
|
+
"pages",
|
|
5841
|
+
"project",
|
|
5842
|
+
"create",
|
|
5843
|
+
input.slug,
|
|
5844
|
+
"--production-branch",
|
|
5845
|
+
branch
|
|
5846
|
+
];
|
|
5847
|
+
}
|
|
5638
5848
|
/** Lowercased substring matchers for the wrangler error taxonomy. */
|
|
5639
5849
|
const ERROR_SIGNATURES = [
|
|
5640
5850
|
{
|
|
5641
5851
|
match: ["could not find project", "project not found"],
|
|
5642
5852
|
kind: "ERR_DEPLOY_PROJECT_NOT_FOUND",
|
|
5643
|
-
advice: "The Cloudflare Pages project does not exist.
|
|
5853
|
+
advice: "The Cloudflare Pages project does not exist yet. Create it in the dashboard (Workers & Pages → Create → Pages) or with `bunx wrangler pages project create <name>`, then retry. (app.deploy.init() only scaffolds local config — it does not create the remote project.)"
|
|
5644
5854
|
},
|
|
5645
5855
|
{
|
|
5646
5856
|
match: [
|
|
@@ -6219,15 +6429,16 @@ function validateConfig$1(ctx) {
|
|
|
6219
6429
|
* Run wrangler for the prepared argv and surface its scrubbed result, translating
|
|
6220
6430
|
* a non-zero exit into the classified deploy error. The API token is read from env
|
|
6221
6431
|
* here so it never crosses a logging boundary; only scrubbed output is returned.
|
|
6432
|
+
* Shared by `run()` (deploy) and `createProject()` (project create).
|
|
6222
6433
|
*
|
|
6223
6434
|
* @param ctx - Plugin context (provides `state.spawn`, `config`, `env`).
|
|
6224
6435
|
* @param args - The fully-built, pre-validated wrangler argv.
|
|
6225
6436
|
* @returns The wrangler `stdout` plus the scrubbed `stderr` to log on success.
|
|
6226
6437
|
* @throws {Error} With a `code` from the deploy error taxonomy on a non-zero exit.
|
|
6227
6438
|
* @example
|
|
6228
|
-
* const { stdout, scrubbedStderr } = await
|
|
6439
|
+
* const { stdout, scrubbedStderr } = await executeWrangler(ctx, args);
|
|
6229
6440
|
*/
|
|
6230
|
-
async function
|
|
6441
|
+
async function executeWrangler(ctx, args) {
|
|
6231
6442
|
const token = ctx.env.require("CLOUDFLARE_API_TOKEN");
|
|
6232
6443
|
const { stdout, scrubbedStderr, exitCode } = await runWrangler({
|
|
6233
6444
|
spawn: ctx.state.spawn,
|
|
@@ -6299,7 +6510,7 @@ function createApi$2(ctx) {
|
|
|
6299
6510
|
root
|
|
6300
6511
|
});
|
|
6301
6512
|
const start = Date.now();
|
|
6302
|
-
const { stdout, scrubbedStderr } = await
|
|
6513
|
+
const { stdout, scrubbedStderr } = await executeWrangler(ctx, args);
|
|
6303
6514
|
ctx.log.info(scrubbedStderr);
|
|
6304
6515
|
const result = buildDeployResult(stdout, branch, start);
|
|
6305
6516
|
ctx.state.lastDeployment = result;
|
|
@@ -6338,6 +6549,38 @@ function createApi$2(ctx) {
|
|
|
6338
6549
|
cwd: process.cwd(),
|
|
6339
6550
|
options
|
|
6340
6551
|
});
|
|
6552
|
+
},
|
|
6553
|
+
/**
|
|
6554
|
+
* The Cloudflare Pages project name this app deploys to (`toSlug(site.name())`).
|
|
6555
|
+
*
|
|
6556
|
+
* @returns The project-name slug.
|
|
6557
|
+
* @example
|
|
6558
|
+
* api.projectName(); // "my-site"
|
|
6559
|
+
*/
|
|
6560
|
+
projectName() {
|
|
6561
|
+
return toSlug(ctx.require(sitePlugin).name());
|
|
6562
|
+
},
|
|
6563
|
+
/**
|
|
6564
|
+
* Create the remote Cloudflare Pages project via wrangler, so a first deploy has a
|
|
6565
|
+
* target. Derives the slug from `site.name()` and the production branch from config.
|
|
6566
|
+
*
|
|
6567
|
+
* @returns The created project name + production branch.
|
|
6568
|
+
* @throws {Error} With a `code` from the deploy error taxonomy on a non-zero exit.
|
|
6569
|
+
* @example
|
|
6570
|
+
* await api.createProject(); // { name: "my-site", branch: "main" }
|
|
6571
|
+
*/
|
|
6572
|
+
async createProject() {
|
|
6573
|
+
const name = toSlug(ctx.require(sitePlugin).name());
|
|
6574
|
+
const branch = ctx.config.productionBranch ?? "main";
|
|
6575
|
+
const { scrubbedStderr } = await executeWrangler(ctx, buildProjectCreateArgs({
|
|
6576
|
+
slug: name,
|
|
6577
|
+
branch
|
|
6578
|
+
}));
|
|
6579
|
+
ctx.log.info(scrubbedStderr);
|
|
6580
|
+
return {
|
|
6581
|
+
name,
|
|
6582
|
+
branch
|
|
6583
|
+
};
|
|
6341
6584
|
}
|
|
6342
6585
|
};
|
|
6343
6586
|
}
|
|
@@ -6449,13 +6692,14 @@ const deployPlugin = createPlugin$1("deploy", {
|
|
|
6449
6692
|
//#endregion
|
|
6450
6693
|
//#region src/plugins/cli/deploy-wizard.ts
|
|
6451
6694
|
/**
|
|
6452
|
-
* @file cli plugin — the guided deploy wizard (`cli.deploy({ guided: true })
|
|
6453
|
-
*
|
|
6454
|
-
* Cloudflare
|
|
6455
|
-
*
|
|
6456
|
-
*
|
|
6457
|
-
*
|
|
6458
|
-
*
|
|
6695
|
+
* @file cli plugin — the guided deploy wizard (`cli.deploy({ guided: true })`, the default
|
|
6696
|
+
* for `bun run deploy`; the direct `--cli` path stays in `api.ts`). Walks a human through a
|
|
6697
|
+
* Cloudflare Pages deploy: checks prerequisites (wrangler config + the Cloudflare
|
|
6698
|
+
* credentials) with concrete fix guidance, offers to scaffold what is missing (a
|
|
6699
|
+
* `wrangler.jsonc`, and a placeholder `.env` for any missing credentials), HARD-GATES the
|
|
6700
|
+
* deploy on everything being green, runs a local build smoke test, confirms, deploys, then
|
|
6701
|
+
* offers to scaffold a GitHub Actions workflow (auto on push to main, or a versioned/manual
|
|
6702
|
+
* trigger). Every prompt + line of output flows through injectable `state` seams.
|
|
6459
6703
|
*/
|
|
6460
6704
|
/** How to create a Cloudflare API token + where to make it available locally. */
|
|
6461
6705
|
const TOKEN_HELP = [
|
|
@@ -6470,21 +6714,56 @@ const ACCOUNT_HELP = [
|
|
|
6470
6714
|
"right-hand sidebar (also in the dashboard URL). Then make it available:",
|
|
6471
6715
|
" export CLOUDFLARE_ACCOUNT_ID=… or add it to .env."
|
|
6472
6716
|
].join("\n");
|
|
6717
|
+
/** Shown when a credential is in the raw environment but the app's env providers did not resolve it. */
|
|
6718
|
+
const PROVIDERS_HELP = [
|
|
6719
|
+
"Found in your shell/.env but the app's env plugin did not resolve it — its providers",
|
|
6720
|
+
"are not wired. Add the Node providers in createApp so the deploy can read it:",
|
|
6721
|
+
" pluginConfigs.env = { providers: [processEnv(), dotenv()] } (import them from @moku-labs/web)."
|
|
6722
|
+
].join("\n");
|
|
6473
6723
|
/** The GitHub repo secrets the generated workflow consumes. */
|
|
6474
6724
|
const SECRETS_HELP = ["Add these repo secrets (GitHub → Settings → Secrets and variables → Actions):", "CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID"].join("\n");
|
|
6475
6725
|
/**
|
|
6726
|
+
* Build one credential prerequisite by reading the SAME source the deploy reads — the
|
|
6727
|
+
* resolved `ctx.env` table — so a ✓ guarantees `ctx.env.require(key)` will succeed. When
|
|
6728
|
+
* the value is present in the raw `process.env` but unresolved by the app's providers
|
|
6729
|
+
* (the silent "deploy can't see it" trap a bare `process.env` check would mark green),
|
|
6730
|
+
* the fix hint points at wiring the providers instead of re-adding the value.
|
|
6731
|
+
*
|
|
6732
|
+
* @param ctx - The cli plugin context (provides the resolved `ctx.env`).
|
|
6733
|
+
* @param key - The credential variable name.
|
|
6734
|
+
* @param label - The diagnostic line label.
|
|
6735
|
+
* @param missingHelp - The fix hint when the credential is genuinely absent everywhere.
|
|
6736
|
+
* @returns The credential prerequisite check.
|
|
6737
|
+
* @example
|
|
6738
|
+
* credentialPrereq(ctx, "CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_TOKEN is set", TOKEN_HELP);
|
|
6739
|
+
*/
|
|
6740
|
+
function credentialPrereq(ctx, key, label, missingHelp) {
|
|
6741
|
+
if ((ctx.env.get(key) ?? "") !== "") return {
|
|
6742
|
+
ok: true,
|
|
6743
|
+
label,
|
|
6744
|
+
detail: void 0,
|
|
6745
|
+
scaffoldable: false
|
|
6746
|
+
};
|
|
6747
|
+
return {
|
|
6748
|
+
ok: false,
|
|
6749
|
+
label,
|
|
6750
|
+
detail: (process.env[key] ?? "") !== "" ? PROVIDERS_HELP : missingHelp,
|
|
6751
|
+
scaffoldable: false
|
|
6752
|
+
};
|
|
6753
|
+
}
|
|
6754
|
+
/**
|
|
6476
6755
|
* Evaluate the three deploy prerequisites against the current project: the Cloudflare
|
|
6477
|
-
* wrangler config exists, and both Cloudflare credentials
|
|
6756
|
+
* wrangler config exists, and both Cloudflare credentials resolve through `ctx.env` (the
|
|
6757
|
+
* deploy's own source of truth — not a bare `process.env` read that can diverge from it).
|
|
6478
6758
|
*
|
|
6759
|
+
* @param ctx - The cli plugin context (provides the resolved `ctx.env`).
|
|
6479
6760
|
* @param cwd - The project root (where `wrangler.jsonc` lives).
|
|
6480
6761
|
* @returns The ordered prerequisite checks.
|
|
6481
6762
|
* @example
|
|
6482
|
-
* const prereqs = diagnose(process.cwd());
|
|
6763
|
+
* const prereqs = diagnose(ctx, process.cwd());
|
|
6483
6764
|
*/
|
|
6484
|
-
function diagnose(cwd) {
|
|
6765
|
+
function diagnose(ctx, cwd) {
|
|
6485
6766
|
const wranglerOk = existsSync(path.join(cwd, "wrangler.jsonc"));
|
|
6486
|
-
const tokenOk = (process.env.CLOUDFLARE_API_TOKEN ?? "") !== "";
|
|
6487
|
-
const accountOk = (process.env.CLOUDFLARE_ACCOUNT_ID ?? "") !== "";
|
|
6488
6767
|
return [
|
|
6489
6768
|
{
|
|
6490
6769
|
ok: wranglerOk,
|
|
@@ -6492,18 +6771,8 @@ function diagnose(cwd) {
|
|
|
6492
6771
|
detail: wranglerOk ? void 0 : "Missing — scaffold it (offered below) or run app.deploy.init().",
|
|
6493
6772
|
scaffoldable: true
|
|
6494
6773
|
},
|
|
6495
|
-
|
|
6496
|
-
|
|
6497
|
-
label: "CLOUDFLARE_API_TOKEN is set",
|
|
6498
|
-
detail: tokenOk ? void 0 : TOKEN_HELP,
|
|
6499
|
-
scaffoldable: false
|
|
6500
|
-
},
|
|
6501
|
-
{
|
|
6502
|
-
ok: accountOk,
|
|
6503
|
-
label: "CLOUDFLARE_ACCOUNT_ID is set",
|
|
6504
|
-
detail: accountOk ? void 0 : ACCOUNT_HELP,
|
|
6505
|
-
scaffoldable: false
|
|
6506
|
-
}
|
|
6774
|
+
credentialPrereq(ctx, "CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_TOKEN is set", TOKEN_HELP),
|
|
6775
|
+
credentialPrereq(ctx, "CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_ACCOUNT_ID is set", ACCOUNT_HELP)
|
|
6507
6776
|
];
|
|
6508
6777
|
}
|
|
6509
6778
|
/**
|
|
@@ -6522,6 +6791,43 @@ async function offerScaffold(ctx, prereqs) {
|
|
|
6522
6791
|
await ctx.require(deployPlugin).init({});
|
|
6523
6792
|
ctx.state.render.check(true, "wrangler.jsonc scaffolded");
|
|
6524
6793
|
}
|
|
6794
|
+
/** The Cloudflare credentials the deploy needs, with the comment written above each in a scaffolded `.env`. */
|
|
6795
|
+
const ENV_CREDENTIALS = [{
|
|
6796
|
+
key: "CLOUDFLARE_API_TOKEN",
|
|
6797
|
+
comment: "# Cloudflare API token — https://dash.cloudflare.com/profile/api-tokens (template: Cloudflare Pages — Edit)"
|
|
6798
|
+
}, {
|
|
6799
|
+
key: "CLOUDFLARE_ACCOUNT_ID",
|
|
6800
|
+
comment: "# Cloudflare account id — dashboard → Workers & Pages → right-hand sidebar"
|
|
6801
|
+
}];
|
|
6802
|
+
/**
|
|
6803
|
+
* Offer to scaffold a `.env` with placeholders for whichever Cloudflare credentials are
|
|
6804
|
+
* missing — created when absent, appended to (never clobbering a key already present)
|
|
6805
|
+
* when it exists. The placeholders are empty, so the deploy still hard-gates until the
|
|
6806
|
+
* user fills them in; this just removes the "where do I even put these?" friction.
|
|
6807
|
+
*
|
|
6808
|
+
* @param ctx - The cli plugin context.
|
|
6809
|
+
* @param cwd - The project root (where `.env` lives).
|
|
6810
|
+
* @returns Resolves once any accepted scaffold has been written.
|
|
6811
|
+
* @example
|
|
6812
|
+
* await offerEnvScaffold(ctx, process.cwd());
|
|
6813
|
+
*/
|
|
6814
|
+
async function offerEnvScaffold(ctx, cwd) {
|
|
6815
|
+
const missing = ENV_CREDENTIALS.filter(({ key }) => (process.env[key] ?? "") === "");
|
|
6816
|
+
if (missing.length === 0) return;
|
|
6817
|
+
const envPath = path.join(cwd, ".env");
|
|
6818
|
+
const exists = existsSync(envPath);
|
|
6819
|
+
const verb = exists ? "Add placeholders for the missing secret(s) to" : "Create";
|
|
6820
|
+
if (!await ctx.state.confirm(`${verb} .env?`)) return;
|
|
6821
|
+
const lines = exists ? readFileSync(envPath, "utf8").split(/\r?\n/) : [];
|
|
6822
|
+
const toAdd = missing.filter(({ key }) => !lines.some((line) => line.trimStart().startsWith(`${key}=`)));
|
|
6823
|
+
if (toAdd.length === 0) {
|
|
6824
|
+
ctx.state.render.info(".env already lists those keys — fill in their values, then re-run.");
|
|
6825
|
+
return;
|
|
6826
|
+
}
|
|
6827
|
+
appendFileSync(envPath, `${exists ? "\n" : "# Cloudflare Pages deploy credentials — fill these in (keep .env gitignored).\n"}${toAdd.map(({ key, comment }) => `${comment}\n${key}=`).join("\n\n")}\n`);
|
|
6828
|
+
const names = toAdd.map(({ key }) => key).join(", ");
|
|
6829
|
+
ctx.state.render.check(true, `${exists ? "added placeholders to" : "created"} .env`, `fill in ${names}, then re-run \`bun run deploy\`.`);
|
|
6830
|
+
}
|
|
6525
6831
|
/**
|
|
6526
6832
|
* Map a top-level workflow choice (and, for the versioned option, a sub-choice) to the
|
|
6527
6833
|
* concrete {@link WorkflowTrigger}, or `null` when the user chose to skip setup.
|
|
@@ -6564,8 +6870,139 @@ async function offerWorkflowSetup(ctx) {
|
|
|
6564
6870
|
ctx.state.render.info(SECRETS_HELP);
|
|
6565
6871
|
}
|
|
6566
6872
|
/**
|
|
6567
|
-
*
|
|
6568
|
-
*
|
|
6873
|
+
* Read the taxonomy `code` off a thrown value, when present. Deploy errors carry a
|
|
6874
|
+
* `code` (e.g. `ERR_DEPLOY_PROJECT_NOT_FOUND`) so the wizard can tailor the fix hint.
|
|
6875
|
+
*
|
|
6876
|
+
* @param error - The thrown value.
|
|
6877
|
+
* @returns The `code` string, or `undefined` when absent.
|
|
6878
|
+
* @example
|
|
6879
|
+
* codeOf(deployError("ERR_DEPLOY_AUTH", "…")); // "ERR_DEPLOY_AUTH"
|
|
6880
|
+
*/
|
|
6881
|
+
function codeOf(error) {
|
|
6882
|
+
if (typeof error === "object" && error !== null && "code" in error) {
|
|
6883
|
+
const { code } = error;
|
|
6884
|
+
return typeof code === "string" ? code : void 0;
|
|
6885
|
+
}
|
|
6886
|
+
}
|
|
6887
|
+
/**
|
|
6888
|
+
* A copy-pasteable "create the project yourself" hint, shown when the user declines the
|
|
6889
|
+
* offer to auto-create. Spells out that the remote project is what's missing (init only
|
|
6890
|
+
* scaffolds local config).
|
|
6891
|
+
*
|
|
6892
|
+
* @param name - The Cloudflare Pages project name (the deploy slug).
|
|
6893
|
+
* @returns The multi-line hint (newline-separated; rendered indented under a `›`).
|
|
6894
|
+
* @example
|
|
6895
|
+
* ctx.state.render.info(projectNotFoundHint("my-site"));
|
|
6896
|
+
*/
|
|
6897
|
+
function projectNotFoundHint(name) {
|
|
6898
|
+
return [
|
|
6899
|
+
"how to fix: the Cloudflare Pages project does not exist yet — create it once, then",
|
|
6900
|
+
"re-run `bun run deploy`. (app.deploy.init() only scaffolds local config; it does not",
|
|
6901
|
+
"create the remote project.)",
|
|
6902
|
+
` • CLI: bunx wrangler pages project create ${name} --production-branch main`,
|
|
6903
|
+
" • Dashboard: Cloudflare → Workers & Pages → Create → Pages"
|
|
6904
|
+
].join("\n");
|
|
6905
|
+
}
|
|
6906
|
+
/**
|
|
6907
|
+
* An actionable, error-specific "how to fix" hint for a failed deploy (other than the
|
|
6908
|
+
* project-not-found case, which the wizard handles interactively), so the user never
|
|
6909
|
+
* lands on a raw stack trace.
|
|
6910
|
+
*
|
|
6911
|
+
* @param error - The thrown deploy error.
|
|
6912
|
+
* @returns The fix hint line.
|
|
6913
|
+
* @example
|
|
6914
|
+
* ctx.state.render.info(deployFailureHint(err));
|
|
6915
|
+
*/
|
|
6916
|
+
function deployFailureHint$1(error) {
|
|
6917
|
+
const code = codeOf(error);
|
|
6918
|
+
if (code === "ERR_DEPLOY_AUTH" || code === "ERR_DEPLOY_AUTH_EXPIRED") return "how to fix: refresh CLOUDFLARE_API_TOKEN (scope: Account › Cloudflare Pages › Edit), then re-run `bun run deploy`.";
|
|
6919
|
+
if (code === "ERR_DEPLOY_NETWORK") return "how to fix: a network error reached Cloudflare — check connectivity, then re-run `bun run deploy`.";
|
|
6920
|
+
return "how to fix: resolve the error above, then re-run `bun run deploy`.";
|
|
6921
|
+
}
|
|
6922
|
+
/**
|
|
6923
|
+
* Render a styled deploy failure (✗ + fix hint) and return the `"failed"` outcome, so a
|
|
6924
|
+
* caught error surfaces consistently instead of as a raw throw.
|
|
6925
|
+
*
|
|
6926
|
+
* @param ctx - The cli plugin context.
|
|
6927
|
+
* @param error - The thrown deploy error.
|
|
6928
|
+
* @returns The `"failed"` deploy outcome.
|
|
6929
|
+
* @example
|
|
6930
|
+
* return renderFailure(ctx, error);
|
|
6931
|
+
*/
|
|
6932
|
+
function renderFailure(ctx, error) {
|
|
6933
|
+
ctx.state.render.error("deploy failed", error);
|
|
6934
|
+
ctx.state.render.info(deployFailureHint$1(error));
|
|
6935
|
+
return {
|
|
6936
|
+
deployed: false,
|
|
6937
|
+
reason: "failed"
|
|
6938
|
+
};
|
|
6939
|
+
}
|
|
6940
|
+
/**
|
|
6941
|
+
* Deploy once via the deploy plugin and wrap the result as a successful outcome. Throws
|
|
6942
|
+
* the classified deploy error on failure (the caller decides how to surface it).
|
|
6943
|
+
*
|
|
6944
|
+
* @param ctx - The cli plugin context.
|
|
6945
|
+
* @param options - The deploy options (branch override).
|
|
6946
|
+
* @returns The successful deploy outcome.
|
|
6947
|
+
* @throws {Error} With a `code` from the deploy error taxonomy on any failure.
|
|
6948
|
+
* @example
|
|
6949
|
+
* const outcome = await deployOnce(ctx, { branch: "main" });
|
|
6950
|
+
*/
|
|
6951
|
+
async function deployOnce(ctx, options) {
|
|
6952
|
+
return {
|
|
6953
|
+
deployed: true,
|
|
6954
|
+
...await ctx.require(deployPlugin).run(options.branch === void 0 ? {} : { branch: options.branch })
|
|
6955
|
+
};
|
|
6956
|
+
}
|
|
6957
|
+
/**
|
|
6958
|
+
* Handle a project-not-found deploy failure interactively: ask (a confirmation step)
|
|
6959
|
+
* before creating a real Cloudflare resource, create the Pages project via the deploy
|
|
6960
|
+
* plugin, then retry the deploy once. A declined offer (or a create failure) returns the
|
|
6961
|
+
* `"failed"` outcome with an actionable hint — never a raw stack trace.
|
|
6962
|
+
*
|
|
6963
|
+
* @param ctx - The cli plugin context.
|
|
6964
|
+
* @param options - The deploy options (branch override).
|
|
6965
|
+
* @param originalError - The project-not-found error from the first attempt.
|
|
6966
|
+
* @returns The deploy outcome (deployed after a successful create + retry, else failed).
|
|
6967
|
+
* @example
|
|
6968
|
+
* return createProjectThenRetry(ctx, options, error);
|
|
6969
|
+
*/
|
|
6970
|
+
async function createProjectThenRetry(ctx, options, originalError) {
|
|
6971
|
+
const deploy = ctx.require(deployPlugin);
|
|
6972
|
+
const name = deploy.projectName();
|
|
6973
|
+
ctx.state.render.warn(`The Cloudflare Pages project "${name}" does not exist yet.`);
|
|
6974
|
+
if (!await ctx.state.confirm(`Create the Cloudflare Pages project "${name}" now?`)) {
|
|
6975
|
+
ctx.state.render.error("deploy failed", originalError);
|
|
6976
|
+
ctx.state.render.info(projectNotFoundHint(name));
|
|
6977
|
+
return {
|
|
6978
|
+
deployed: false,
|
|
6979
|
+
reason: "failed"
|
|
6980
|
+
};
|
|
6981
|
+
}
|
|
6982
|
+
try {
|
|
6983
|
+
const created = await deploy.createProject();
|
|
6984
|
+
ctx.state.render.check(true, `created Cloudflare Pages project "${created.name}"`);
|
|
6985
|
+
} catch (error) {
|
|
6986
|
+
ctx.state.render.error("could not create the Pages project", error);
|
|
6987
|
+
ctx.state.render.info(deployFailureHint$1(error));
|
|
6988
|
+
return {
|
|
6989
|
+
deployed: false,
|
|
6990
|
+
reason: "failed"
|
|
6991
|
+
};
|
|
6992
|
+
}
|
|
6993
|
+
ctx.state.render.info("project created — retrying the deploy…");
|
|
6994
|
+
try {
|
|
6995
|
+
return await deployOnce(ctx, options);
|
|
6996
|
+
} catch (error) {
|
|
6997
|
+
return renderFailure(ctx, error);
|
|
6998
|
+
}
|
|
6999
|
+
}
|
|
7000
|
+
/**
|
|
7001
|
+
* Run the deploy step: confirm (unless `yes`), then deploy via the deploy plugin. A
|
|
7002
|
+
* declined confirm returns `{ deployed: false, reason: "declined" }`. A project-not-found
|
|
7003
|
+
* failure offers to create the project (with a confirmation step) and retries; any other
|
|
7004
|
+
* runtime failure is surfaced as a styled error + fix hint, returning
|
|
7005
|
+
* `{ deployed: false, reason: "failed" }` — never a raw stack trace.
|
|
6569
7006
|
*
|
|
6570
7007
|
* @param ctx - The cli plugin context.
|
|
6571
7008
|
* @param options - The deploy options (branch override + `yes`).
|
|
@@ -6582,10 +7019,12 @@ async function runDeployStep(ctx, options) {
|
|
|
6582
7019
|
reason: "declined"
|
|
6583
7020
|
};
|
|
6584
7021
|
}
|
|
6585
|
-
|
|
6586
|
-
|
|
6587
|
-
|
|
6588
|
-
|
|
7022
|
+
try {
|
|
7023
|
+
return await deployOnce(ctx, options);
|
|
7024
|
+
} catch (error) {
|
|
7025
|
+
if (codeOf(error) === "ERR_DEPLOY_PROJECT_NOT_FOUND") return createProjectThenRetry(ctx, options, error);
|
|
7026
|
+
return renderFailure(ctx, error);
|
|
7027
|
+
}
|
|
6589
7028
|
}
|
|
6590
7029
|
/**
|
|
6591
7030
|
* Run the guided deploy wizard end to end: diagnose prerequisites (offering to scaffold
|
|
@@ -6603,9 +7042,10 @@ async function runDeployStep(ctx, options) {
|
|
|
6603
7042
|
async function runDeployWizard(ctx, options) {
|
|
6604
7043
|
const cwd = process.cwd();
|
|
6605
7044
|
ctx.state.render.heading("Checking prerequisites");
|
|
6606
|
-
for (const item of diagnose(cwd)) ctx.state.render.check(item.ok, item.label, item.detail);
|
|
6607
|
-
await offerScaffold(ctx, diagnose(cwd));
|
|
6608
|
-
|
|
7045
|
+
for (const item of diagnose(ctx, cwd)) ctx.state.render.check(item.ok, item.label, item.detail);
|
|
7046
|
+
await offerScaffold(ctx, diagnose(ctx, cwd));
|
|
7047
|
+
await offerEnvScaffold(ctx, cwd);
|
|
7048
|
+
const blockers = diagnose(ctx, cwd).filter((item) => !item.ok);
|
|
6609
7049
|
if (blockers.length > 0) {
|
|
6610
7050
|
ctx.state.render.heading("Not ready to deploy");
|
|
6611
7051
|
for (const item of blockers) ctx.state.render.check(false, item.label, item.detail);
|
|
@@ -6622,7 +7062,7 @@ async function runDeployWizard(ctx, options) {
|
|
|
6622
7062
|
ctx.state.render.check(notFoundOk, `${ctx.config.notFoundFile} present`, notFoundOk ? void 0 : "Set build.notFound so the SSG emits it (CF Pages else flips to SPA mode).");
|
|
6623
7063
|
ctx.state.render.info("Tip: run `bun run preview` to eyeball the built site before deploying.");
|
|
6624
7064
|
const outcome = await runDeployStep(ctx, options);
|
|
6625
|
-
await offerWorkflowSetup(ctx);
|
|
7065
|
+
if (!(outcome.deployed === false && outcome.reason === "failed")) await offerWorkflowSetup(ctx);
|
|
6626
7066
|
return outcome;
|
|
6627
7067
|
}
|
|
6628
7068
|
//#endregion
|
|
@@ -6662,25 +7102,26 @@ function injectReloadClient(html) {
|
|
|
6662
7102
|
* Run one rebuild and report the result. Announces the start (`onRebuildStart`), then
|
|
6663
7103
|
* routes success to `onReloaded` and failure to `onError`.
|
|
6664
7104
|
*
|
|
6665
|
-
* @param input - The rebuild dependencies + the changed file.
|
|
6666
|
-
* @param input.runBuild - Runs one build and resolves with its summary.
|
|
7105
|
+
* @param input - The rebuild dependencies + the changed file/paths.
|
|
7106
|
+
* @param input.runBuild - Runs one build (given the changed paths) and resolves with its summary.
|
|
6667
7107
|
* @param input.onRebuildStart - Called with the changed file just before the build runs.
|
|
6668
|
-
* @param input.onReloaded - Called with the changed file + summary after a rebuild.
|
|
7108
|
+
* @param input.onReloaded - Called with the changed file + summary + the built `changed` set after a rebuild.
|
|
6669
7109
|
* @param input.onError - Called when a rebuild throws.
|
|
6670
7110
|
* @param input.file - The changed file to report alongside the summary.
|
|
7111
|
+
* @param input.changed - The accumulated changed paths handed to `runBuild` (incremental).
|
|
6671
7112
|
* @returns Resolves once the rebuild settles (always — errors are routed, not thrown).
|
|
6672
7113
|
* @example
|
|
6673
|
-
* await runOneRebuild({ runBuild, onReloaded, onError, file: "a.md" });
|
|
7114
|
+
* await runOneRebuild({ runBuild, onReloaded, onError, file: "a.md", changed: ["a.md"] });
|
|
6674
7115
|
*/
|
|
6675
7116
|
async function runOneRebuild(input) {
|
|
6676
7117
|
input.onRebuildStart?.(input.file);
|
|
6677
7118
|
try {
|
|
6678
|
-
const summary = await input.runBuild();
|
|
7119
|
+
const summary = await input.runBuild(input.changed);
|
|
6679
7120
|
input.onReloaded({
|
|
6680
7121
|
file: input.file,
|
|
6681
7122
|
pageCount: summary.pageCount,
|
|
6682
7123
|
durationMs: summary.durationMs
|
|
6683
|
-
});
|
|
7124
|
+
}, input.changed);
|
|
6684
7125
|
} catch (error) {
|
|
6685
7126
|
input.onError(error);
|
|
6686
7127
|
}
|
|
@@ -6694,9 +7135,9 @@ async function runOneRebuild(input) {
|
|
|
6694
7135
|
*
|
|
6695
7136
|
* @param input - The rebuild dependencies.
|
|
6696
7137
|
* @param input.debounceMs - Debounce window in milliseconds.
|
|
6697
|
-
* @param input.runBuild - Runs one build and resolves with its summary.
|
|
7138
|
+
* @param input.runBuild - Runs one build (given the changed paths) and resolves with its summary.
|
|
6698
7139
|
* @param input.onRebuildStart - Called with the changed file just before each build runs.
|
|
6699
|
-
* @param input.onReloaded - Called with the changed file + summary after a rebuild.
|
|
7140
|
+
* @param input.onReloaded - Called with the changed file + summary + the built `changed` set after a rebuild.
|
|
6700
7141
|
* @param input.onError - Called when a rebuild throws.
|
|
6701
7142
|
* @returns The debounced rebuild driver.
|
|
6702
7143
|
* @example
|
|
@@ -6705,12 +7146,13 @@ async function runOneRebuild(input) {
|
|
|
6705
7146
|
function createRebuilder(input) {
|
|
6706
7147
|
let timer;
|
|
6707
7148
|
let pendingFile = "";
|
|
7149
|
+
const pendingChanged = /* @__PURE__ */ new Set();
|
|
6708
7150
|
let building = false;
|
|
6709
7151
|
let dirty = false;
|
|
6710
7152
|
/**
|
|
6711
|
-
* Rebuild repeatedly until no change arrived mid-flight: each pass clears
|
|
6712
|
-
* runs one build, then loops again if a
|
|
6713
|
-
* no change is dropped.
|
|
7153
|
+
* Rebuild repeatedly until no change arrived mid-flight: each pass snapshots + clears
|
|
7154
|
+
* the accumulated changed paths, runs one build over them, then loops again if a
|
|
7155
|
+
* `schedule()` set `dirty` (and added more paths) while it ran, so no change is dropped.
|
|
6714
7156
|
*
|
|
6715
7157
|
* @returns Resolves once a pass completes with no pending change (errors are routed,
|
|
6716
7158
|
* never thrown).
|
|
@@ -6720,12 +7162,15 @@ function createRebuilder(input) {
|
|
|
6720
7162
|
const drainPendingRebuilds = async () => {
|
|
6721
7163
|
do {
|
|
6722
7164
|
dirty = false;
|
|
7165
|
+
const changed = [...pendingChanged];
|
|
7166
|
+
pendingChanged.clear();
|
|
6723
7167
|
await runOneRebuild({
|
|
6724
7168
|
runBuild: input.runBuild,
|
|
6725
7169
|
...input.onRebuildStart ? { onRebuildStart: input.onRebuildStart } : {},
|
|
6726
7170
|
onReloaded: input.onReloaded,
|
|
6727
7171
|
onError: input.onError,
|
|
6728
|
-
file: pendingFile
|
|
7172
|
+
file: pendingFile,
|
|
7173
|
+
changed
|
|
6729
7174
|
});
|
|
6730
7175
|
} while (dirty);
|
|
6731
7176
|
};
|
|
@@ -6752,14 +7197,15 @@ function createRebuilder(input) {
|
|
|
6752
7197
|
};
|
|
6753
7198
|
return {
|
|
6754
7199
|
/**
|
|
6755
|
-
* Queue a rebuild for the given
|
|
7200
|
+
* Queue a rebuild for the given changed path (debounced + coalesced + accumulated).
|
|
6756
7201
|
*
|
|
6757
|
-
* @param file - The
|
|
7202
|
+
* @param file - The changed path reported as `ReloadInfo.file` and added to the changed set.
|
|
6758
7203
|
* @example
|
|
6759
|
-
* rebuilder.schedule("content");
|
|
7204
|
+
* rebuilder.schedule("content/intro/en.md");
|
|
6760
7205
|
*/
|
|
6761
7206
|
schedule(file) {
|
|
6762
7207
|
pendingFile = file;
|
|
7208
|
+
pendingChanged.add(file);
|
|
6763
7209
|
if (timer) clearTimeout(timer);
|
|
6764
7210
|
timer = setTimeout(fire, input.debounceMs);
|
|
6765
7211
|
},
|
|
@@ -6789,26 +7235,33 @@ function isNoisePath(filename) {
|
|
|
6789
7235
|
return filename.split(/[/\\]/).some((segment) => segment.startsWith(".")) || filename.endsWith("~");
|
|
6790
7236
|
}
|
|
6791
7237
|
/**
|
|
6792
|
-
* Create a {@link ChangeGate} that drops
|
|
6793
|
-
*
|
|
6794
|
-
* `outDir` (the build's own output — a loop guard),
|
|
6795
|
-
*
|
|
6796
|
-
*
|
|
6797
|
-
*
|
|
6798
|
-
*
|
|
6799
|
-
*
|
|
7238
|
+
* Create a {@link ChangeGate} that drops four kinds of spurious change events before they
|
|
7239
|
+
* reach the debounced rebuilder: editor/OS noise (dotfiles, backups), writes under
|
|
7240
|
+
* `outDir` (the build's own output — a loop guard), the stale duplicate/parent-dir echoes
|
|
7241
|
+
* macOS fires for one save (a build-start high-water mark — a change whose mtime is at or
|
|
7242
|
+
* before the last build we started was already captured), and — when a `fileHash` seam is
|
|
7243
|
+
* supplied — a NO-OP save whose bytes are identical to the last successfully-built version
|
|
7244
|
+
* (a double Ctrl-S, a `touch`, a format-on-save that reverts). The no-op baseline is
|
|
7245
|
+
* recorded ONLY by {@link ChangeGate.commitBuilt} on build SUCCESS, scoped to that build's
|
|
7246
|
+
* paths — so a failed build commits nothing (a retry save always rebuilds) and a file
|
|
7247
|
+
* edited mid-build is never falsely baselined by another file's success. A genuinely newer
|
|
7248
|
+
* edit (even mid-build) and a deletion (missing file) always pass.
|
|
6800
7249
|
*
|
|
6801
7250
|
* @param input - The gate dependencies.
|
|
6802
7251
|
* @param input.outDir - The build output directory whose writes must never re-trigger a build.
|
|
6803
7252
|
* @param input.fileMtime - Resolves a path's mtime in ms (or `null` when missing).
|
|
6804
7253
|
* @param input.now - Monotonic wall clock (ms) used for the build-start high-water mark.
|
|
7254
|
+
* @param input.fileHash - Resolves a path's content hash (or `null` when missing). Optional;
|
|
7255
|
+
* defaults to `() => null`, which disables the no-op-save short-circuit (every edit passes).
|
|
6805
7256
|
* @returns The change gate.
|
|
6806
7257
|
* @example
|
|
6807
|
-
* const gate = createChangeGate({ outDir: "dist", fileMtime: state.fileMtime, now: state.clock });
|
|
7258
|
+
* const gate = createChangeGate({ outDir: "dist", fileMtime: state.fileMtime, now: state.clock, fileHash: state.fileHash });
|
|
6808
7259
|
*/
|
|
6809
7260
|
function createChangeGate(input) {
|
|
6810
7261
|
const outDirAbs = path.resolve(input.outDir);
|
|
7262
|
+
const fileHash = input.fileHash ?? (() => null);
|
|
6811
7263
|
let lastBuildStartedAt = input.now();
|
|
7264
|
+
const committedHash = /* @__PURE__ */ new Map();
|
|
6812
7265
|
return {
|
|
6813
7266
|
/**
|
|
6814
7267
|
* Decide whether a change beneath `dir` warrants a rebuild (see {@link ChangeGate.accept}).
|
|
@@ -6826,6 +7279,12 @@ function createChangeGate(input) {
|
|
|
6826
7279
|
if (changed === outDirAbs || changed.startsWith(`${outDirAbs}${path.sep}`)) return false;
|
|
6827
7280
|
const mtime = input.fileMtime(changed);
|
|
6828
7281
|
if (mtime !== null && mtime < lastBuildStartedAt) return false;
|
|
7282
|
+
const hash = fileHash(changed);
|
|
7283
|
+
if (hash === null) {
|
|
7284
|
+
committedHash.delete(changed);
|
|
7285
|
+
return true;
|
|
7286
|
+
}
|
|
7287
|
+
if (committedHash.get(changed) === hash) return false;
|
|
6829
7288
|
return true;
|
|
6830
7289
|
},
|
|
6831
7290
|
/**
|
|
@@ -6836,6 +7295,20 @@ function createChangeGate(input) {
|
|
|
6836
7295
|
*/
|
|
6837
7296
|
markBuildStart() {
|
|
6838
7297
|
lastBuildStartedAt = input.now();
|
|
7298
|
+
},
|
|
7299
|
+
/**
|
|
7300
|
+
* Baseline exactly the paths the just-succeeded build consumed (see {@link ChangeGate.commitBuilt}).
|
|
7301
|
+
*
|
|
7302
|
+
* @param changed - The paths the just-succeeded build consumed.
|
|
7303
|
+
* @example
|
|
7304
|
+
* gate.commitBuilt(["content/intro/en.md"]);
|
|
7305
|
+
*/
|
|
7306
|
+
commitBuilt(changed) {
|
|
7307
|
+
for (const file of changed) {
|
|
7308
|
+
const key = path.resolve(file);
|
|
7309
|
+
const hash = fileHash(key);
|
|
7310
|
+
if (hash !== null) committedHash.set(key, hash);
|
|
7311
|
+
}
|
|
6839
7312
|
}
|
|
6840
7313
|
};
|
|
6841
7314
|
}
|
|
@@ -7028,19 +7501,46 @@ function createDevHandler(ctx, hub) {
|
|
|
7028
7501
|
};
|
|
7029
7502
|
}
|
|
7030
7503
|
/**
|
|
7504
|
+
* Build the per-run {@link BuildRunOverrides} for a dev build from the session feature
|
|
7505
|
+
* opt-ins: minification is always off in dev (no benefit, slower), and each expensive
|
|
7506
|
+
* output stays off unless its flag re-enables it (`ogImage: false` disables OG generation
|
|
7507
|
+
* regardless of the persisted config). The persisted plugin config is never mutated — the
|
|
7508
|
+
* overrides apply to the dev run only.
|
|
7509
|
+
*
|
|
7510
|
+
* @param features - The resolved per-session dev feature opt-ins.
|
|
7511
|
+
* @returns The config overrides merged into the dev build run.
|
|
7512
|
+
* @example
|
|
7513
|
+
* devBuildOverrides({ og: false, sitemap: false, feeds: false, localeRedirects: false });
|
|
7514
|
+
*/
|
|
7515
|
+
function devBuildOverrides(features) {
|
|
7516
|
+
return {
|
|
7517
|
+
minify: false,
|
|
7518
|
+
...features.feeds ? {} : { feeds: false },
|
|
7519
|
+
...features.sitemap ? {} : { sitemap: false },
|
|
7520
|
+
...features.og ? {} : { ogImage: false },
|
|
7521
|
+
...features.localeRedirects ? {} : { localeRedirects: false }
|
|
7522
|
+
};
|
|
7523
|
+
}
|
|
7524
|
+
/**
|
|
7031
7525
|
* Run the dev loop: an initial build, an in-process static server that injects the
|
|
7032
7526
|
* live-reload client, a recursive watcher over `config.watchDirs`, and a debounced
|
|
7033
7527
|
* rebuild that re-renders and pushes a browser reload. Resolves on SIGINT/SIGTERM,
|
|
7034
|
-
* which stops the server, closes the watchers, and cancels any pending rebuild.
|
|
7528
|
+
* which stops the server, closes the watchers, and cancels any pending rebuild. The dev
|
|
7529
|
+
* build disables minification + expensive outputs (per {@link devBuildOverrides}); every
|
|
7530
|
+
* rebuild also skips the clean so caches + unchanged assets survive (no mid-rebuild 404).
|
|
7531
|
+
* Because rebuilds skip the clean, a DELETED or renamed content slug's stale page lingers
|
|
7532
|
+
* (and is served) until you restart `serve` or run a production `build`.
|
|
7035
7533
|
*
|
|
7036
7534
|
* @param ctx - The cli plugin context (config, state seams, `require`).
|
|
7037
7535
|
* @param port - The port to bind the dev server to.
|
|
7536
|
+
* @param features - Per-session dev feature opt-ins (`og`/`sitemap`/`feeds`/`localeRedirects`).
|
|
7038
7537
|
* @returns Resolves once the server has been torn down by a termination signal.
|
|
7039
7538
|
* @example
|
|
7040
|
-
* await runDevServer(ctx, 4173);
|
|
7539
|
+
* await runDevServer(ctx, 4173, { og: false, sitemap: false, feeds: false, localeRedirects: false });
|
|
7041
7540
|
*/
|
|
7042
|
-
async function runDevServer(ctx, port) {
|
|
7043
|
-
|
|
7541
|
+
async function runDevServer(ctx, port, features) {
|
|
7542
|
+
const overrides = devBuildOverrides(features);
|
|
7543
|
+
await ctx.require(buildPlugin).run({ overrides });
|
|
7044
7544
|
const hub = createReloadHub();
|
|
7045
7545
|
const server = ctx.state.serveStatic({
|
|
7046
7546
|
port,
|
|
@@ -7050,19 +7550,27 @@ async function runDevServer(ctx, port) {
|
|
|
7050
7550
|
const gate = createChangeGate({
|
|
7051
7551
|
outDir: ctx.config.outDir,
|
|
7052
7552
|
fileMtime: ctx.state.fileMtime,
|
|
7053
|
-
now: ctx.state.clock
|
|
7553
|
+
now: ctx.state.clock,
|
|
7554
|
+
fileHash: ctx.state.fileHash
|
|
7054
7555
|
});
|
|
7055
7556
|
const rebuilder = createRebuilder({
|
|
7056
7557
|
debounceMs: ctx.config.debounceMs,
|
|
7057
7558
|
/**
|
|
7058
|
-
* Re-run the SSG build for a rebuild
|
|
7559
|
+
* Re-run the SSG build for a rebuild: skip the clean so the prior assets + on-disk
|
|
7560
|
+
* caches survive (and no in-flight request hits an empty outDir), with the dev
|
|
7561
|
+
* overrides applied.
|
|
7059
7562
|
*
|
|
7563
|
+
* @param changed - The paths changed since the last build (incremental rebuild hint).
|
|
7060
7564
|
* @returns The rebuild summary.
|
|
7061
7565
|
* @example
|
|
7062
|
-
* await runBuild();
|
|
7566
|
+
* await runBuild(["content/intro/en.md"]);
|
|
7063
7567
|
*/
|
|
7064
|
-
runBuild() {
|
|
7065
|
-
return ctx.require(buildPlugin).run(
|
|
7568
|
+
runBuild(changed) {
|
|
7569
|
+
return ctx.require(buildPlugin).run({
|
|
7570
|
+
skipClean: true,
|
|
7571
|
+
overrides,
|
|
7572
|
+
changed
|
|
7573
|
+
});
|
|
7066
7574
|
},
|
|
7067
7575
|
/**
|
|
7068
7576
|
* Show the compact in-place "rebuilding {label}" line before the build runs.
|
|
@@ -7079,15 +7587,18 @@ async function runDevServer(ctx, port) {
|
|
|
7079
7587
|
* Render the reload line and push a browser reload after a rebuild.
|
|
7080
7588
|
*
|
|
7081
7589
|
* @param info - The changed file plus the rebuild's page count and duration.
|
|
7590
|
+
* @param changed - The paths this successful build consumed (baselined for no-op drops).
|
|
7082
7591
|
* @example
|
|
7083
|
-
* onReloaded({ file: "a.md", pageCount: 1, durationMs: 10 });
|
|
7592
|
+
* onReloaded({ file: "a.md", pageCount: 1, durationMs: 10 }, ["content/a.md"]);
|
|
7084
7593
|
*/
|
|
7085
|
-
onReloaded(info) {
|
|
7594
|
+
onReloaded(info, changed) {
|
|
7595
|
+
gate.commitBuilt(changed);
|
|
7086
7596
|
ctx.state.render.reload(info);
|
|
7087
7597
|
hub.reloadAll();
|
|
7088
7598
|
},
|
|
7089
7599
|
/**
|
|
7090
|
-
* Render a rebuild failure (the dev loop keeps running).
|
|
7600
|
+
* Render a rebuild failure (the dev loop keeps running). A failed build baselines
|
|
7601
|
+
* nothing (commitBuilt only runs on success), so an identical retry save still rebuilds.
|
|
7091
7602
|
*
|
|
7092
7603
|
* @param error - The thrown rebuild error.
|
|
7093
7604
|
* @example
|
|
@@ -7098,7 +7609,8 @@ async function runDevServer(ctx, port) {
|
|
|
7098
7609
|
}
|
|
7099
7610
|
});
|
|
7100
7611
|
const watchers = ctx.config.watchDirs.map((dir) => ctx.state.watch(dir, (filename) => {
|
|
7101
|
-
if (gate.accept(dir, filename))
|
|
7612
|
+
if (!gate.accept(dir, filename)) return;
|
|
7613
|
+
rebuilder.schedule(filename === void 0 ? dir : path.join(dir, filename));
|
|
7102
7614
|
}));
|
|
7103
7615
|
ctx.state.render.serverReady({
|
|
7104
7616
|
local: `http://localhost:${port}`,
|
|
@@ -7110,6 +7622,7 @@ async function runDevServer(ctx, port) {
|
|
|
7110
7622
|
for (const watcher of watchers) watcher.close();
|
|
7111
7623
|
hub.close();
|
|
7112
7624
|
server.stop();
|
|
7625
|
+
ctx.state.render.dispose();
|
|
7113
7626
|
});
|
|
7114
7627
|
}
|
|
7115
7628
|
//#endregion
|
|
@@ -7310,6 +7823,28 @@ async function confirmDeploy(ctx, yes) {
|
|
|
7310
7823
|
if (!confirmed) ctx.state.render.warn("deploy skipped");
|
|
7311
7824
|
return confirmed;
|
|
7312
7825
|
}
|
|
7826
|
+
/** Matches the prerequisite/credential failures a direct deploy most often hits (missing token/account). */
|
|
7827
|
+
const PREREQUISITE_ERROR = /required variable|not defined|cloudflare|token|account|unauthor|wrangler/i;
|
|
7828
|
+
/**
|
|
7829
|
+
* A short, actionable "how to fix" hint for a failed deploy, rendered under the error so
|
|
7830
|
+
* the user is never left at a raw stack trace. A missing-credential/prerequisite failure
|
|
7831
|
+
* (the common case for a first direct deploy) gets the concrete secret-setup steps;
|
|
7832
|
+
* anything else points at the guided deploy, which diagnoses prerequisites step by step.
|
|
7833
|
+
*
|
|
7834
|
+
* @param error - The thrown deploy error.
|
|
7835
|
+
* @returns The multi-line hint (newline-separated; rendered indented under a `›`).
|
|
7836
|
+
* @example
|
|
7837
|
+
* render.info(deployFailureHint(err));
|
|
7838
|
+
*/
|
|
7839
|
+
function deployFailureHint(error) {
|
|
7840
|
+
if (PREREQUISITE_ERROR.test(String(error))) return [
|
|
7841
|
+
"how to fix:",
|
|
7842
|
+
"1. run `bun run deploy` (without `--cli`) — the guided setup diagnoses prerequisites and offers to create a starter .env",
|
|
7843
|
+
"2. or set them yourself in .env: CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID",
|
|
7844
|
+
" token: https://dash.cloudflare.com/profile/api-tokens (template: Cloudflare Pages — Edit)"
|
|
7845
|
+
].join("\n");
|
|
7846
|
+
return "how to fix: run `bun run deploy` (without `--cli`) — the guided setup diagnoses prerequisites — then retry";
|
|
7847
|
+
}
|
|
7313
7848
|
/**
|
|
7314
7849
|
* Create the cli plugin API surface — exactly `build`, `serve`, `preview`, `deploy`.
|
|
7315
7850
|
* Each method renders `state.render.header(<command>)` first, then does its work;
|
|
@@ -7342,17 +7877,25 @@ function createApi$1(ctx) {
|
|
|
7342
7877
|
},
|
|
7343
7878
|
/**
|
|
7344
7879
|
* Dev loop: build once, serve `dist/` in-process (live-reload injected), watch
|
|
7345
|
-
* `watchDirs`, debounced rebuild + reload.
|
|
7880
|
+
* `watchDirs`, debounced + incremental rebuild + reload. For a fast rebuild the dev
|
|
7881
|
+
* build disables minification + expensive, preview-irrelevant outputs (feeds /
|
|
7882
|
+
* sitemap / og-images / locale-redirects); pass `og`/`sitemap`/`feeds`/
|
|
7883
|
+
* `localeRedirects` to re-enable any of them for the session. Resolves on SIGINT/SIGTERM.
|
|
7346
7884
|
*
|
|
7347
|
-
* @param options - Optional port override
|
|
7885
|
+
* @param options - Optional port override + per-session dev feature opt-ins.
|
|
7348
7886
|
* @returns Resolves once the server has been torn down.
|
|
7349
7887
|
* @example
|
|
7350
|
-
* await api.serve({ port: 3000 });
|
|
7888
|
+
* await api.serve({ port: 3000, og: true });
|
|
7351
7889
|
*/
|
|
7352
7890
|
serve(options = {}) {
|
|
7353
7891
|
const { port = ctx.config.port } = options;
|
|
7354
7892
|
ctx.state.render.header("serve");
|
|
7355
|
-
return runDevServer(ctx, port
|
|
7893
|
+
return runDevServer(ctx, port, {
|
|
7894
|
+
og: options.og ?? false,
|
|
7895
|
+
sitemap: options.sitemap ?? false,
|
|
7896
|
+
feeds: options.feeds ?? false,
|
|
7897
|
+
localeRedirects: options.localeRedirects ?? false
|
|
7898
|
+
});
|
|
7356
7899
|
},
|
|
7357
7900
|
/**
|
|
7358
7901
|
* Static preview of the built `dist/` with CF-Pages clean-URL resolution.
|
|
@@ -7384,15 +7927,21 @@ function createApi$1(ctx) {
|
|
|
7384
7927
|
const { branch, yes = false } = options;
|
|
7385
7928
|
ctx.state.render.header("deploy");
|
|
7386
7929
|
if (options.guided === true) return runDeployWizard(ctx, options);
|
|
7387
|
-
|
|
7388
|
-
|
|
7389
|
-
|
|
7390
|
-
|
|
7391
|
-
|
|
7392
|
-
|
|
7393
|
-
|
|
7394
|
-
|
|
7395
|
-
|
|
7930
|
+
try {
|
|
7931
|
+
await ctx.require(deployPlugin).init({ ci: true });
|
|
7932
|
+
if (!await confirmDeploy(ctx, yes)) return {
|
|
7933
|
+
deployed: false,
|
|
7934
|
+
reason: "declined"
|
|
7935
|
+
};
|
|
7936
|
+
return {
|
|
7937
|
+
deployed: true,
|
|
7938
|
+
...await ctx.require(deployPlugin).run(branch === void 0 ? {} : { branch })
|
|
7939
|
+
};
|
|
7940
|
+
} catch (error) {
|
|
7941
|
+
ctx.state.render.error("deploy failed", error);
|
|
7942
|
+
ctx.state.render.info(deployFailureHint(error));
|
|
7943
|
+
throw error;
|
|
7944
|
+
}
|
|
7396
7945
|
}
|
|
7397
7946
|
};
|
|
7398
7947
|
}
|
|
@@ -7483,6 +8032,28 @@ const ANSI = {
|
|
|
7483
8032
|
cyan: `${ESC}[36m`,
|
|
7484
8033
|
gray: `${ESC}[90m`
|
|
7485
8034
|
};
|
|
8035
|
+
/**
|
|
8036
|
+
* The Moku brand pink (`#FF1E6F`) as an RGB triple, used for 24-bit truecolor output.
|
|
8037
|
+
* Degrades to {@link ANSI.magenta} on a 16-color TTY and to plain text off a TTY.
|
|
8038
|
+
*/
|
|
8039
|
+
const BRAND_PINK = {
|
|
8040
|
+
r: 255,
|
|
8041
|
+
g: 30,
|
|
8042
|
+
b: 111
|
|
8043
|
+
};
|
|
8044
|
+
/**
|
|
8045
|
+
* Build a 24-bit (truecolor) SGR foreground escape for the given RGB triple.
|
|
8046
|
+
*
|
|
8047
|
+
* @param r - Red channel (0–255).
|
|
8048
|
+
* @param g - Green channel (0–255).
|
|
8049
|
+
* @param b - Blue channel (0–255).
|
|
8050
|
+
* @returns The `ESC[38;2;r;g;bm` foreground sequence.
|
|
8051
|
+
* @example
|
|
8052
|
+
* fg24(255, 30, 111); // "\x1b[38;2;255;30;111m"
|
|
8053
|
+
*/
|
|
8054
|
+
function fg24(r, g, b) {
|
|
8055
|
+
return `${ESC}[38;2;${r};${g};${b}m`;
|
|
8056
|
+
}
|
|
7486
8057
|
/** ANSI: erase the entire current line, leaving the cursor where it is. */
|
|
7487
8058
|
const CLEAR_LINE = `${ESC}[2K`;
|
|
7488
8059
|
/** ANSI: erase from the cursor to the end of the screen (drops stale trailing rows). */
|
|
@@ -7554,6 +8125,36 @@ function supportsColor(stream = process.stdout, noColor = process.env.NO_COLOR)
|
|
|
7554
8125
|
return stream.isTTY === true && noColor === void 0;
|
|
7555
8126
|
}
|
|
7556
8127
|
/**
|
|
8128
|
+
* Whether the terminal advertises 24-bit (truecolor) support via `COLORTERM`, so the
|
|
8129
|
+
* renderer may emit the exact brand pink ({@link BRAND_PINK}) instead of the 16-color
|
|
8130
|
+
* `magenta` approximation. Always layered on top of {@link supportsColor} — truecolor
|
|
8131
|
+
* is never used when color itself is disabled.
|
|
8132
|
+
*
|
|
8133
|
+
* @param colorTerm - The `COLORTERM` value (defaults to `process.env.COLORTERM`).
|
|
8134
|
+
* @returns `true` when `COLORTERM` is `truecolor` or `24bit`.
|
|
8135
|
+
* @example
|
|
8136
|
+
* supportsTruecolor("truecolor"); // true
|
|
8137
|
+
*/
|
|
8138
|
+
function supportsTruecolor(colorTerm = process.env.COLORTERM) {
|
|
8139
|
+
return colorTerm === "truecolor" || colorTerm === "24bit";
|
|
8140
|
+
}
|
|
8141
|
+
/**
|
|
8142
|
+
* The braille spinner glyph for a given elapsed time, advancing one frame per
|
|
8143
|
+
* `frameMs`. Deriving the frame from wall-clock elapsed (rather than a tick counter)
|
|
8144
|
+
* keeps the spinner correct even when the animation ticker is briefly starved by a
|
|
8145
|
+
* synchronous build phase and several ticks coalesce — the glyph still reflects real
|
|
8146
|
+
* elapsed time instead of freezing on a stale frame.
|
|
8147
|
+
*
|
|
8148
|
+
* @param elapsedMs - Milliseconds since the live region opened.
|
|
8149
|
+
* @param frameMs - Milliseconds per frame (defaults to `80`).
|
|
8150
|
+
* @returns The active spinner glyph.
|
|
8151
|
+
* @example
|
|
8152
|
+
* spinnerFrameAt(240); // "⠹" (the 4th frame at 80ms/frame)
|
|
8153
|
+
*/
|
|
8154
|
+
function spinnerFrameAt(elapsedMs, frameMs = 80) {
|
|
8155
|
+
return SPINNER_FRAMES[Math.floor(Math.max(0, elapsedMs) / frameMs) % SPINNER_FRAMES.length] ?? "⠋";
|
|
8156
|
+
}
|
|
8157
|
+
/**
|
|
7557
8158
|
* Select the box glyph set for the given color mode (Unicode on a TTY, ASCII off it).
|
|
7558
8159
|
*
|
|
7559
8160
|
* @param color - Whether color/Unicode output is enabled.
|
|
@@ -7581,12 +8182,14 @@ function visibleWidth(text) {
|
|
|
7581
8182
|
* output in CI/pipes.
|
|
7582
8183
|
*
|
|
7583
8184
|
* @param color - Whether color is enabled (typically `supportsColor()`).
|
|
8185
|
+
* @param truecolor - Whether 24-bit output is enabled (typically `supportsTruecolor()`);
|
|
8186
|
+
* only consulted by {@link Palette.pink}. Defaults to `false` (16-color magenta).
|
|
7584
8187
|
* @returns The bound color palette.
|
|
7585
8188
|
* @example
|
|
7586
|
-
* const palette = makePalette(supportsColor());
|
|
8189
|
+
* const palette = makePalette(supportsColor(), supportsTruecolor());
|
|
7587
8190
|
* const line = palette.green("done");
|
|
7588
8191
|
*/
|
|
7589
|
-
function makePalette(color) {
|
|
8192
|
+
function makePalette(color, truecolor = false) {
|
|
7590
8193
|
return {
|
|
7591
8194
|
enabled: color,
|
|
7592
8195
|
/**
|
|
@@ -7666,23 +8269,39 @@ function makePalette(color) {
|
|
|
7666
8269
|
*/
|
|
7667
8270
|
cyan(text) {
|
|
7668
8271
|
return this.paint(ANSI.cyan, text);
|
|
8272
|
+
},
|
|
8273
|
+
/**
|
|
8274
|
+
* Color the given text the Moku brand pink: exact `#FF1E6F` (24-bit) when truecolor
|
|
8275
|
+
* is enabled, the 16-color `magenta` approximation otherwise, unchanged in plain mode.
|
|
8276
|
+
*
|
|
8277
|
+
* @param text - The text to colorize.
|
|
8278
|
+
* @returns The pink (or unchanged) text.
|
|
8279
|
+
* @example
|
|
8280
|
+
* palette.pink("▟▙ moku web");
|
|
8281
|
+
*/
|
|
8282
|
+
pink(text) {
|
|
8283
|
+
if (!color) return text;
|
|
8284
|
+
if (truecolor) return `${fg24(BRAND_PINK.r, BRAND_PINK.g, BRAND_PINK.b)}${text}${ANSI.reset}`;
|
|
8285
|
+
return this.paint(ANSI.magenta, text);
|
|
7669
8286
|
}
|
|
7670
8287
|
};
|
|
7671
8288
|
}
|
|
7672
8289
|
/**
|
|
7673
8290
|
* Frame a list of already-rendered content lines in a box, padding each line to the
|
|
7674
|
-
*
|
|
7675
|
-
*
|
|
8291
|
+
* widest visible line (or `minInnerWidth`, whichever is larger — so several boxes can be
|
|
8292
|
+
* forced to a shared width). Uses Unicode borders when `color` is enabled and ASCII
|
|
8293
|
+
* otherwise. Visible width ignores embedded ANSI so colored lines align.
|
|
7676
8294
|
*
|
|
7677
8295
|
* @param lines - The content lines (may contain ANSI color codes).
|
|
7678
8296
|
* @param color - Whether to use Unicode borders (and assume color-capable output).
|
|
8297
|
+
* @param minInnerWidth - Minimum inner (content) width to pad every row to. Defaults to `0`.
|
|
7679
8298
|
* @returns The boxed lines (top border, content rows, bottom border).
|
|
7680
8299
|
* @example
|
|
7681
|
-
* box(["Local: http://localhost:4173"], true);
|
|
8300
|
+
* box(["Local: http://localhost:4173"], true, 62);
|
|
7682
8301
|
*/
|
|
7683
|
-
function box(lines, color) {
|
|
8302
|
+
function box(lines, color, minInnerWidth = 0) {
|
|
7684
8303
|
const glyphs = boxGlyphs(color);
|
|
7685
|
-
const inner = Math.max(0, ...lines.map((line) => visibleWidth(line)));
|
|
8304
|
+
const inner = Math.max(0, minInnerWidth, ...lines.map((line) => visibleWidth(line)));
|
|
7686
8305
|
const horizontal = glyphs.horizontal.repeat(inner + 2);
|
|
7687
8306
|
const top = `${glyphs.topLeft}${horizontal}${glyphs.topRight}`;
|
|
7688
8307
|
const bottom = `${glyphs.bottomLeft}${horizontal}${glyphs.bottomRight}`;
|
|
@@ -7697,13 +8316,70 @@ function box(lines, color) {
|
|
|
7697
8316
|
}
|
|
7698
8317
|
//#endregion
|
|
7699
8318
|
//#region src/plugins/cli/render/panel.ts
|
|
7700
|
-
/** Per-command label shown
|
|
8319
|
+
/** Per-command label shown beside the lockup wordmark. */
|
|
7701
8320
|
const COMMAND_LABEL = {
|
|
7702
8321
|
build: "build",
|
|
7703
8322
|
serve: "serve · dev",
|
|
7704
8323
|
preview: "preview",
|
|
7705
8324
|
deploy: "deploy"
|
|
7706
8325
|
};
|
|
8326
|
+
/** Total visible width the header rule spans and the per-row timing column right-aligns to. */
|
|
8327
|
+
const RAIL_WIDTH = 66;
|
|
8328
|
+
/** Animation repaint cadence (ms) — how often the live region is redrawn when the loop is free. */
|
|
8329
|
+
const TICK_MS = 40;
|
|
8330
|
+
/** Spinner frame interval (ms) — one braille glyph advance per this many elapsed ms. */
|
|
8331
|
+
const SPIN_MS = 60;
|
|
8332
|
+
/** Inner (content) width of the BUILD/server boxes so their right edge lines up with the phase tree. */
|
|
8333
|
+
const BOX_INNER = RAIL_WIDTH - 4;
|
|
8334
|
+
/** The eight block glyphs the per-phase time-profile sparkline maps durations onto. */
|
|
8335
|
+
const SPARK_BARS = "▁▂▃▄▅▆▇█";
|
|
8336
|
+
/**
|
|
8337
|
+
* Build a sparkline from a list of values — one block glyph per value, height scaled to
|
|
8338
|
+
* the largest value so the tallest bar is `█`. A real micro-histogram (no fake data):
|
|
8339
|
+
* under the BUILD summary each bar is one phase's duration, so the slowest phase stands
|
|
8340
|
+
* out at a glance. Returns `""` for an empty list.
|
|
8341
|
+
*
|
|
8342
|
+
* @param values - The values to plot (e.g. per-phase durations in ms).
|
|
8343
|
+
* @returns The sparkline string.
|
|
8344
|
+
* @example
|
|
8345
|
+
* sparkline([12, 1701, 19698, 9]); // "▁▁█▁"
|
|
8346
|
+
*/
|
|
8347
|
+
function sparkline(values) {
|
|
8348
|
+
if (values.length === 0) return "";
|
|
8349
|
+
const max = Math.max(...values, 1);
|
|
8350
|
+
return values.map((value) => {
|
|
8351
|
+
return SPARK_BARS[Math.min(7, Math.floor(value / max * 7))] ?? SPARK_BARS[0];
|
|
8352
|
+
}).join("");
|
|
8353
|
+
}
|
|
8354
|
+
/**
|
|
8355
|
+
* The structural glyph set for the active color mode: Unicode on a color-capable TTY,
|
|
8356
|
+
* ASCII fallbacks off it. Only the NEW Velocity chrome (cube, rule, tree, bar, live
|
|
8357
|
+
* dot) degrades here — the `✓ ✗ ~ ➜ ›` status marks stay as-is in both modes.
|
|
8358
|
+
*
|
|
8359
|
+
* @param color - Whether color/Unicode output is enabled.
|
|
8360
|
+
* @returns The matching glyph set.
|
|
8361
|
+
* @example
|
|
8362
|
+
* const g = glyphSet(true);
|
|
8363
|
+
*/
|
|
8364
|
+
function glyphSet(color) {
|
|
8365
|
+
return color ? {
|
|
8366
|
+
cube: "▟▙",
|
|
8367
|
+
rule: "─",
|
|
8368
|
+
tree: "├─",
|
|
8369
|
+
barFill: "━",
|
|
8370
|
+
barTrack: "╴",
|
|
8371
|
+
liveOn: "◍",
|
|
8372
|
+
liveOff: "○"
|
|
8373
|
+
} : {
|
|
8374
|
+
cube: "*",
|
|
8375
|
+
rule: "-",
|
|
8376
|
+
tree: "-",
|
|
8377
|
+
barFill: "#",
|
|
8378
|
+
barTrack: "-",
|
|
8379
|
+
liveOn: "*",
|
|
8380
|
+
liveOff: "*"
|
|
8381
|
+
};
|
|
8382
|
+
}
|
|
7707
8383
|
/**
|
|
7708
8384
|
* Render one human-readable duration suffix (e.g. `· 84ms`).
|
|
7709
8385
|
*
|
|
@@ -7718,15 +8394,49 @@ function durationSuffix(palette, durationMs) {
|
|
|
7718
8394
|
return ` ${palette.dim(`· ${durationMs}ms`)}`;
|
|
7719
8395
|
}
|
|
7720
8396
|
/**
|
|
8397
|
+
* Right-align `right` against `left` within {@link RAIL_WIDTH}, measuring visible width
|
|
8398
|
+
* so embedded ANSI never throws the timing column off.
|
|
8399
|
+
*
|
|
8400
|
+
* @param left - The left segment (may contain ANSI).
|
|
8401
|
+
* @param right - The right segment (may contain ANSI).
|
|
8402
|
+
* @param width - Total visible width to fill (defaults to {@link RAIL_WIDTH}).
|
|
8403
|
+
* @returns The padded line.
|
|
8404
|
+
* @example
|
|
8405
|
+
* railLine(" ├─ ✓ pages", "· 12ms");
|
|
8406
|
+
*/
|
|
8407
|
+
function railLine(left, right, width = RAIL_WIDTH) {
|
|
8408
|
+
const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right));
|
|
8409
|
+
return `${left}${" ".repeat(gap)}${right}`;
|
|
8410
|
+
}
|
|
8411
|
+
/**
|
|
8412
|
+
* The runtime facts line shown under the banner: the pinned core version (when known)
|
|
8413
|
+
* plus the live Node/Bun versions + platform — the ACTUAL running runtime, not the
|
|
8414
|
+
* `engines` floor. Every value is real (read from `@moku-labs/core`'s pinned dependency
|
|
8415
|
+
* and `process.versions`), so nothing on this line is faked.
|
|
8416
|
+
*
|
|
8417
|
+
* @param coreVersion - The pinned `@moku-labs/core` version (appended last — it rarely
|
|
8418
|
+
* matters — and omitted entirely when unknown).
|
|
8419
|
+
* @returns The facts string (e.g. `node 24.3.0 · bun 1.3.9 · darwin arm64 · core 0.1.0-alpha.6`).
|
|
8420
|
+
* @example
|
|
8421
|
+
* runtimeFacts("0.1.0-alpha.6");
|
|
8422
|
+
*/
|
|
8423
|
+
function runtimeFacts(coreVersion) {
|
|
8424
|
+
const node = `node ${process.versions.node}`;
|
|
8425
|
+
const bun = process.versions.bun ? ` · bun ${process.versions.bun}` : "";
|
|
8426
|
+
const core = coreVersion ? ` · core ${coreVersion}` : "";
|
|
8427
|
+
return `${node}${bun} · ${process.platform} ${process.arch}${core}`;
|
|
8428
|
+
}
|
|
8429
|
+
/**
|
|
7721
8430
|
* Create the Panel {@link CliRenderer}. Output is written through the injected sink
|
|
7722
|
-
* (default `console.log`/`console.error`) and colorized only when color is enabled,
|
|
7723
|
-
*
|
|
7724
|
-
* ASCII lines in CI/pipes.
|
|
8431
|
+
* (default `console.log`/`console.error`) and colorized only when color is enabled, so
|
|
8432
|
+
* the identical render path yields the animated, box-free Velocity UI on a TTY and
|
|
8433
|
+
* plain ASCII lines in CI/pipes.
|
|
7725
8434
|
*
|
|
7726
|
-
* @param options - Optional sinks
|
|
8435
|
+
* @param options - Optional sinks, color/truecolor overrides, clock, and version (see
|
|
8436
|
+
* {@link PanelOptions}).
|
|
7727
8437
|
* @returns The renderer mounted on `state.render` and driven by the API + hooks.
|
|
7728
8438
|
* @example
|
|
7729
|
-
* const render = createPanelRenderer();
|
|
8439
|
+
* const render = createPanelRenderer({ version: "0.1.0-alpha" });
|
|
7730
8440
|
* render.header("build");
|
|
7731
8441
|
*/
|
|
7732
8442
|
function createPanelRenderer(options = {}) {
|
|
@@ -7737,26 +8447,24 @@ function createPanelRenderer(options = {}) {
|
|
|
7737
8447
|
});
|
|
7738
8448
|
const now = options.now ?? Date.now;
|
|
7739
8449
|
const color = options.color ?? supportsColor();
|
|
7740
|
-
const palette = makePalette(color);
|
|
8450
|
+
const palette = makePalette(color, options.truecolor ?? (color && supportsTruecolor()));
|
|
8451
|
+
const version = options.version ?? "dev";
|
|
8452
|
+
const coreVersion = options.coreVersion;
|
|
8453
|
+
const g = glyphSet(color);
|
|
7741
8454
|
let phaseRows = [];
|
|
7742
8455
|
let phaseDrawn = 0;
|
|
7743
8456
|
let phaseOpen = false;
|
|
8457
|
+
let blockStartedAt = 0;
|
|
7744
8458
|
let rebuilding = false;
|
|
7745
8459
|
let rebuildLabel = "";
|
|
7746
8460
|
let rebuildStartedAt = 0;
|
|
7747
|
-
let
|
|
8461
|
+
let idle = false;
|
|
8462
|
+
let idleStartedAt = 0;
|
|
8463
|
+
let serveMode = false;
|
|
7748
8464
|
let ticker;
|
|
7749
8465
|
/**
|
|
7750
|
-
*
|
|
7751
|
-
*
|
|
7752
|
-
* @returns The active braille spinner frame.
|
|
7753
|
-
* @example
|
|
7754
|
-
* frameGlyph(); // "⠙"
|
|
7755
|
-
*/
|
|
7756
|
-
const frameGlyph = () => SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length] ?? "⠋";
|
|
7757
|
-
/**
|
|
7758
|
-
* Render one phase row: a green `✓ name · time` when done, else a spinning cyan glyph
|
|
7759
|
-
* before the dim name.
|
|
8466
|
+
* Render one phase-tree row: a spinning cyan glyph + dim name while running, or a green
|
|
8467
|
+
* `✓` + name with the duration right-aligned in the dim timing column once done.
|
|
7760
8468
|
*
|
|
7761
8469
|
* @param row - The phase row to render.
|
|
7762
8470
|
* @returns The rendered row line (no trailing newline).
|
|
@@ -7764,12 +8472,34 @@ function createPanelRenderer(options = {}) {
|
|
|
7764
8472
|
* renderPhaseRow({ name: "pages", done: true, durationMs: 12 });
|
|
7765
8473
|
*/
|
|
7766
8474
|
const renderPhaseRow = (row) => {
|
|
7767
|
-
|
|
7768
|
-
return ` ${palette.
|
|
8475
|
+
const branch = palette.dim(g.tree);
|
|
8476
|
+
if (row.done) return railLine(` ${branch} ${palette.green("✓")} ${row.name}`, palette.dim(`· ${row.durationMs}ms`));
|
|
8477
|
+
return ` ${branch} ${palette.cyan(spinnerFrameAt(now() - blockStartedAt, SPIN_MS))} ${palette.dim(row.name)}`;
|
|
7769
8478
|
};
|
|
7770
8479
|
/**
|
|
7771
|
-
*
|
|
7772
|
-
*
|
|
8480
|
+
* Render the indeterminate "comet" build bar — a short pink fill window sweeping across
|
|
8481
|
+
* a dim track — for the given elapsed time. Animated purely from wall-clock elapsed so
|
|
8482
|
+
* it never needs a known phase total.
|
|
8483
|
+
*
|
|
8484
|
+
* @param elapsedMs - Milliseconds since the phase block opened.
|
|
8485
|
+
* @returns The rendered bar row (no trailing newline).
|
|
8486
|
+
* @example
|
|
8487
|
+
* renderBuildBar(300);
|
|
8488
|
+
*/
|
|
8489
|
+
const renderBuildBar = (elapsedMs) => {
|
|
8490
|
+
const length = 28;
|
|
8491
|
+
const window = 6;
|
|
8492
|
+
const head = Math.floor(elapsedMs / 28) % 34;
|
|
8493
|
+
let bar = "";
|
|
8494
|
+
for (let index = 0; index < length; index++) {
|
|
8495
|
+
const lit = index <= head && index > head - window;
|
|
8496
|
+
bar += lit ? palette.pink(g.barFill) : palette.dim(g.barTrack);
|
|
8497
|
+
}
|
|
8498
|
+
return ` ${bar}`;
|
|
8499
|
+
};
|
|
8500
|
+
/**
|
|
8501
|
+
* Repaint the live phase block in place (tree rows + animated build bar): move up over
|
|
8502
|
+
* the prior draw, rewrite each row, then the bar, clearing any stale trailing lines.
|
|
7773
8503
|
*
|
|
7774
8504
|
* @example
|
|
7775
8505
|
* paintPhaseBlock();
|
|
@@ -7777,8 +8507,9 @@ function createPanelRenderer(options = {}) {
|
|
|
7777
8507
|
const paintPhaseBlock = () => {
|
|
7778
8508
|
let frame = cursorUp(phaseDrawn);
|
|
7779
8509
|
for (const row of phaseRows) frame += `${CLEAR_LINE}${renderPhaseRow(row)}\n`;
|
|
8510
|
+
frame += `${CLEAR_LINE}${renderBuildBar(now() - blockStartedAt)}\n`;
|
|
7780
8511
|
writeRaw(frame + CLEAR_BELOW);
|
|
7781
|
-
phaseDrawn = phaseRows.length;
|
|
8512
|
+
phaseDrawn = phaseRows.length + 1;
|
|
7782
8513
|
};
|
|
7783
8514
|
/**
|
|
7784
8515
|
* Repaint the single in-place rebuild line (spinner + label + live elapsed seconds).
|
|
@@ -7787,20 +8518,31 @@ function createPanelRenderer(options = {}) {
|
|
|
7787
8518
|
* paintRebuildLine();
|
|
7788
8519
|
*/
|
|
7789
8520
|
const paintRebuildLine = () => {
|
|
7790
|
-
const
|
|
7791
|
-
const
|
|
7792
|
-
writeRaw(`\r${CLEAR_LINE} ${
|
|
8521
|
+
const spinner = palette.cyan(spinnerFrameAt(now() - rebuildStartedAt, SPIN_MS));
|
|
8522
|
+
const elapsed = palette.dim(`· ${((now() - rebuildStartedAt) / 1e3).toFixed(1)}s`);
|
|
8523
|
+
writeRaw(`\r${CLEAR_LINE} ${spinner} rebuilding ${rebuildLabel} ${elapsed}`);
|
|
7793
8524
|
};
|
|
7794
8525
|
/**
|
|
7795
|
-
*
|
|
8526
|
+
* Repaint the persistent in-place `◍ live` idle pulse beneath the serve panel — the
|
|
8527
|
+
* dot breathes (pink → dim) on a calm ~0.6s cycle so a quiet dev session always reads
|
|
8528
|
+
* as alive without strobing.
|
|
8529
|
+
*
|
|
8530
|
+
* @example
|
|
8531
|
+
* paintIdleLine();
|
|
8532
|
+
*/
|
|
8533
|
+
const paintIdleLine = () => {
|
|
8534
|
+
writeRaw(`\r${CLEAR_LINE} ${Math.floor((now() - idleStartedAt) / 450) % 2 === 0 ? palette.pink(g.liveOn) : palette.dim(g.liveOff)} ${palette.dim("live · waiting for changes…")}`);
|
|
8535
|
+
};
|
|
8536
|
+
/**
|
|
8537
|
+
* Advance whichever live region is active by one frame (driven by the shared ticker).
|
|
7796
8538
|
*
|
|
7797
8539
|
* @example
|
|
7798
8540
|
* onTick();
|
|
7799
8541
|
*/
|
|
7800
8542
|
const onTick = () => {
|
|
7801
|
-
spinnerFrame += 1;
|
|
7802
8543
|
if (rebuilding) paintRebuildLine();
|
|
7803
8544
|
else if (phaseOpen) paintPhaseBlock();
|
|
8545
|
+
else if (idle) paintIdleLine();
|
|
7804
8546
|
};
|
|
7805
8547
|
/**
|
|
7806
8548
|
* Start the animation ticker (TTY only; idempotent; `unref`'d so it never blocks exit).
|
|
@@ -7810,7 +8552,7 @@ function createPanelRenderer(options = {}) {
|
|
|
7810
8552
|
*/
|
|
7811
8553
|
const startTicker = () => {
|
|
7812
8554
|
if (!color || ticker) return;
|
|
7813
|
-
ticker = setInterval(onTick,
|
|
8555
|
+
ticker = setInterval(onTick, TICK_MS);
|
|
7814
8556
|
ticker.unref?.();
|
|
7815
8557
|
};
|
|
7816
8558
|
/**
|
|
@@ -7833,22 +8575,47 @@ function createPanelRenderer(options = {}) {
|
|
|
7833
8575
|
const writeBlock = (lines) => {
|
|
7834
8576
|
for (const line of lines) write(line);
|
|
7835
8577
|
};
|
|
8578
|
+
/**
|
|
8579
|
+
* Resume the serve idle pulse on a fresh bottom line (TTY serve sessions only). A no-op
|
|
8580
|
+
* outside serve so standalone rebuild/error calls in unit tests never leave a ticker
|
|
8581
|
+
* running.
|
|
8582
|
+
*
|
|
8583
|
+
* @example
|
|
8584
|
+
* resumeIdle();
|
|
8585
|
+
*/
|
|
8586
|
+
const resumeIdle = () => {
|
|
8587
|
+
if (!(color && serveMode)) {
|
|
8588
|
+
stopTicker();
|
|
8589
|
+
return;
|
|
8590
|
+
}
|
|
8591
|
+
idle = true;
|
|
8592
|
+
idleStartedAt = now();
|
|
8593
|
+
paintIdleLine();
|
|
8594
|
+
startTicker();
|
|
8595
|
+
};
|
|
7836
8596
|
return {
|
|
7837
8597
|
/**
|
|
7838
|
-
* Render the
|
|
8598
|
+
* Render the `▟▙ moku web` lockup + per-command label, a dim rule, and the runtime
|
|
8599
|
+
* facts line (live Node/Bun versions + platform). Called once per command (one
|
|
8600
|
+
* command = one process), so it never repeats within a run.
|
|
7839
8601
|
*
|
|
7840
|
-
* @param command - The command being run, shown beside the
|
|
8602
|
+
* @param command - The command being run, shown beside the wordmark.
|
|
7841
8603
|
* @example
|
|
7842
8604
|
* render.header("serve");
|
|
7843
8605
|
*/
|
|
7844
8606
|
header(command) {
|
|
7845
|
-
writeBlock(
|
|
8607
|
+
writeBlock([
|
|
8608
|
+
railLine(` ${palette.pink(g.cube)} ${palette.pink(palette.bold("moku web"))} ${palette.dim(COMMAND_LABEL[command])}`, palette.dim(version)),
|
|
8609
|
+
` ${palette.dim(g.rule.repeat(RAIL_WIDTH - 1))}`,
|
|
8610
|
+
` ${palette.dim(runtimeFacts(coreVersion))}`
|
|
8611
|
+
]);
|
|
7846
8612
|
},
|
|
7847
8613
|
/**
|
|
7848
|
-
* Render a per-phase row from a `build:phase` event. On a TTY each phase is ONE
|
|
7849
|
-
* that updates in place (spinning glyph while running → green ✓ + duration
|
|
7850
|
-
*
|
|
7851
|
-
* no-op while a serve()
|
|
8614
|
+
* Render a live per-phase row from a `build:phase` event. On a TTY each phase is ONE
|
|
8615
|
+
* tree row that updates in place (spinning glyph while running → green ✓ + duration
|
|
8616
|
+
* when done) beneath an animated indeterminate build bar; off a TTY one line is
|
|
8617
|
+
* printed per completed phase (no start/done duplication). A no-op while a serve()
|
|
8618
|
+
* rebuild is in flight — those show the compact rebuild line.
|
|
7852
8619
|
*
|
|
7853
8620
|
* @param phase - The `build:phase` payload.
|
|
7854
8621
|
* @example
|
|
@@ -7864,6 +8631,7 @@ function createPanelRenderer(options = {}) {
|
|
|
7864
8631
|
phaseRows = [];
|
|
7865
8632
|
phaseDrawn = 0;
|
|
7866
8633
|
phaseOpen = true;
|
|
8634
|
+
blockStartedAt = now();
|
|
7867
8635
|
}
|
|
7868
8636
|
const done = phase.status === "done";
|
|
7869
8637
|
const existing = phaseRows.find((row) => row.name === phase.phase);
|
|
@@ -7879,7 +8647,9 @@ function createPanelRenderer(options = {}) {
|
|
|
7879
8647
|
startTicker();
|
|
7880
8648
|
},
|
|
7881
8649
|
/**
|
|
7882
|
-
* Render the BUILD summary
|
|
8650
|
+
* Render the BUILD summary line + a one-shot throughput sparkline from a
|
|
8651
|
+
* `build:complete` event, finalizing the live phase tree (dropping its animated bar)
|
|
8652
|
+
* first.
|
|
7883
8653
|
*
|
|
7884
8654
|
* @param summary - The `build:complete` payload.
|
|
7885
8655
|
* @example
|
|
@@ -7887,33 +8657,52 @@ function createPanelRenderer(options = {}) {
|
|
|
7887
8657
|
*/
|
|
7888
8658
|
built(summary) {
|
|
7889
8659
|
if (rebuilding) return;
|
|
8660
|
+
if (color && phaseOpen) {
|
|
8661
|
+
let frame = cursorUp(phaseDrawn);
|
|
8662
|
+
for (const row of phaseRows) frame += `${CLEAR_LINE}${renderPhaseRow(row)}\n`;
|
|
8663
|
+
writeRaw(frame + CLEAR_BELOW);
|
|
8664
|
+
}
|
|
8665
|
+
const phaseDurations = phaseRows.map((row) => row.durationMs).filter((value) => value !== void 0);
|
|
7890
8666
|
phaseOpen = false;
|
|
7891
8667
|
phaseDrawn = 0;
|
|
7892
8668
|
stopTicker();
|
|
7893
8669
|
const pages = palette.bold(String(summary.pageCount));
|
|
7894
|
-
|
|
7895
|
-
|
|
7896
|
-
|
|
7897
|
-
|
|
7898
|
-
|
|
7899
|
-
|
|
8670
|
+
const dot = palette.dim("·");
|
|
8671
|
+
const lines = [railLine(`${palette.green("✓")} ${palette.bold("BUILD")} ${dot} ${pages} pages`, `${summary.durationMs}ms ${dot} ${summary.outDir}/`, BOX_INNER)];
|
|
8672
|
+
if (color && summary.durationMs > 0) {
|
|
8673
|
+
const rate = Math.max(1, Math.round(summary.pageCount / (summary.durationMs / 1e3)));
|
|
8674
|
+
const spark = phaseDurations.length > 0 ? palette.pink(sparkline(phaseDurations)) : "";
|
|
8675
|
+
const rateLabel = palette.dim(`${rate} pages/s`);
|
|
8676
|
+
lines.push(railLine(spark, rateLabel, BOX_INNER));
|
|
8677
|
+
}
|
|
8678
|
+
writeBlock(box(lines, color, BOX_INNER));
|
|
7900
8679
|
},
|
|
7901
8680
|
/**
|
|
7902
|
-
* Render the
|
|
8681
|
+
* Render the server-ready rail (Local / Network URLs + watched dirs) and, on a TTY,
|
|
8682
|
+
* begin the persistent breathing `◍ live` idle pulse beneath it.
|
|
7903
8683
|
*
|
|
7904
8684
|
* @param info - Local/Network URLs and optionally the watched directories.
|
|
7905
8685
|
* @example
|
|
7906
8686
|
* render.serverReady({ local: "http://localhost:4173", network: null });
|
|
7907
8687
|
*/
|
|
7908
8688
|
serverReady(info) {
|
|
7909
|
-
const
|
|
7910
|
-
|
|
7911
|
-
|
|
8689
|
+
const network = info.network ? palette.cyan(info.network) : palette.dim("unavailable");
|
|
8690
|
+
const lines = [`${palette.green("➜")} ${palette.bold("Local")} ${palette.cyan(info.local)}`, `${palette.green("➜")} ${palette.bold("Network")} ${network}`];
|
|
8691
|
+
if (info.watching && info.watching.length > 0) lines.push(`${palette.dim("watching")} ${palette.dim(info.watching.join(", "))}`);
|
|
8692
|
+
writeBlock(box(lines, color, BOX_INNER));
|
|
8693
|
+
if (color) {
|
|
8694
|
+
serveMode = true;
|
|
8695
|
+
idle = true;
|
|
8696
|
+
idleStartedAt = now();
|
|
8697
|
+
paintIdleLine();
|
|
8698
|
+
startTicker();
|
|
8699
|
+
}
|
|
7912
8700
|
},
|
|
7913
8701
|
/**
|
|
7914
8702
|
* Begin a serve() rebuild: show ONE compact "rebuilding {label}" line (an animated
|
|
7915
|
-
* spinner with live elapsed on a TTY; a plain "~ {label}" line otherwise)
|
|
7916
|
-
* the verbose phase
|
|
8703
|
+
* spinner with live elapsed on a TTY; a plain "~ {label}" line otherwise), taking over
|
|
8704
|
+
* the idle-pulse line, and mute the verbose phase tree + BUILD summary until
|
|
8705
|
+
* {@link reload}/{@link error} settles it.
|
|
7917
8706
|
*
|
|
7918
8707
|
* @param label - The changed watch target shown in the line.
|
|
7919
8708
|
* @example
|
|
@@ -7921,9 +8710,9 @@ function createPanelRenderer(options = {}) {
|
|
|
7921
8710
|
*/
|
|
7922
8711
|
rebuildStart(label) {
|
|
7923
8712
|
rebuilding = true;
|
|
8713
|
+
idle = false;
|
|
7924
8714
|
rebuildLabel = label;
|
|
7925
8715
|
rebuildStartedAt = now();
|
|
7926
|
-
spinnerFrame = 0;
|
|
7927
8716
|
if (!color) {
|
|
7928
8717
|
write(` ${palette.yellow("~")} ${label}`);
|
|
7929
8718
|
return;
|
|
@@ -7933,9 +8722,9 @@ function createPanelRenderer(options = {}) {
|
|
|
7933
8722
|
},
|
|
7934
8723
|
/**
|
|
7935
8724
|
* Settle the current rebuild: replace the in-place "rebuilding…" line with a compact
|
|
7936
|
-
* "✓ rebuilt N pages · Xms · reloaded" (
|
|
7937
|
-
* Called standalone (no preceding {@link rebuildStart}) it also
|
|
7938
|
-
* line so the changed target stays visible.
|
|
8725
|
+
* "✓ rebuilt N pages · Xms · reloaded", then (in a serve session) resume the idle pulse
|
|
8726
|
+
* on a fresh bottom line. Called standalone (no preceding {@link rebuildStart}) it also
|
|
8727
|
+
* prints the "~ file" line so the changed target stays visible.
|
|
7939
8728
|
*
|
|
7940
8729
|
* @param info - The changed file plus the rebuild's page count and duration.
|
|
7941
8730
|
* @example
|
|
@@ -7944,30 +8733,26 @@ function createPanelRenderer(options = {}) {
|
|
|
7944
8733
|
reload(info) {
|
|
7945
8734
|
const settledRebuild = rebuilding;
|
|
7946
8735
|
rebuilding = false;
|
|
7947
|
-
stopTicker();
|
|
7948
8736
|
const line = ` ${palette.green("✓")} rebuilt ${palette.bold(String(info.pageCount))} pages ${palette.dim(`· ${info.durationMs}ms · reloaded`)}`;
|
|
7949
8737
|
if (settledRebuild && color) {
|
|
7950
8738
|
writeRaw(`\r${CLEAR_LINE}${line}\n`);
|
|
8739
|
+
resumeIdle();
|
|
7951
8740
|
return;
|
|
7952
8741
|
}
|
|
7953
8742
|
if (!settledRebuild) write(` ${palette.yellow("~")} ${info.file}`);
|
|
7954
8743
|
write(line);
|
|
7955
8744
|
},
|
|
7956
8745
|
/**
|
|
7957
|
-
* Render the deploy result
|
|
8746
|
+
* Render the deploy result from a `deploy:complete` event: a `✓ DEPLOYED → url` line
|
|
8747
|
+
* with the URL the hero value, then a dim `branch · id · time` line beneath it.
|
|
7958
8748
|
*
|
|
7959
8749
|
* @param result - The `deploy:complete` payload.
|
|
7960
8750
|
* @example
|
|
7961
8751
|
* render.deployed({ url: "https://x.pages.dev", deploymentId: "id", branch: "main", durationMs: 1200 });
|
|
7962
8752
|
*/
|
|
7963
8753
|
deployed(result) {
|
|
7964
|
-
|
|
7965
|
-
|
|
7966
|
-
`${palette.dim("url")} ${palette.cyan(result.url)}`,
|
|
7967
|
-
`${palette.dim("branch")} ${result.branch}`,
|
|
7968
|
-
`${palette.dim("id")} ${result.deploymentId}`,
|
|
7969
|
-
`${palette.dim("time")} ${result.durationMs}ms`
|
|
7970
|
-
], color));
|
|
8754
|
+
const meta = palette.dim(`branch ${result.branch} · ${result.deploymentId} · ${result.durationMs}ms`);
|
|
8755
|
+
writeBlock([` ${palette.green("✓")} ${palette.bold("DEPLOYED")} ${palette.dim("→")} ${palette.cyan(result.url)}`, ` ${meta}`]);
|
|
7971
8756
|
},
|
|
7972
8757
|
/**
|
|
7973
8758
|
* Render a neutral informational line.
|
|
@@ -7992,7 +8777,8 @@ function createPanelRenderer(options = {}) {
|
|
|
7992
8777
|
writeError(` ${palette.yellow("⚠")} ${message}`);
|
|
7993
8778
|
},
|
|
7994
8779
|
/**
|
|
7995
|
-
* Render an error line (to stderr), optionally with a cause.
|
|
8780
|
+
* Render an error line (to stderr), optionally with a cause. A failing rebuild settles
|
|
8781
|
+
* its in-place spinner line first; in a serve session the idle pulse then resumes.
|
|
7996
8782
|
*
|
|
7997
8783
|
* @param message - The error summary to print.
|
|
7998
8784
|
* @param cause - Optional underlying error/value to print beneath the summary.
|
|
@@ -8000,16 +8786,18 @@ function createPanelRenderer(options = {}) {
|
|
|
8000
8786
|
* render.error("build failed", err);
|
|
8001
8787
|
*/
|
|
8002
8788
|
error(message, cause) {
|
|
8789
|
+
const wasRebuilding = rebuilding;
|
|
8003
8790
|
if (rebuilding) {
|
|
8004
8791
|
rebuilding = false;
|
|
8005
|
-
stopTicker();
|
|
8006
8792
|
if (color) writeRaw(`\r${CLEAR_LINE}`);
|
|
8007
8793
|
}
|
|
8008
8794
|
writeError(` ${palette.red("✗")} ${message}`);
|
|
8009
8795
|
if (cause !== void 0) writeError(String(cause));
|
|
8796
|
+
if (wasRebuilding) resumeIdle();
|
|
8797
|
+
else stopTicker();
|
|
8010
8798
|
},
|
|
8011
8799
|
/**
|
|
8012
|
-
* Render a section heading (a blank line + a bold
|
|
8800
|
+
* Render a section heading (a blank line + a bold pink label) for a multi-step flow.
|
|
8013
8801
|
*
|
|
8014
8802
|
* @param text - The heading label.
|
|
8015
8803
|
* @example
|
|
@@ -8017,7 +8805,7 @@ function createPanelRenderer(options = {}) {
|
|
|
8017
8805
|
*/
|
|
8018
8806
|
heading(text) {
|
|
8019
8807
|
write("");
|
|
8020
|
-
write(` ${palette.bold(palette.
|
|
8808
|
+
write(` ${palette.bold(palette.pink(text))}`);
|
|
8021
8809
|
},
|
|
8022
8810
|
/**
|
|
8023
8811
|
* Render a diagnostic line: green `✓` (pass) or red `✗` (fail) + label, with optional
|
|
@@ -8032,6 +8820,19 @@ function createPanelRenderer(options = {}) {
|
|
|
8032
8820
|
check(ok, label, detail) {
|
|
8033
8821
|
write(` ${ok ? palette.green("✓") : palette.red("✗")} ${label}`);
|
|
8034
8822
|
if (detail !== void 0) for (const line of detail.split("\n")) write(` ${palette.dim(line)}`);
|
|
8823
|
+
},
|
|
8824
|
+
/**
|
|
8825
|
+
* Stop every animation and release the interval timer (serve()'s teardown calls this).
|
|
8826
|
+
*
|
|
8827
|
+
* @example
|
|
8828
|
+
* render.dispose();
|
|
8829
|
+
*/
|
|
8830
|
+
dispose() {
|
|
8831
|
+
stopTicker();
|
|
8832
|
+
idle = false;
|
|
8833
|
+
rebuilding = false;
|
|
8834
|
+
phaseOpen = false;
|
|
8835
|
+
serveMode = false;
|
|
8035
8836
|
}
|
|
8036
8837
|
};
|
|
8037
8838
|
}
|
|
@@ -8177,6 +8978,23 @@ function defaultFileMtime(filePath) {
|
|
|
8177
8978
|
}
|
|
8178
8979
|
}
|
|
8179
8980
|
/**
|
|
8981
|
+
* Default file-content-hash probe — `sha256` of the file bytes, returning `null` for a
|
|
8982
|
+
* missing/unreadable path. serve()'s change gate compares this against the last
|
|
8983
|
+
* successfully-built bytes to drop a no-op save (a byte-identical double Ctrl-S).
|
|
8984
|
+
*
|
|
8985
|
+
* @param filePath - The absolute path to hash.
|
|
8986
|
+
* @returns The hex SHA-256 of the file's bytes, or `null` when it cannot be read.
|
|
8987
|
+
* @example
|
|
8988
|
+
* const hash = defaultFileHash("/abs/content/a.md");
|
|
8989
|
+
*/
|
|
8990
|
+
function defaultFileHash(filePath) {
|
|
8991
|
+
try {
|
|
8992
|
+
return createHash("sha256").update(readFileSync(filePath)).digest("hex");
|
|
8993
|
+
} catch {
|
|
8994
|
+
return null;
|
|
8995
|
+
}
|
|
8996
|
+
}
|
|
8997
|
+
/**
|
|
8180
8998
|
* Default LAN network-URL deriver — wraps {@link networkUrl} so the production seam
|
|
8181
8999
|
* reads `node:os` interfaces while tests can inject a deterministic value.
|
|
8182
9000
|
*
|
|
@@ -8188,6 +9006,101 @@ function defaultFileMtime(filePath) {
|
|
|
8188
9006
|
function defaultNetworkUrl(port) {
|
|
8189
9007
|
return networkUrl(port);
|
|
8190
9008
|
}
|
|
9009
|
+
/** Memoized banner facts — resolution touches the filesystem + git once, then caches. */
|
|
9010
|
+
let cachedBanner;
|
|
9011
|
+
/**
|
|
9012
|
+
* Run a read-only `git` command in `dir`, returning its trimmed stdout (`undefined` on
|
|
9013
|
+
* any failure — not a checkout, git missing, etc.). A thin wrapper so the version
|
|
9014
|
+
* resolver can issue a couple of git queries without repeating the spawn boilerplate.
|
|
9015
|
+
*
|
|
9016
|
+
* @param dir - The working directory to run git in.
|
|
9017
|
+
* @param args - The git arguments (no user input is ever interpolated).
|
|
9018
|
+
* @returns The trimmed command output, or `undefined` on failure.
|
|
9019
|
+
* @example
|
|
9020
|
+
* git("/Users/me/moku/web", ["tag", "--list", "v*"]);
|
|
9021
|
+
*/
|
|
9022
|
+
function git(dir, args) {
|
|
9023
|
+
try {
|
|
9024
|
+
return execFileSync("git", args, {
|
|
9025
|
+
cwd: dir,
|
|
9026
|
+
encoding: "utf8",
|
|
9027
|
+
stdio: [
|
|
9028
|
+
"ignore",
|
|
9029
|
+
"pipe",
|
|
9030
|
+
"ignore"
|
|
9031
|
+
]
|
|
9032
|
+
}).trim();
|
|
9033
|
+
} catch {
|
|
9034
|
+
return;
|
|
9035
|
+
}
|
|
9036
|
+
}
|
|
9037
|
+
/**
|
|
9038
|
+
* The framework's source/dev version, derived the SAME way the publish workflow computes
|
|
9039
|
+
* a release: the highest semver `v*` tag is the source of truth (`@moku-labs/web` is
|
|
9040
|
+
* released tag-only — the working-tree `package.json` deliberately carries no `version`).
|
|
9041
|
+
* A `-dev` suffix marks it as a local build off that release line (e.g. `v1.1.0-dev`), so
|
|
9042
|
+
* it never masquerades as the published release. Falls back to the short commit (then
|
|
9043
|
+
* `undefined`) only when no tags exist. `undefined` when `dir` is not a git checkout (a
|
|
9044
|
+
* published npm install — which carries its real `version` instead).
|
|
9045
|
+
*
|
|
9046
|
+
* @param dir - A directory inside the framework's own repository (the realpath of the
|
|
9047
|
+
* package root, so a symlinked local checkout reports the framework's tag — not the
|
|
9048
|
+
* consumer's).
|
|
9049
|
+
* @returns The dev version (e.g. `v1.1.0-dev`), or `undefined`.
|
|
9050
|
+
* @example
|
|
9051
|
+
* devVersion("/Users/me/moku/web"); // "v1.1.0-dev"
|
|
9052
|
+
*/
|
|
9053
|
+
function devVersion(dir) {
|
|
9054
|
+
const latestTag = git(dir, [
|
|
9055
|
+
"tag",
|
|
9056
|
+
"--list",
|
|
9057
|
+
"v*",
|
|
9058
|
+
"--sort=-v:refname"
|
|
9059
|
+
])?.split("\n")[0]?.trim();
|
|
9060
|
+
if (latestTag) return `${latestTag}-dev`;
|
|
9061
|
+
const sha = git(dir, [
|
|
9062
|
+
"rev-parse",
|
|
9063
|
+
"--short",
|
|
9064
|
+
"HEAD"
|
|
9065
|
+
]);
|
|
9066
|
+
return sha ? `${sha}-dev` : void 0;
|
|
9067
|
+
}
|
|
9068
|
+
/**
|
|
9069
|
+
* Resolve the real version/runtime facts shown in the Panel banner (memoized). Reads the
|
|
9070
|
+
* `package.json` shipped beside the built bundle (`dist/../package.json`): a PUBLISHED
|
|
9071
|
+
* release carries a `version` and reports `v{version}`; a source/dev build (no `version`
|
|
9072
|
+
* field — `@moku-labs/web` is released tag-only) reports the latest semver tag + `-dev`
|
|
9073
|
+
* (e.g. `v1.1.0-dev`, the same tag the publish workflow treats as the version source), or
|
|
9074
|
+
* `"dev"` when git is unavailable. The pinned `@moku-labs/core` version comes from the
|
|
9075
|
+
* same file's `dependencies`.
|
|
9076
|
+
*
|
|
9077
|
+
* @returns The resolved {@link BannerFacts}.
|
|
9078
|
+
* @example
|
|
9079
|
+
* resolveBanner(); // { version: "v1.1.0-dev", coreVersion: "0.1.0-alpha.6" }
|
|
9080
|
+
*/
|
|
9081
|
+
function resolveBanner() {
|
|
9082
|
+
if (cachedBanner) return cachedBanner;
|
|
9083
|
+
let pkg = {};
|
|
9084
|
+
let pkgDir;
|
|
9085
|
+
try {
|
|
9086
|
+
const pkgUrl = new URL("../package.json", import.meta.url);
|
|
9087
|
+
pkgDir = realpathSync(path.dirname(fileURLToPath(pkgUrl)));
|
|
9088
|
+
pkg = JSON.parse(readFileSync(pkgUrl, "utf8"));
|
|
9089
|
+
} catch {}
|
|
9090
|
+
const coreVersion = (pkg.dependencies?.["@moku-labs/core"] ?? "").replace(/^\D*/, "") || "unknown";
|
|
9091
|
+
const released = pkg.version;
|
|
9092
|
+
let version = "dev";
|
|
9093
|
+
if (released) version = `v${released}`;
|
|
9094
|
+
else {
|
|
9095
|
+
const dev = devVersion(pkgDir ?? process.cwd());
|
|
9096
|
+
if (dev) version = dev;
|
|
9097
|
+
}
|
|
9098
|
+
cachedBanner = {
|
|
9099
|
+
version,
|
|
9100
|
+
coreVersion
|
|
9101
|
+
};
|
|
9102
|
+
return cachedBanner;
|
|
9103
|
+
}
|
|
8191
9104
|
/**
|
|
8192
9105
|
* Create the initial cli plugin state with the production seams wired. Every field is
|
|
8193
9106
|
* an injectable seam (`render`, `confirm`, `clock`, `watch`, the server factories,
|
|
@@ -8201,8 +9114,12 @@ function defaultNetworkUrl(port) {
|
|
|
8201
9114
|
* const state = createState({ global: {}, config });
|
|
8202
9115
|
*/
|
|
8203
9116
|
function createState$1(_ctx) {
|
|
9117
|
+
const banner = resolveBanner();
|
|
8204
9118
|
return {
|
|
8205
|
-
render: createPanelRenderer(
|
|
9119
|
+
render: createPanelRenderer({
|
|
9120
|
+
version: banner.version,
|
|
9121
|
+
coreVersion: banner.coreVersion
|
|
9122
|
+
}),
|
|
8206
9123
|
confirm: defaultConfirm,
|
|
8207
9124
|
select: defaultSelect,
|
|
8208
9125
|
clock: Date.now,
|
|
@@ -8210,7 +9127,8 @@ function createState$1(_ctx) {
|
|
|
8210
9127
|
serveStatic: defaultServeStatic,
|
|
8211
9128
|
fileResponse: defaultFileResponse,
|
|
8212
9129
|
networkUrl: defaultNetworkUrl,
|
|
8213
|
-
fileMtime: defaultFileMtime
|
|
9130
|
+
fileMtime: defaultFileMtime,
|
|
9131
|
+
fileHash: defaultFileHash
|
|
8214
9132
|
};
|
|
8215
9133
|
}
|
|
8216
9134
|
//#endregion
|