@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.cjs
CHANGED
|
@@ -10,7 +10,9 @@ node_path = require_convention.__toESM(node_path);
|
|
|
10
10
|
let feed = require("feed");
|
|
11
11
|
let preact = require("preact");
|
|
12
12
|
let preact_render_to_string = require("preact-render-to-string");
|
|
13
|
+
let node_child_process = require("node:child_process");
|
|
13
14
|
let node_readline = require("node:readline");
|
|
15
|
+
let node_url = require("node:url");
|
|
14
16
|
let node_os = require("node:os");
|
|
15
17
|
let gray_matter = require("gray-matter");
|
|
16
18
|
gray_matter = require_convention.__toESM(gray_matter, 1);
|
|
@@ -1293,18 +1295,24 @@ function isPublished(article, isProduction) {
|
|
|
1293
1295
|
* locale collection: existing files only, drafts dropped in production, sorted
|
|
1294
1296
|
* date-descending. The single load+filter+sort step behind {@link createContentApi.loadAll}.
|
|
1295
1297
|
*
|
|
1298
|
+
* When a `cached` map is supplied (incremental dev rebuild), a slug already present in it
|
|
1299
|
+
* is reused as-is (skipping the re-read + Markdown/Shiki re-render); only slugs absent
|
|
1300
|
+
* from it — the ones a preceding `invalidate()` dropped, plus any never-loaded — are
|
|
1301
|
+
* resolved fresh. With no `cached` map every slug is resolved (the full load).
|
|
1302
|
+
*
|
|
1296
1303
|
* @param ctx - Kernel-free domain context (provider + i18n helpers + stage).
|
|
1297
1304
|
* @param slugs - Every known article slug from the provider.
|
|
1298
1305
|
* @param locale - The locale to resolve and collect.
|
|
1306
|
+
* @param cached - Optional per-locale article cache to reuse for unchanged slugs.
|
|
1299
1307
|
* @returns The published (date-descending) articles for this locale.
|
|
1300
1308
|
* @example
|
|
1301
1309
|
* ```ts
|
|
1302
1310
|
* const present = await loadAndFilterArticles(ctx, slugs, "en");
|
|
1303
1311
|
* ```
|
|
1304
1312
|
*/
|
|
1305
|
-
async function loadAndFilterArticles(ctx, slugs, locale) {
|
|
1313
|
+
async function loadAndFilterArticles(ctx, slugs, locale, cached) {
|
|
1306
1314
|
const isProduction = ctx.global.stage === "production";
|
|
1307
|
-
return (await Promise.all(slugs.map((slug) => resolveArticle(ctx, slug, locale)))).filter((article) => article !== null).filter((article) => isPublished(article, isProduction)).toSorted(byDateDescending);
|
|
1315
|
+
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);
|
|
1308
1316
|
}
|
|
1309
1317
|
/**
|
|
1310
1318
|
* Derive the article slug from a source file path — the parent directory name
|
|
@@ -1341,19 +1349,26 @@ function createContentApi(ctx) {
|
|
|
1341
1349
|
* Load every article across every active locale (locale fallback, production
|
|
1342
1350
|
* draft exclusion, date sort, `contentId` after sort), cache them, emit `content:ready`.
|
|
1343
1351
|
*
|
|
1352
|
+
* With `{ reuse: true }` (dev incremental rebuild) cached articles are reused for
|
|
1353
|
+
* every slug a preceding `invalidate()` did not drop, so only the dirty articles
|
|
1354
|
+
* re-read + re-run the Markdown/Shiki pipeline; the `contentId` ordinals are still
|
|
1355
|
+
* recomputed across the FULL sorted set, so ids + order match a full load.
|
|
1356
|
+
*
|
|
1357
|
+
* @param options - Optional load behaviour (`reuse`); omit for a full load.
|
|
1344
1358
|
* @returns A locale-keyed map of date-descending articles.
|
|
1345
1359
|
* @example
|
|
1346
1360
|
* ```ts
|
|
1347
1361
|
* const byLocale = await api.loadAll();
|
|
1348
1362
|
* ```
|
|
1349
1363
|
*/
|
|
1350
|
-
async loadAll() {
|
|
1364
|
+
async loadAll(options) {
|
|
1365
|
+
const reuse = options?.reuse === true;
|
|
1351
1366
|
const slugs = await ctx.provider.slugs();
|
|
1352
1367
|
const locales = ctx.locales();
|
|
1353
1368
|
const result = /* @__PURE__ */ new Map();
|
|
1354
1369
|
let total = 0;
|
|
1355
1370
|
for (const locale of locales) {
|
|
1356
|
-
const present = await loadAndFilterArticles(ctx, slugs, locale);
|
|
1371
|
+
const present = await loadAndFilterArticles(ctx, slugs, locale, reuse ? ctx.state.articles.get(locale) : void 0);
|
|
1357
1372
|
const cache = /* @__PURE__ */ new Map();
|
|
1358
1373
|
let index = 0;
|
|
1359
1374
|
for (const article of present) {
|
|
@@ -3354,7 +3369,11 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
|
|
|
3354
3369
|
/**
|
|
3355
3370
|
* Bundles CSS and JS into the output directory via two separate runner passes
|
|
3356
3371
|
* (dodging Bun's mixed-entrypoint segfault), honoring `config.minify`, and caches
|
|
3357
|
-
* the resulting hashed asset paths in `state.buildCache` for downstream phases.
|
|
3372
|
+
* the resulting hashed asset paths in `state.buildCache` for downstream phases. The
|
|
3373
|
+
* two passes run CONCURRENTLY (`Promise.all`) — they target disjoint hashed outputs
|
|
3374
|
+
* and distinct `buildCache` keys (`css`/`js`), so overlapping them ~halves bundle
|
|
3375
|
+
* wall-time with no shared-state hazard. The CSS pass is still dispatched first, so
|
|
3376
|
+
* the runner's invocation order stays css-then-js.
|
|
3358
3377
|
*
|
|
3359
3378
|
* @param ctx - Plugin context (provides `state`, `config`, `log`).
|
|
3360
3379
|
* @param options - Optional dependency-injection seam (runner + entrypoints).
|
|
@@ -3367,10 +3386,10 @@ async function runOne(ctx, runner, kind, entrypoints, outDir, outdir, minify) {
|
|
|
3367
3386
|
async function bundle(ctx, options = {}) {
|
|
3368
3387
|
const runner = options.runner ?? defaultRunner;
|
|
3369
3388
|
const { minify, outDir } = ctx.config;
|
|
3389
|
+
const assetsDir = node_path$1.default.join(outDir, "assets");
|
|
3370
3390
|
const cssEntrypoints = options.cssEntrypoints ?? resolveEntrypoints(CSS_ENTRY_CANDIDATES);
|
|
3371
3391
|
const jsEntrypoints = options.jsEntrypoints ?? resolveJsEntrypoints(ctx);
|
|
3372
|
-
await runOne(ctx, runner, "css", cssEntrypoints, outDir,
|
|
3373
|
-
await runOne(ctx, runner, "js", jsEntrypoints, outDir, node_path$1.default.join(outDir, "assets"), minify);
|
|
3392
|
+
await Promise.all([runOne(ctx, runner, "css", cssEntrypoints, outDir, assetsDir, minify), runOne(ctx, runner, "js", jsEntrypoints, outDir, assetsDir, minify)]);
|
|
3374
3393
|
}
|
|
3375
3394
|
//#endregion
|
|
3376
3395
|
//#region src/plugins/build/phases/content.ts
|
|
@@ -3387,15 +3406,24 @@ const CONTENT_CACHE_KEY = "content";
|
|
|
3387
3406
|
* pages/feeds/og-images phases. Performs NO Markdown parsing itself — the
|
|
3388
3407
|
* content plugin owns rendering (god-plugin invariant).
|
|
3389
3408
|
*
|
|
3409
|
+
* On a dev incremental rebuild (`options.changed` set) it first `invalidate()`s the
|
|
3410
|
+
* changed Markdown so `loadAll({ reuse: true })` re-reads + re-renders ONLY those
|
|
3411
|
+
* articles, reusing the cached HTML for the rest. With no options it does a full load.
|
|
3412
|
+
*
|
|
3390
3413
|
* @param ctx - Plugin context (provides `require`, `state`, `log`).
|
|
3414
|
+
* @param options - Optional incremental hints; omit for a full load.
|
|
3415
|
+
* @param options.reuse - Reuse cached content for slugs not invalidated (dev incremental rebuild).
|
|
3416
|
+
* @param options.changed - The changed Markdown paths to invalidate before loading.
|
|
3391
3417
|
* @returns The locale-keyed article map returned by the content plugin.
|
|
3392
3418
|
* @example
|
|
3393
3419
|
* ```ts
|
|
3394
3420
|
* const byLocale = await loadContent(ctx);
|
|
3395
3421
|
* ```
|
|
3396
3422
|
*/
|
|
3397
|
-
async function loadContent(ctx) {
|
|
3398
|
-
const
|
|
3423
|
+
async function loadContent(ctx, options) {
|
|
3424
|
+
const content = ctx.require(contentPlugin);
|
|
3425
|
+
if (options?.changed && options.changed.length > 0) content.invalidate(options.changed);
|
|
3426
|
+
const byLocale = await content.loadAll({ reuse: options?.reuse ?? false });
|
|
3399
3427
|
ctx.state.buildCache.set(CONTENT_CACHE_KEY, byLocale);
|
|
3400
3428
|
ctx.log.debug("build:content", { locales: byLocale.size });
|
|
3401
3429
|
return byLocale;
|
|
@@ -4749,6 +4777,71 @@ function renderBody(definition, routeContext) {
|
|
|
4749
4777
|
return (0, preact_render_to_string.renderToString)(definition._handlers.layout ? definition._handlers.layout(layoutContext, vnode) : vnode);
|
|
4750
4778
|
}
|
|
4751
4779
|
/**
|
|
4780
|
+
* Hash a page's render inputs (its loaded data) for the render cache. `null` when the
|
|
4781
|
+
* data is not JSON-serializable — such a page is never cached and always re-renders.
|
|
4782
|
+
*
|
|
4783
|
+
* @param data - The route's loaded data (the only per-page input besides params/locale/code).
|
|
4784
|
+
* @returns The hex SHA-256 of the serialized data, or `null` when it cannot be serialized.
|
|
4785
|
+
* @example
|
|
4786
|
+
* ```ts
|
|
4787
|
+
* hashData({ title: "Hi" }); // "9f8e…"
|
|
4788
|
+
* ```
|
|
4789
|
+
*/
|
|
4790
|
+
function hashData(data) {
|
|
4791
|
+
try {
|
|
4792
|
+
const serialized = JSON.stringify(data) ?? "";
|
|
4793
|
+
return (0, node_crypto.createHash)("sha256").update(serialized).digest("hex");
|
|
4794
|
+
} catch {
|
|
4795
|
+
return null;
|
|
4796
|
+
}
|
|
4797
|
+
}
|
|
4798
|
+
/**
|
|
4799
|
+
* The render-cache key for one page instance: name + params + locale (the stable identity
|
|
4800
|
+
* that, together with the data hash, determines its body). NUL-joined so no value collides.
|
|
4801
|
+
*
|
|
4802
|
+
* @param instance - The page instance.
|
|
4803
|
+
* @returns The cache key string.
|
|
4804
|
+
* @example
|
|
4805
|
+
* ```ts
|
|
4806
|
+
* renderCacheKey(instance); // "article{\"slug\":\"x\"}en"
|
|
4807
|
+
* ```
|
|
4808
|
+
*/
|
|
4809
|
+
function renderCacheKey(instance) {
|
|
4810
|
+
return `${instance.name}${JSON.stringify(instance.params)}${instance.locale}`;
|
|
4811
|
+
}
|
|
4812
|
+
/**
|
|
4813
|
+
* Render one page's body, reusing the cached body when this page's data is unchanged.
|
|
4814
|
+
* The body is the synchronous, dominant-cost step ({@link renderBody}); an incremental
|
|
4815
|
+
* dev rebuild (`reuse`, code unchanged) skips it for every page whose data hash matches
|
|
4816
|
+
* the cache, and a changed page (or a non-`reuse` run) renders + refreshes the cache.
|
|
4817
|
+
*
|
|
4818
|
+
* @param ctx - Plugin context (provides the cross-run `state.renderCache`).
|
|
4819
|
+
* @param instance - The page instance being rendered.
|
|
4820
|
+
* @param routeContext - The route context passed to `.render()`/`.layout()`.
|
|
4821
|
+
* @param data - The route's loaded data (hashed to detect a change).
|
|
4822
|
+
* @param reuse - Whether this run may reuse a cached body (incremental, no code change).
|
|
4823
|
+
* @returns The SSR-rendered body HTML.
|
|
4824
|
+
* @example
|
|
4825
|
+
* ```ts
|
|
4826
|
+
* const body = renderBodyCached(ctx, instance, routeContext, data, true);
|
|
4827
|
+
* ```
|
|
4828
|
+
*/
|
|
4829
|
+
function renderBodyCached(ctx, instance, routeContext, data, reuse) {
|
|
4830
|
+
const cache = ctx.state.renderCache;
|
|
4831
|
+
const key = renderCacheKey(instance);
|
|
4832
|
+
const hash = hashData(data);
|
|
4833
|
+
if (reuse && hash !== null) {
|
|
4834
|
+
const hit = cache.get(key);
|
|
4835
|
+
if (hit?.dataHash === hash) return hit.body;
|
|
4836
|
+
}
|
|
4837
|
+
const body = renderBody(instance.definition, routeContext);
|
|
4838
|
+
if (hash !== null) cache.set(key, {
|
|
4839
|
+
dataHash: hash,
|
|
4840
|
+
body
|
|
4841
|
+
});
|
|
4842
|
+
return body;
|
|
4843
|
+
}
|
|
4844
|
+
/**
|
|
4752
4845
|
* Write a rendered page document to its on-disk path. The path comes from the
|
|
4753
4846
|
* compiled `TypedRoute.toFile(params)` (honoring any route-level `.toFile()`
|
|
4754
4847
|
* override), resolved under the build `outDir`; parent directories are created first.
|
|
@@ -4777,13 +4870,14 @@ async function writeDocument(outDir, entry, params, html) {
|
|
|
4777
4870
|
* @param ctx - Plugin context (provides `require`, `state`, `config`, `has`).
|
|
4778
4871
|
* @param instance - The concrete page instance to render.
|
|
4779
4872
|
* @param shell - Per-build wiring shared across instances (asset tags + template).
|
|
4873
|
+
* @param reuse - Whether this run may reuse a cached body (incremental, no code change).
|
|
4780
4874
|
* @returns The instance's URL, rendered HTML, loaded data, and client-nav flag.
|
|
4781
4875
|
* @example
|
|
4782
4876
|
* ```ts
|
|
4783
|
-
* await renderInstance(ctx, instance, { assets: "", template: null });
|
|
4877
|
+
* await renderInstance(ctx, instance, { assets: "", template: null }, false);
|
|
4784
4878
|
* ```
|
|
4785
4879
|
*/
|
|
4786
|
-
async function renderInstance(ctx, instance, shell) {
|
|
4880
|
+
async function renderInstance(ctx, instance, shell, reuse) {
|
|
4787
4881
|
const { definition, entry, params, locale } = instance;
|
|
4788
4882
|
const router = ctx.require(routerPlugin);
|
|
4789
4883
|
const data = await loadRouteData(definition, params, locale, ctx);
|
|
@@ -4796,7 +4890,7 @@ async function renderInstance(ctx, instance, shell) {
|
|
|
4796
4890
|
};
|
|
4797
4891
|
const parts = {
|
|
4798
4892
|
head: composeHeadHtml(ctx, instance, url, routeContext, data),
|
|
4799
|
-
body:
|
|
4893
|
+
body: renderBodyCached(ctx, instance, routeContext, data, reuse),
|
|
4800
4894
|
assets: shell.assets,
|
|
4801
4895
|
locale
|
|
4802
4896
|
};
|
|
@@ -4890,27 +4984,77 @@ function findRootHtml(rendered) {
|
|
|
4890
4984
|
return rendered.find((page) => page.url === "/" || page.url === "")?.html ?? null;
|
|
4891
4985
|
}
|
|
4892
4986
|
/**
|
|
4987
|
+
* Pages rendered concurrently per batch. Kept small so the macrotask yield between
|
|
4988
|
+
* batches fires frequently — a large batch renders for seconds before yielding, which
|
|
4989
|
+
* leaves a watching dev server's spinner repainting only every few seconds (sluggish).
|
|
4990
|
+
* Smaller batches trade a little write-concurrency for a smooth, responsive spinner.
|
|
4991
|
+
*/
|
|
4992
|
+
const RENDER_BATCH_SIZE = 2;
|
|
4993
|
+
/**
|
|
4994
|
+
* Batch size for an incremental (`reuse`) rebuild. Most instances are cheap cache hits, so
|
|
4995
|
+
* a larger batch cuts the per-batch `setImmediate` round-trips (which would otherwise add
|
|
4996
|
+
* pure latency to an otherwise-fast rebuild) without starving the dev spinner.
|
|
4997
|
+
*/
|
|
4998
|
+
const INCREMENTAL_BATCH_SIZE = 32;
|
|
4999
|
+
/**
|
|
5000
|
+
* Render `items` through `worker` in bounded-size batches, yielding a macrotask
|
|
5001
|
+
* (`setImmediate`) between batches. Beyond bounding peak concurrency/memory for large
|
|
5002
|
+
* sites, the yield lets the single JS thread breathe: one un-yielded `Promise.all` over
|
|
5003
|
+
* hundreds of synchronous `renderToString` calls starves the event loop, which freezes a
|
|
5004
|
+
* watching dev server's progress spinner until the whole phase resolves. Output order is
|
|
5005
|
+
* preserved (batch order + `Promise.all` order within a batch).
|
|
5006
|
+
*
|
|
5007
|
+
* @template Item - The input item type.
|
|
5008
|
+
* @template Out - The rendered output type.
|
|
5009
|
+
* @param items - The items to render.
|
|
5010
|
+
* @param batchSize - Maximum items rendered concurrently per batch.
|
|
5011
|
+
* @param worker - Renders one item to its output.
|
|
5012
|
+
* @returns All rendered outputs in input order.
|
|
5013
|
+
* @example
|
|
5014
|
+
* ```ts
|
|
5015
|
+
* const pages = await renderInBatches(instances, 32, i => renderInstance(ctx, i, shell));
|
|
5016
|
+
* ```
|
|
5017
|
+
*/
|
|
5018
|
+
async function renderInBatches(items, batchSize, worker) {
|
|
5019
|
+
const out = [];
|
|
5020
|
+
for (let start = 0; start < items.length; start += batchSize) {
|
|
5021
|
+
const batch = items.slice(start, start + batchSize);
|
|
5022
|
+
out.push(...await Promise.all(batch.map((item) => worker(item))));
|
|
5023
|
+
if (start + batchSize < items.length) await new Promise((resolve) => {
|
|
5024
|
+
setImmediate(resolve);
|
|
5025
|
+
});
|
|
5026
|
+
}
|
|
5027
|
+
return out;
|
|
5028
|
+
}
|
|
5029
|
+
/**
|
|
4893
5030
|
* Renders every route in the manifest to `outDir/<path>/index.html`. Reads as a
|
|
4894
|
-
* pipeline: resolve deps → prepare the shared shell → expand instances → render
|
|
4895
|
-
*
|
|
4896
|
-
*
|
|
5031
|
+
* pipeline: resolve deps → prepare the shared shell → expand instances → render in
|
|
5032
|
+
* bounded batches ({@link renderInBatches}) → write data sidecars (hybrid/spa) →
|
|
5033
|
+
* capture the root page's HTML for the root-index phase.
|
|
5034
|
+
*
|
|
5035
|
+
* On an incremental rebuild (`options.reuse`) the cross-run render cache is kept and each
|
|
5036
|
+
* unchanged-data page reuses its cached body; a full render clears the cache first so a
|
|
5037
|
+
* removed/renamed route's stale body never lingers.
|
|
4897
5038
|
*
|
|
4898
5039
|
* @param ctx - Plugin context (provides `require`, `state`, `config`, `log`, `has`).
|
|
5040
|
+
* @param options - Optional incremental hint; omit for a full render.
|
|
5041
|
+
* @param options.reuse - Reuse cached page bodies for unchanged-data pages (dev incremental rebuild).
|
|
4899
5042
|
* @returns The number of pages rendered and the captured default-page HTML.
|
|
4900
5043
|
* @example
|
|
4901
5044
|
* ```ts
|
|
4902
5045
|
* const { pageCount, rootHtml } = await renderPages(ctx);
|
|
4903
5046
|
* ```
|
|
4904
5047
|
*/
|
|
4905
|
-
async function renderPages(ctx) {
|
|
5048
|
+
async function renderPages(ctx, options) {
|
|
5049
|
+
const reuse = options?.reuse === true;
|
|
4906
5050
|
const router = ctx.require(routerPlugin);
|
|
4907
5051
|
const manifest = router.manifest();
|
|
4908
5052
|
ctx.state.manifest = [...manifest];
|
|
4909
5053
|
const locales = ctx.require(i18nPlugin).locales();
|
|
4910
5054
|
const byPattern = makeEntryMap(router);
|
|
5055
|
+
if (!reuse) ctx.state.renderCache.clear();
|
|
4911
5056
|
const shell = await prepareShell(ctx);
|
|
4912
|
-
const
|
|
4913
|
-
const rendered = await Promise.all(instances.map((instance) => renderInstance(ctx, instance, shell)));
|
|
5057
|
+
const rendered = await renderInBatches(await expandAllInstances(manifest, locales, byPattern, ctx), reuse ? INCREMENTAL_BATCH_SIZE : RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell, reuse));
|
|
4914
5058
|
await writeDataSidecars(ctx, rendered, router.mode());
|
|
4915
5059
|
ctx.log.debug("build:pages", { count: rendered.length });
|
|
4916
5060
|
return {
|
|
@@ -5118,6 +5262,40 @@ async function generateSitemap(ctx) {
|
|
|
5118
5262
|
* @file build plugin — pipeline driver. Sequences the fixed multi-phase build,
|
|
5119
5263
|
* emits `build:phase` boundaries, and runs intra-phase work via `Promise.all`.
|
|
5120
5264
|
*/
|
|
5265
|
+
/** Matches a Markdown source path (a content edit). */
|
|
5266
|
+
const MARKDOWN_PATH = /\.md$/;
|
|
5267
|
+
/** Matches a stylesheet path (a CSS edit — does not change rendered page bodies). */
|
|
5268
|
+
const STYLE_PATH = /\.css$/;
|
|
5269
|
+
/** Matches a code path (TS/JS/JSON — may change ANY page's render output). */
|
|
5270
|
+
const CODE_PATH = /\.(?:tsx?|jsx?|mjs|cjs|json)$/;
|
|
5271
|
+
/**
|
|
5272
|
+
* Derive the {@link ChangePlan} for a run from its changed-path set (see the type docs
|
|
5273
|
+
* for the rules).
|
|
5274
|
+
*
|
|
5275
|
+
* @param changed - Absolute/relative changed paths, or `undefined` for a full build.
|
|
5276
|
+
* @returns The reuse plan for this run.
|
|
5277
|
+
* @example
|
|
5278
|
+
* ```ts
|
|
5279
|
+
* const plan = planIncrementalRebuild(options?.changed);
|
|
5280
|
+
* ```
|
|
5281
|
+
*/
|
|
5282
|
+
function planIncrementalRebuild(changed) {
|
|
5283
|
+
if (changed === void 0 || changed.length === 0) return {
|
|
5284
|
+
contentChanged: [],
|
|
5285
|
+
contentReuse: false,
|
|
5286
|
+
renderReuse: false
|
|
5287
|
+
};
|
|
5288
|
+
if (!changed.every((file) => MARKDOWN_PATH.test(file) || STYLE_PATH.test(file) || CODE_PATH.test(file))) return {
|
|
5289
|
+
contentChanged: [],
|
|
5290
|
+
contentReuse: false,
|
|
5291
|
+
renderReuse: false
|
|
5292
|
+
};
|
|
5293
|
+
return {
|
|
5294
|
+
contentChanged: changed.filter((file) => MARKDOWN_PATH.test(file)),
|
|
5295
|
+
contentReuse: true,
|
|
5296
|
+
renderReuse: !changed.some((file) => CODE_PATH.test(file))
|
|
5297
|
+
};
|
|
5298
|
+
}
|
|
5121
5299
|
/**
|
|
5122
5300
|
* The static ordered list of pipeline phase names.
|
|
5123
5301
|
*
|
|
@@ -5231,8 +5409,7 @@ async function runOutputs(ctx) {
|
|
|
5231
5409
|
* `build:phase` boundary per phase and `build:complete` once at the end.
|
|
5232
5410
|
*
|
|
5233
5411
|
* @param ctx - Plugin context (provides `require`, `emit`, `state`, `config`, `log`).
|
|
5234
|
-
* @param options - Optional run overrides.
|
|
5235
|
-
* @param options.outDir - Override the configured output directory for this run.
|
|
5412
|
+
* @param options - Optional per-run overrides ({@link RunOptions}).
|
|
5236
5413
|
* @returns The build result (outDir, pageCount, durationMs).
|
|
5237
5414
|
* @example
|
|
5238
5415
|
* ```ts
|
|
@@ -5247,17 +5424,22 @@ async function runPipeline(ctx, options) {
|
|
|
5247
5424
|
...ctx,
|
|
5248
5425
|
config: {
|
|
5249
5426
|
...ctx.config,
|
|
5250
|
-
outDir
|
|
5427
|
+
outDir,
|
|
5428
|
+
...options?.overrides
|
|
5251
5429
|
}
|
|
5252
5430
|
};
|
|
5253
|
-
|
|
5431
|
+
const plan = planIncrementalRebuild(options?.changed);
|
|
5432
|
+
if (!options?.skipClean) await (0, node_fs_promises.rm)(outDir, {
|
|
5254
5433
|
recursive: true,
|
|
5255
5434
|
force: true
|
|
5256
5435
|
});
|
|
5257
5436
|
await (0, node_fs_promises.mkdir)(outDir, { recursive: true });
|
|
5258
5437
|
await withPhase(phaseContext, "bundle", () => bundle(phaseContext));
|
|
5259
|
-
await Promise.all([withPhase(phaseContext, "content", () => loadContent(phaseContext
|
|
5260
|
-
|
|
5438
|
+
await Promise.all([withPhase(phaseContext, "content", () => loadContent(phaseContext, {
|
|
5439
|
+
reuse: plan.contentReuse,
|
|
5440
|
+
changed: plan.contentChanged
|
|
5441
|
+
})), withPhase(phaseContext, "images", () => processImages(phaseContext))]);
|
|
5442
|
+
const pages = await withPhase(phaseContext, "pages", () => renderPages(phaseContext, { reuse: plan.renderReuse }));
|
|
5261
5443
|
await withPhase(phaseContext, "content-images", () => copyContentImages(phaseContext));
|
|
5262
5444
|
await runOutputs(phaseContext);
|
|
5263
5445
|
await withPhase(phaseContext, "root-index", async () => {
|
|
@@ -5310,10 +5492,12 @@ const defaultConfig$2 = {
|
|
|
5310
5492
|
function createApi$3(ctx) {
|
|
5311
5493
|
return {
|
|
5312
5494
|
/**
|
|
5313
|
-
* Run the full SSG pipeline and write the site to disk.
|
|
5495
|
+
* Run the full SSG pipeline and write the site to disk. With no options a full
|
|
5496
|
+
* production build runs; dev callers pass `skipClean`/`overrides`/`changed` for a
|
|
5497
|
+
* fast incremental rebuild (all gated behind opt-in fields — the default path is
|
|
5498
|
+
* unchanged).
|
|
5314
5499
|
*
|
|
5315
|
-
* @param options - Optional run overrides.
|
|
5316
|
-
* @param options.outDir - Override the configured output directory for this run.
|
|
5500
|
+
* @param options - Optional per-run overrides (outDir / skipClean / overrides / changed).
|
|
5317
5501
|
* @returns The build result (outDir, pageCount, durationMs).
|
|
5318
5502
|
* @example
|
|
5319
5503
|
* ```ts
|
|
@@ -5406,8 +5590,8 @@ function createEvents(register) {
|
|
|
5406
5590
|
/**
|
|
5407
5591
|
* Creates initial `build` plugin state: a frozen config snapshot plus empty
|
|
5408
5592
|
* per-run caches (`manifest`, `buildCache`, `runId`) and the cross-run OG
|
|
5409
|
-
* content-hash
|
|
5410
|
-
* duplicated here (pulled fresh via `ctx.require` each run).
|
|
5593
|
+
* content-hash + page-render caches. Holds caches and config only — no domain data
|
|
5594
|
+
* is duplicated here (pulled fresh via `ctx.require` each run).
|
|
5411
5595
|
*
|
|
5412
5596
|
* @param ctx - Minimal context with global and config.
|
|
5413
5597
|
* @param ctx.global - Global plugin registry (unused; caches are config-driven).
|
|
@@ -5424,7 +5608,8 @@ function createState$3(ctx) {
|
|
|
5424
5608
|
manifest: null,
|
|
5425
5609
|
buildCache: /* @__PURE__ */ new Map(),
|
|
5426
5610
|
runId: null,
|
|
5427
|
-
ogImageHashCache: /* @__PURE__ */ new Map()
|
|
5611
|
+
ogImageHashCache: /* @__PURE__ */ new Map(),
|
|
5612
|
+
renderCache: /* @__PURE__ */ new Map()
|
|
5428
5613
|
};
|
|
5429
5614
|
}
|
|
5430
5615
|
//#endregion
|
|
@@ -5648,12 +5833,37 @@ function buildWranglerArgs(input) {
|
|
|
5648
5833
|
branch
|
|
5649
5834
|
];
|
|
5650
5835
|
}
|
|
5836
|
+
/**
|
|
5837
|
+
* Assemble the argv for `wrangler pages project create` (no shell). Guards the
|
|
5838
|
+
* production branch against flag injection; the slug is already a safe `toSlug` output.
|
|
5839
|
+
*
|
|
5840
|
+
* @param input - The resolved project-create inputs.
|
|
5841
|
+
* @param input.slug - Cloudflare project-name slug (`toSlug(site.name())`).
|
|
5842
|
+
* @param input.branch - Production branch (guarded by `/^[a-zA-Z0-9/_.-]+$/`).
|
|
5843
|
+
* @returns The wrangler argv array.
|
|
5844
|
+
* @throws {Error} `ERR_DEPLOY_INVALID_BRANCH` when the branch fails the guard.
|
|
5845
|
+
* @example
|
|
5846
|
+
* buildProjectCreateArgs({ slug: "my-site", branch: "main" });
|
|
5847
|
+
*/
|
|
5848
|
+
function buildProjectCreateArgs(input) {
|
|
5849
|
+
const branch = guardBranch(input.branch);
|
|
5850
|
+
return [
|
|
5851
|
+
"bunx",
|
|
5852
|
+
"wrangler",
|
|
5853
|
+
"pages",
|
|
5854
|
+
"project",
|
|
5855
|
+
"create",
|
|
5856
|
+
input.slug,
|
|
5857
|
+
"--production-branch",
|
|
5858
|
+
branch
|
|
5859
|
+
];
|
|
5860
|
+
}
|
|
5651
5861
|
/** Lowercased substring matchers for the wrangler error taxonomy. */
|
|
5652
5862
|
const ERROR_SIGNATURES = [
|
|
5653
5863
|
{
|
|
5654
5864
|
match: ["could not find project", "project not found"],
|
|
5655
5865
|
kind: "ERR_DEPLOY_PROJECT_NOT_FOUND",
|
|
5656
|
-
advice: "The Cloudflare Pages project does not exist.
|
|
5866
|
+
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.)"
|
|
5657
5867
|
},
|
|
5658
5868
|
{
|
|
5659
5869
|
match: [
|
|
@@ -6232,15 +6442,16 @@ function validateConfig$1(ctx) {
|
|
|
6232
6442
|
* Run wrangler for the prepared argv and surface its scrubbed result, translating
|
|
6233
6443
|
* a non-zero exit into the classified deploy error. The API token is read from env
|
|
6234
6444
|
* here so it never crosses a logging boundary; only scrubbed output is returned.
|
|
6445
|
+
* Shared by `run()` (deploy) and `createProject()` (project create).
|
|
6235
6446
|
*
|
|
6236
6447
|
* @param ctx - Plugin context (provides `state.spawn`, `config`, `env`).
|
|
6237
6448
|
* @param args - The fully-built, pre-validated wrangler argv.
|
|
6238
6449
|
* @returns The wrangler `stdout` plus the scrubbed `stderr` to log on success.
|
|
6239
6450
|
* @throws {Error} With a `code` from the deploy error taxonomy on a non-zero exit.
|
|
6240
6451
|
* @example
|
|
6241
|
-
* const { stdout, scrubbedStderr } = await
|
|
6452
|
+
* const { stdout, scrubbedStderr } = await executeWrangler(ctx, args);
|
|
6242
6453
|
*/
|
|
6243
|
-
async function
|
|
6454
|
+
async function executeWrangler(ctx, args) {
|
|
6244
6455
|
const token = ctx.env.require("CLOUDFLARE_API_TOKEN");
|
|
6245
6456
|
const { stdout, scrubbedStderr, exitCode } = await runWrangler({
|
|
6246
6457
|
spawn: ctx.state.spawn,
|
|
@@ -6312,7 +6523,7 @@ function createApi$2(ctx) {
|
|
|
6312
6523
|
root
|
|
6313
6524
|
});
|
|
6314
6525
|
const start = Date.now();
|
|
6315
|
-
const { stdout, scrubbedStderr } = await
|
|
6526
|
+
const { stdout, scrubbedStderr } = await executeWrangler(ctx, args);
|
|
6316
6527
|
ctx.log.info(scrubbedStderr);
|
|
6317
6528
|
const result = buildDeployResult(stdout, branch, start);
|
|
6318
6529
|
ctx.state.lastDeployment = result;
|
|
@@ -6351,6 +6562,38 @@ function createApi$2(ctx) {
|
|
|
6351
6562
|
cwd: process.cwd(),
|
|
6352
6563
|
options
|
|
6353
6564
|
});
|
|
6565
|
+
},
|
|
6566
|
+
/**
|
|
6567
|
+
* The Cloudflare Pages project name this app deploys to (`toSlug(site.name())`).
|
|
6568
|
+
*
|
|
6569
|
+
* @returns The project-name slug.
|
|
6570
|
+
* @example
|
|
6571
|
+
* api.projectName(); // "my-site"
|
|
6572
|
+
*/
|
|
6573
|
+
projectName() {
|
|
6574
|
+
return toSlug(ctx.require(sitePlugin).name());
|
|
6575
|
+
},
|
|
6576
|
+
/**
|
|
6577
|
+
* Create the remote Cloudflare Pages project via wrangler, so a first deploy has a
|
|
6578
|
+
* target. Derives the slug from `site.name()` and the production branch from config.
|
|
6579
|
+
*
|
|
6580
|
+
* @returns The created project name + production branch.
|
|
6581
|
+
* @throws {Error} With a `code` from the deploy error taxonomy on a non-zero exit.
|
|
6582
|
+
* @example
|
|
6583
|
+
* await api.createProject(); // { name: "my-site", branch: "main" }
|
|
6584
|
+
*/
|
|
6585
|
+
async createProject() {
|
|
6586
|
+
const name = toSlug(ctx.require(sitePlugin).name());
|
|
6587
|
+
const branch = ctx.config.productionBranch ?? "main";
|
|
6588
|
+
const { scrubbedStderr } = await executeWrangler(ctx, buildProjectCreateArgs({
|
|
6589
|
+
slug: name,
|
|
6590
|
+
branch
|
|
6591
|
+
}));
|
|
6592
|
+
ctx.log.info(scrubbedStderr);
|
|
6593
|
+
return {
|
|
6594
|
+
name,
|
|
6595
|
+
branch
|
|
6596
|
+
};
|
|
6354
6597
|
}
|
|
6355
6598
|
};
|
|
6356
6599
|
}
|
|
@@ -6462,13 +6705,14 @@ const deployPlugin = createPlugin$1("deploy", {
|
|
|
6462
6705
|
//#endregion
|
|
6463
6706
|
//#region src/plugins/cli/deploy-wizard.ts
|
|
6464
6707
|
/**
|
|
6465
|
-
* @file cli plugin — the guided deploy wizard (`cli.deploy({ guided: true })
|
|
6466
|
-
*
|
|
6467
|
-
* Cloudflare
|
|
6468
|
-
*
|
|
6469
|
-
*
|
|
6470
|
-
*
|
|
6471
|
-
*
|
|
6708
|
+
* @file cli plugin — the guided deploy wizard (`cli.deploy({ guided: true })`, the default
|
|
6709
|
+
* for `bun run deploy`; the direct `--cli` path stays in `api.ts`). Walks a human through a
|
|
6710
|
+
* Cloudflare Pages deploy: checks prerequisites (wrangler config + the Cloudflare
|
|
6711
|
+
* credentials) with concrete fix guidance, offers to scaffold what is missing (a
|
|
6712
|
+
* `wrangler.jsonc`, and a placeholder `.env` for any missing credentials), HARD-GATES the
|
|
6713
|
+
* deploy on everything being green, runs a local build smoke test, confirms, deploys, then
|
|
6714
|
+
* offers to scaffold a GitHub Actions workflow (auto on push to main, or a versioned/manual
|
|
6715
|
+
* trigger). Every prompt + line of output flows through injectable `state` seams.
|
|
6472
6716
|
*/
|
|
6473
6717
|
/** How to create a Cloudflare API token + where to make it available locally. */
|
|
6474
6718
|
const TOKEN_HELP = [
|
|
@@ -6483,21 +6727,56 @@ const ACCOUNT_HELP = [
|
|
|
6483
6727
|
"right-hand sidebar (also in the dashboard URL). Then make it available:",
|
|
6484
6728
|
" export CLOUDFLARE_ACCOUNT_ID=… or add it to .env."
|
|
6485
6729
|
].join("\n");
|
|
6730
|
+
/** Shown when a credential is in the raw environment but the app's env providers did not resolve it. */
|
|
6731
|
+
const PROVIDERS_HELP = [
|
|
6732
|
+
"Found in your shell/.env but the app's env plugin did not resolve it — its providers",
|
|
6733
|
+
"are not wired. Add the Node providers in createApp so the deploy can read it:",
|
|
6734
|
+
" pluginConfigs.env = { providers: [processEnv(), dotenv()] } (import them from @moku-labs/web)."
|
|
6735
|
+
].join("\n");
|
|
6486
6736
|
/** The GitHub repo secrets the generated workflow consumes. */
|
|
6487
6737
|
const SECRETS_HELP = ["Add these repo secrets (GitHub → Settings → Secrets and variables → Actions):", "CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID"].join("\n");
|
|
6488
6738
|
/**
|
|
6739
|
+
* Build one credential prerequisite by reading the SAME source the deploy reads — the
|
|
6740
|
+
* resolved `ctx.env` table — so a ✓ guarantees `ctx.env.require(key)` will succeed. When
|
|
6741
|
+
* the value is present in the raw `process.env` but unresolved by the app's providers
|
|
6742
|
+
* (the silent "deploy can't see it" trap a bare `process.env` check would mark green),
|
|
6743
|
+
* the fix hint points at wiring the providers instead of re-adding the value.
|
|
6744
|
+
*
|
|
6745
|
+
* @param ctx - The cli plugin context (provides the resolved `ctx.env`).
|
|
6746
|
+
* @param key - The credential variable name.
|
|
6747
|
+
* @param label - The diagnostic line label.
|
|
6748
|
+
* @param missingHelp - The fix hint when the credential is genuinely absent everywhere.
|
|
6749
|
+
* @returns The credential prerequisite check.
|
|
6750
|
+
* @example
|
|
6751
|
+
* credentialPrereq(ctx, "CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_TOKEN is set", TOKEN_HELP);
|
|
6752
|
+
*/
|
|
6753
|
+
function credentialPrereq(ctx, key, label, missingHelp) {
|
|
6754
|
+
if ((ctx.env.get(key) ?? "") !== "") return {
|
|
6755
|
+
ok: true,
|
|
6756
|
+
label,
|
|
6757
|
+
detail: void 0,
|
|
6758
|
+
scaffoldable: false
|
|
6759
|
+
};
|
|
6760
|
+
return {
|
|
6761
|
+
ok: false,
|
|
6762
|
+
label,
|
|
6763
|
+
detail: (process.env[key] ?? "") !== "" ? PROVIDERS_HELP : missingHelp,
|
|
6764
|
+
scaffoldable: false
|
|
6765
|
+
};
|
|
6766
|
+
}
|
|
6767
|
+
/**
|
|
6489
6768
|
* Evaluate the three deploy prerequisites against the current project: the Cloudflare
|
|
6490
|
-
* wrangler config exists, and both Cloudflare credentials
|
|
6769
|
+
* wrangler config exists, and both Cloudflare credentials resolve through `ctx.env` (the
|
|
6770
|
+
* deploy's own source of truth — not a bare `process.env` read that can diverge from it).
|
|
6491
6771
|
*
|
|
6772
|
+
* @param ctx - The cli plugin context (provides the resolved `ctx.env`).
|
|
6492
6773
|
* @param cwd - The project root (where `wrangler.jsonc` lives).
|
|
6493
6774
|
* @returns The ordered prerequisite checks.
|
|
6494
6775
|
* @example
|
|
6495
|
-
* const prereqs = diagnose(process.cwd());
|
|
6776
|
+
* const prereqs = diagnose(ctx, process.cwd());
|
|
6496
6777
|
*/
|
|
6497
|
-
function diagnose(cwd) {
|
|
6778
|
+
function diagnose(ctx, cwd) {
|
|
6498
6779
|
const wranglerOk = (0, node_fs.existsSync)(node_path$1.default.join(cwd, "wrangler.jsonc"));
|
|
6499
|
-
const tokenOk = (process.env.CLOUDFLARE_API_TOKEN ?? "") !== "";
|
|
6500
|
-
const accountOk = (process.env.CLOUDFLARE_ACCOUNT_ID ?? "") !== "";
|
|
6501
6780
|
return [
|
|
6502
6781
|
{
|
|
6503
6782
|
ok: wranglerOk,
|
|
@@ -6505,18 +6784,8 @@ function diagnose(cwd) {
|
|
|
6505
6784
|
detail: wranglerOk ? void 0 : "Missing — scaffold it (offered below) or run app.deploy.init().",
|
|
6506
6785
|
scaffoldable: true
|
|
6507
6786
|
},
|
|
6508
|
-
|
|
6509
|
-
|
|
6510
|
-
label: "CLOUDFLARE_API_TOKEN is set",
|
|
6511
|
-
detail: tokenOk ? void 0 : TOKEN_HELP,
|
|
6512
|
-
scaffoldable: false
|
|
6513
|
-
},
|
|
6514
|
-
{
|
|
6515
|
-
ok: accountOk,
|
|
6516
|
-
label: "CLOUDFLARE_ACCOUNT_ID is set",
|
|
6517
|
-
detail: accountOk ? void 0 : ACCOUNT_HELP,
|
|
6518
|
-
scaffoldable: false
|
|
6519
|
-
}
|
|
6787
|
+
credentialPrereq(ctx, "CLOUDFLARE_API_TOKEN", "CLOUDFLARE_API_TOKEN is set", TOKEN_HELP),
|
|
6788
|
+
credentialPrereq(ctx, "CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_ACCOUNT_ID is set", ACCOUNT_HELP)
|
|
6520
6789
|
];
|
|
6521
6790
|
}
|
|
6522
6791
|
/**
|
|
@@ -6535,6 +6804,43 @@ async function offerScaffold(ctx, prereqs) {
|
|
|
6535
6804
|
await ctx.require(deployPlugin).init({});
|
|
6536
6805
|
ctx.state.render.check(true, "wrangler.jsonc scaffolded");
|
|
6537
6806
|
}
|
|
6807
|
+
/** The Cloudflare credentials the deploy needs, with the comment written above each in a scaffolded `.env`. */
|
|
6808
|
+
const ENV_CREDENTIALS = [{
|
|
6809
|
+
key: "CLOUDFLARE_API_TOKEN",
|
|
6810
|
+
comment: "# Cloudflare API token — https://dash.cloudflare.com/profile/api-tokens (template: Cloudflare Pages — Edit)"
|
|
6811
|
+
}, {
|
|
6812
|
+
key: "CLOUDFLARE_ACCOUNT_ID",
|
|
6813
|
+
comment: "# Cloudflare account id — dashboard → Workers & Pages → right-hand sidebar"
|
|
6814
|
+
}];
|
|
6815
|
+
/**
|
|
6816
|
+
* Offer to scaffold a `.env` with placeholders for whichever Cloudflare credentials are
|
|
6817
|
+
* missing — created when absent, appended to (never clobbering a key already present)
|
|
6818
|
+
* when it exists. The placeholders are empty, so the deploy still hard-gates until the
|
|
6819
|
+
* user fills them in; this just removes the "where do I even put these?" friction.
|
|
6820
|
+
*
|
|
6821
|
+
* @param ctx - The cli plugin context.
|
|
6822
|
+
* @param cwd - The project root (where `.env` lives).
|
|
6823
|
+
* @returns Resolves once any accepted scaffold has been written.
|
|
6824
|
+
* @example
|
|
6825
|
+
* await offerEnvScaffold(ctx, process.cwd());
|
|
6826
|
+
*/
|
|
6827
|
+
async function offerEnvScaffold(ctx, cwd) {
|
|
6828
|
+
const missing = ENV_CREDENTIALS.filter(({ key }) => (process.env[key] ?? "") === "");
|
|
6829
|
+
if (missing.length === 0) return;
|
|
6830
|
+
const envPath = node_path$1.default.join(cwd, ".env");
|
|
6831
|
+
const exists = (0, node_fs.existsSync)(envPath);
|
|
6832
|
+
const verb = exists ? "Add placeholders for the missing secret(s) to" : "Create";
|
|
6833
|
+
if (!await ctx.state.confirm(`${verb} .env?`)) return;
|
|
6834
|
+
const lines = exists ? (0, node_fs.readFileSync)(envPath, "utf8").split(/\r?\n/) : [];
|
|
6835
|
+
const toAdd = missing.filter(({ key }) => !lines.some((line) => line.trimStart().startsWith(`${key}=`)));
|
|
6836
|
+
if (toAdd.length === 0) {
|
|
6837
|
+
ctx.state.render.info(".env already lists those keys — fill in their values, then re-run.");
|
|
6838
|
+
return;
|
|
6839
|
+
}
|
|
6840
|
+
(0, node_fs.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`);
|
|
6841
|
+
const names = toAdd.map(({ key }) => key).join(", ");
|
|
6842
|
+
ctx.state.render.check(true, `${exists ? "added placeholders to" : "created"} .env`, `fill in ${names}, then re-run \`bun run deploy\`.`);
|
|
6843
|
+
}
|
|
6538
6844
|
/**
|
|
6539
6845
|
* Map a top-level workflow choice (and, for the versioned option, a sub-choice) to the
|
|
6540
6846
|
* concrete {@link WorkflowTrigger}, or `null` when the user chose to skip setup.
|
|
@@ -6577,8 +6883,139 @@ async function offerWorkflowSetup(ctx) {
|
|
|
6577
6883
|
ctx.state.render.info(SECRETS_HELP);
|
|
6578
6884
|
}
|
|
6579
6885
|
/**
|
|
6580
|
-
*
|
|
6581
|
-
*
|
|
6886
|
+
* Read the taxonomy `code` off a thrown value, when present. Deploy errors carry a
|
|
6887
|
+
* `code` (e.g. `ERR_DEPLOY_PROJECT_NOT_FOUND`) so the wizard can tailor the fix hint.
|
|
6888
|
+
*
|
|
6889
|
+
* @param error - The thrown value.
|
|
6890
|
+
* @returns The `code` string, or `undefined` when absent.
|
|
6891
|
+
* @example
|
|
6892
|
+
* codeOf(deployError("ERR_DEPLOY_AUTH", "…")); // "ERR_DEPLOY_AUTH"
|
|
6893
|
+
*/
|
|
6894
|
+
function codeOf(error) {
|
|
6895
|
+
if (typeof error === "object" && error !== null && "code" in error) {
|
|
6896
|
+
const { code } = error;
|
|
6897
|
+
return typeof code === "string" ? code : void 0;
|
|
6898
|
+
}
|
|
6899
|
+
}
|
|
6900
|
+
/**
|
|
6901
|
+
* A copy-pasteable "create the project yourself" hint, shown when the user declines the
|
|
6902
|
+
* offer to auto-create. Spells out that the remote project is what's missing (init only
|
|
6903
|
+
* scaffolds local config).
|
|
6904
|
+
*
|
|
6905
|
+
* @param name - The Cloudflare Pages project name (the deploy slug).
|
|
6906
|
+
* @returns The multi-line hint (newline-separated; rendered indented under a `›`).
|
|
6907
|
+
* @example
|
|
6908
|
+
* ctx.state.render.info(projectNotFoundHint("my-site"));
|
|
6909
|
+
*/
|
|
6910
|
+
function projectNotFoundHint(name) {
|
|
6911
|
+
return [
|
|
6912
|
+
"how to fix: the Cloudflare Pages project does not exist yet — create it once, then",
|
|
6913
|
+
"re-run `bun run deploy`. (app.deploy.init() only scaffolds local config; it does not",
|
|
6914
|
+
"create the remote project.)",
|
|
6915
|
+
` • CLI: bunx wrangler pages project create ${name} --production-branch main`,
|
|
6916
|
+
" • Dashboard: Cloudflare → Workers & Pages → Create → Pages"
|
|
6917
|
+
].join("\n");
|
|
6918
|
+
}
|
|
6919
|
+
/**
|
|
6920
|
+
* An actionable, error-specific "how to fix" hint for a failed deploy (other than the
|
|
6921
|
+
* project-not-found case, which the wizard handles interactively), so the user never
|
|
6922
|
+
* lands on a raw stack trace.
|
|
6923
|
+
*
|
|
6924
|
+
* @param error - The thrown deploy error.
|
|
6925
|
+
* @returns The fix hint line.
|
|
6926
|
+
* @example
|
|
6927
|
+
* ctx.state.render.info(deployFailureHint(err));
|
|
6928
|
+
*/
|
|
6929
|
+
function deployFailureHint$1(error) {
|
|
6930
|
+
const code = codeOf(error);
|
|
6931
|
+
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`.";
|
|
6932
|
+
if (code === "ERR_DEPLOY_NETWORK") return "how to fix: a network error reached Cloudflare — check connectivity, then re-run `bun run deploy`.";
|
|
6933
|
+
return "how to fix: resolve the error above, then re-run `bun run deploy`.";
|
|
6934
|
+
}
|
|
6935
|
+
/**
|
|
6936
|
+
* Render a styled deploy failure (✗ + fix hint) and return the `"failed"` outcome, so a
|
|
6937
|
+
* caught error surfaces consistently instead of as a raw throw.
|
|
6938
|
+
*
|
|
6939
|
+
* @param ctx - The cli plugin context.
|
|
6940
|
+
* @param error - The thrown deploy error.
|
|
6941
|
+
* @returns The `"failed"` deploy outcome.
|
|
6942
|
+
* @example
|
|
6943
|
+
* return renderFailure(ctx, error);
|
|
6944
|
+
*/
|
|
6945
|
+
function renderFailure(ctx, error) {
|
|
6946
|
+
ctx.state.render.error("deploy failed", error);
|
|
6947
|
+
ctx.state.render.info(deployFailureHint$1(error));
|
|
6948
|
+
return {
|
|
6949
|
+
deployed: false,
|
|
6950
|
+
reason: "failed"
|
|
6951
|
+
};
|
|
6952
|
+
}
|
|
6953
|
+
/**
|
|
6954
|
+
* Deploy once via the deploy plugin and wrap the result as a successful outcome. Throws
|
|
6955
|
+
* the classified deploy error on failure (the caller decides how to surface it).
|
|
6956
|
+
*
|
|
6957
|
+
* @param ctx - The cli plugin context.
|
|
6958
|
+
* @param options - The deploy options (branch override).
|
|
6959
|
+
* @returns The successful deploy outcome.
|
|
6960
|
+
* @throws {Error} With a `code` from the deploy error taxonomy on any failure.
|
|
6961
|
+
* @example
|
|
6962
|
+
* const outcome = await deployOnce(ctx, { branch: "main" });
|
|
6963
|
+
*/
|
|
6964
|
+
async function deployOnce(ctx, options) {
|
|
6965
|
+
return {
|
|
6966
|
+
deployed: true,
|
|
6967
|
+
...await ctx.require(deployPlugin).run(options.branch === void 0 ? {} : { branch: options.branch })
|
|
6968
|
+
};
|
|
6969
|
+
}
|
|
6970
|
+
/**
|
|
6971
|
+
* Handle a project-not-found deploy failure interactively: ask (a confirmation step)
|
|
6972
|
+
* before creating a real Cloudflare resource, create the Pages project via the deploy
|
|
6973
|
+
* plugin, then retry the deploy once. A declined offer (or a create failure) returns the
|
|
6974
|
+
* `"failed"` outcome with an actionable hint — never a raw stack trace.
|
|
6975
|
+
*
|
|
6976
|
+
* @param ctx - The cli plugin context.
|
|
6977
|
+
* @param options - The deploy options (branch override).
|
|
6978
|
+
* @param originalError - The project-not-found error from the first attempt.
|
|
6979
|
+
* @returns The deploy outcome (deployed after a successful create + retry, else failed).
|
|
6980
|
+
* @example
|
|
6981
|
+
* return createProjectThenRetry(ctx, options, error);
|
|
6982
|
+
*/
|
|
6983
|
+
async function createProjectThenRetry(ctx, options, originalError) {
|
|
6984
|
+
const deploy = ctx.require(deployPlugin);
|
|
6985
|
+
const name = deploy.projectName();
|
|
6986
|
+
ctx.state.render.warn(`The Cloudflare Pages project "${name}" does not exist yet.`);
|
|
6987
|
+
if (!await ctx.state.confirm(`Create the Cloudflare Pages project "${name}" now?`)) {
|
|
6988
|
+
ctx.state.render.error("deploy failed", originalError);
|
|
6989
|
+
ctx.state.render.info(projectNotFoundHint(name));
|
|
6990
|
+
return {
|
|
6991
|
+
deployed: false,
|
|
6992
|
+
reason: "failed"
|
|
6993
|
+
};
|
|
6994
|
+
}
|
|
6995
|
+
try {
|
|
6996
|
+
const created = await deploy.createProject();
|
|
6997
|
+
ctx.state.render.check(true, `created Cloudflare Pages project "${created.name}"`);
|
|
6998
|
+
} catch (error) {
|
|
6999
|
+
ctx.state.render.error("could not create the Pages project", error);
|
|
7000
|
+
ctx.state.render.info(deployFailureHint$1(error));
|
|
7001
|
+
return {
|
|
7002
|
+
deployed: false,
|
|
7003
|
+
reason: "failed"
|
|
7004
|
+
};
|
|
7005
|
+
}
|
|
7006
|
+
ctx.state.render.info("project created — retrying the deploy…");
|
|
7007
|
+
try {
|
|
7008
|
+
return await deployOnce(ctx, options);
|
|
7009
|
+
} catch (error) {
|
|
7010
|
+
return renderFailure(ctx, error);
|
|
7011
|
+
}
|
|
7012
|
+
}
|
|
7013
|
+
/**
|
|
7014
|
+
* Run the deploy step: confirm (unless `yes`), then deploy via the deploy plugin. A
|
|
7015
|
+
* declined confirm returns `{ deployed: false, reason: "declined" }`. A project-not-found
|
|
7016
|
+
* failure offers to create the project (with a confirmation step) and retries; any other
|
|
7017
|
+
* runtime failure is surfaced as a styled error + fix hint, returning
|
|
7018
|
+
* `{ deployed: false, reason: "failed" }` — never a raw stack trace.
|
|
6582
7019
|
*
|
|
6583
7020
|
* @param ctx - The cli plugin context.
|
|
6584
7021
|
* @param options - The deploy options (branch override + `yes`).
|
|
@@ -6595,10 +7032,12 @@ async function runDeployStep(ctx, options) {
|
|
|
6595
7032
|
reason: "declined"
|
|
6596
7033
|
};
|
|
6597
7034
|
}
|
|
6598
|
-
|
|
6599
|
-
|
|
6600
|
-
|
|
6601
|
-
|
|
7035
|
+
try {
|
|
7036
|
+
return await deployOnce(ctx, options);
|
|
7037
|
+
} catch (error) {
|
|
7038
|
+
if (codeOf(error) === "ERR_DEPLOY_PROJECT_NOT_FOUND") return createProjectThenRetry(ctx, options, error);
|
|
7039
|
+
return renderFailure(ctx, error);
|
|
7040
|
+
}
|
|
6602
7041
|
}
|
|
6603
7042
|
/**
|
|
6604
7043
|
* Run the guided deploy wizard end to end: diagnose prerequisites (offering to scaffold
|
|
@@ -6616,9 +7055,10 @@ async function runDeployStep(ctx, options) {
|
|
|
6616
7055
|
async function runDeployWizard(ctx, options) {
|
|
6617
7056
|
const cwd = process.cwd();
|
|
6618
7057
|
ctx.state.render.heading("Checking prerequisites");
|
|
6619
|
-
for (const item of diagnose(cwd)) ctx.state.render.check(item.ok, item.label, item.detail);
|
|
6620
|
-
await offerScaffold(ctx, diagnose(cwd));
|
|
6621
|
-
|
|
7058
|
+
for (const item of diagnose(ctx, cwd)) ctx.state.render.check(item.ok, item.label, item.detail);
|
|
7059
|
+
await offerScaffold(ctx, diagnose(ctx, cwd));
|
|
7060
|
+
await offerEnvScaffold(ctx, cwd);
|
|
7061
|
+
const blockers = diagnose(ctx, cwd).filter((item) => !item.ok);
|
|
6622
7062
|
if (blockers.length > 0) {
|
|
6623
7063
|
ctx.state.render.heading("Not ready to deploy");
|
|
6624
7064
|
for (const item of blockers) ctx.state.render.check(false, item.label, item.detail);
|
|
@@ -6635,7 +7075,7 @@ async function runDeployWizard(ctx, options) {
|
|
|
6635
7075
|
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).");
|
|
6636
7076
|
ctx.state.render.info("Tip: run `bun run preview` to eyeball the built site before deploying.");
|
|
6637
7077
|
const outcome = await runDeployStep(ctx, options);
|
|
6638
|
-
await offerWorkflowSetup(ctx);
|
|
7078
|
+
if (!(outcome.deployed === false && outcome.reason === "failed")) await offerWorkflowSetup(ctx);
|
|
6639
7079
|
return outcome;
|
|
6640
7080
|
}
|
|
6641
7081
|
//#endregion
|
|
@@ -6675,25 +7115,26 @@ function injectReloadClient(html) {
|
|
|
6675
7115
|
* Run one rebuild and report the result. Announces the start (`onRebuildStart`), then
|
|
6676
7116
|
* routes success to `onReloaded` and failure to `onError`.
|
|
6677
7117
|
*
|
|
6678
|
-
* @param input - The rebuild dependencies + the changed file.
|
|
6679
|
-
* @param input.runBuild - Runs one build and resolves with its summary.
|
|
7118
|
+
* @param input - The rebuild dependencies + the changed file/paths.
|
|
7119
|
+
* @param input.runBuild - Runs one build (given the changed paths) and resolves with its summary.
|
|
6680
7120
|
* @param input.onRebuildStart - Called with the changed file just before the build runs.
|
|
6681
|
-
* @param input.onReloaded - Called with the changed file + summary after a rebuild.
|
|
7121
|
+
* @param input.onReloaded - Called with the changed file + summary + the built `changed` set after a rebuild.
|
|
6682
7122
|
* @param input.onError - Called when a rebuild throws.
|
|
6683
7123
|
* @param input.file - The changed file to report alongside the summary.
|
|
7124
|
+
* @param input.changed - The accumulated changed paths handed to `runBuild` (incremental).
|
|
6684
7125
|
* @returns Resolves once the rebuild settles (always — errors are routed, not thrown).
|
|
6685
7126
|
* @example
|
|
6686
|
-
* await runOneRebuild({ runBuild, onReloaded, onError, file: "a.md" });
|
|
7127
|
+
* await runOneRebuild({ runBuild, onReloaded, onError, file: "a.md", changed: ["a.md"] });
|
|
6687
7128
|
*/
|
|
6688
7129
|
async function runOneRebuild(input) {
|
|
6689
7130
|
input.onRebuildStart?.(input.file);
|
|
6690
7131
|
try {
|
|
6691
|
-
const summary = await input.runBuild();
|
|
7132
|
+
const summary = await input.runBuild(input.changed);
|
|
6692
7133
|
input.onReloaded({
|
|
6693
7134
|
file: input.file,
|
|
6694
7135
|
pageCount: summary.pageCount,
|
|
6695
7136
|
durationMs: summary.durationMs
|
|
6696
|
-
});
|
|
7137
|
+
}, input.changed);
|
|
6697
7138
|
} catch (error) {
|
|
6698
7139
|
input.onError(error);
|
|
6699
7140
|
}
|
|
@@ -6707,9 +7148,9 @@ async function runOneRebuild(input) {
|
|
|
6707
7148
|
*
|
|
6708
7149
|
* @param input - The rebuild dependencies.
|
|
6709
7150
|
* @param input.debounceMs - Debounce window in milliseconds.
|
|
6710
|
-
* @param input.runBuild - Runs one build and resolves with its summary.
|
|
7151
|
+
* @param input.runBuild - Runs one build (given the changed paths) and resolves with its summary.
|
|
6711
7152
|
* @param input.onRebuildStart - Called with the changed file just before each build runs.
|
|
6712
|
-
* @param input.onReloaded - Called with the changed file + summary after a rebuild.
|
|
7153
|
+
* @param input.onReloaded - Called with the changed file + summary + the built `changed` set after a rebuild.
|
|
6713
7154
|
* @param input.onError - Called when a rebuild throws.
|
|
6714
7155
|
* @returns The debounced rebuild driver.
|
|
6715
7156
|
* @example
|
|
@@ -6718,12 +7159,13 @@ async function runOneRebuild(input) {
|
|
|
6718
7159
|
function createRebuilder(input) {
|
|
6719
7160
|
let timer;
|
|
6720
7161
|
let pendingFile = "";
|
|
7162
|
+
const pendingChanged = /* @__PURE__ */ new Set();
|
|
6721
7163
|
let building = false;
|
|
6722
7164
|
let dirty = false;
|
|
6723
7165
|
/**
|
|
6724
|
-
* Rebuild repeatedly until no change arrived mid-flight: each pass clears
|
|
6725
|
-
* runs one build, then loops again if a
|
|
6726
|
-
* no change is dropped.
|
|
7166
|
+
* Rebuild repeatedly until no change arrived mid-flight: each pass snapshots + clears
|
|
7167
|
+
* the accumulated changed paths, runs one build over them, then loops again if a
|
|
7168
|
+
* `schedule()` set `dirty` (and added more paths) while it ran, so no change is dropped.
|
|
6727
7169
|
*
|
|
6728
7170
|
* @returns Resolves once a pass completes with no pending change (errors are routed,
|
|
6729
7171
|
* never thrown).
|
|
@@ -6733,12 +7175,15 @@ function createRebuilder(input) {
|
|
|
6733
7175
|
const drainPendingRebuilds = async () => {
|
|
6734
7176
|
do {
|
|
6735
7177
|
dirty = false;
|
|
7178
|
+
const changed = [...pendingChanged];
|
|
7179
|
+
pendingChanged.clear();
|
|
6736
7180
|
await runOneRebuild({
|
|
6737
7181
|
runBuild: input.runBuild,
|
|
6738
7182
|
...input.onRebuildStart ? { onRebuildStart: input.onRebuildStart } : {},
|
|
6739
7183
|
onReloaded: input.onReloaded,
|
|
6740
7184
|
onError: input.onError,
|
|
6741
|
-
file: pendingFile
|
|
7185
|
+
file: pendingFile,
|
|
7186
|
+
changed
|
|
6742
7187
|
});
|
|
6743
7188
|
} while (dirty);
|
|
6744
7189
|
};
|
|
@@ -6765,14 +7210,15 @@ function createRebuilder(input) {
|
|
|
6765
7210
|
};
|
|
6766
7211
|
return {
|
|
6767
7212
|
/**
|
|
6768
|
-
* Queue a rebuild for the given
|
|
7213
|
+
* Queue a rebuild for the given changed path (debounced + coalesced + accumulated).
|
|
6769
7214
|
*
|
|
6770
|
-
* @param file - The
|
|
7215
|
+
* @param file - The changed path reported as `ReloadInfo.file` and added to the changed set.
|
|
6771
7216
|
* @example
|
|
6772
|
-
* rebuilder.schedule("content");
|
|
7217
|
+
* rebuilder.schedule("content/intro/en.md");
|
|
6773
7218
|
*/
|
|
6774
7219
|
schedule(file) {
|
|
6775
7220
|
pendingFile = file;
|
|
7221
|
+
pendingChanged.add(file);
|
|
6776
7222
|
if (timer) clearTimeout(timer);
|
|
6777
7223
|
timer = setTimeout(fire, input.debounceMs);
|
|
6778
7224
|
},
|
|
@@ -6802,26 +7248,33 @@ function isNoisePath(filename) {
|
|
|
6802
7248
|
return filename.split(/[/\\]/).some((segment) => segment.startsWith(".")) || filename.endsWith("~");
|
|
6803
7249
|
}
|
|
6804
7250
|
/**
|
|
6805
|
-
* Create a {@link ChangeGate} that drops
|
|
6806
|
-
*
|
|
6807
|
-
* `outDir` (the build's own output — a loop guard),
|
|
6808
|
-
*
|
|
6809
|
-
*
|
|
6810
|
-
*
|
|
6811
|
-
*
|
|
6812
|
-
*
|
|
7251
|
+
* Create a {@link ChangeGate} that drops four kinds of spurious change events before they
|
|
7252
|
+
* reach the debounced rebuilder: editor/OS noise (dotfiles, backups), writes under
|
|
7253
|
+
* `outDir` (the build's own output — a loop guard), the stale duplicate/parent-dir echoes
|
|
7254
|
+
* macOS fires for one save (a build-start high-water mark — a change whose mtime is at or
|
|
7255
|
+
* before the last build we started was already captured), and — when a `fileHash` seam is
|
|
7256
|
+
* supplied — a NO-OP save whose bytes are identical to the last successfully-built version
|
|
7257
|
+
* (a double Ctrl-S, a `touch`, a format-on-save that reverts). The no-op baseline is
|
|
7258
|
+
* recorded ONLY by {@link ChangeGate.commitBuilt} on build SUCCESS, scoped to that build's
|
|
7259
|
+
* paths — so a failed build commits nothing (a retry save always rebuilds) and a file
|
|
7260
|
+
* edited mid-build is never falsely baselined by another file's success. A genuinely newer
|
|
7261
|
+
* edit (even mid-build) and a deletion (missing file) always pass.
|
|
6813
7262
|
*
|
|
6814
7263
|
* @param input - The gate dependencies.
|
|
6815
7264
|
* @param input.outDir - The build output directory whose writes must never re-trigger a build.
|
|
6816
7265
|
* @param input.fileMtime - Resolves a path's mtime in ms (or `null` when missing).
|
|
6817
7266
|
* @param input.now - Monotonic wall clock (ms) used for the build-start high-water mark.
|
|
7267
|
+
* @param input.fileHash - Resolves a path's content hash (or `null` when missing). Optional;
|
|
7268
|
+
* defaults to `() => null`, which disables the no-op-save short-circuit (every edit passes).
|
|
6818
7269
|
* @returns The change gate.
|
|
6819
7270
|
* @example
|
|
6820
|
-
* const gate = createChangeGate({ outDir: "dist", fileMtime: state.fileMtime, now: state.clock });
|
|
7271
|
+
* const gate = createChangeGate({ outDir: "dist", fileMtime: state.fileMtime, now: state.clock, fileHash: state.fileHash });
|
|
6821
7272
|
*/
|
|
6822
7273
|
function createChangeGate(input) {
|
|
6823
7274
|
const outDirAbs = node_path$1.default.resolve(input.outDir);
|
|
7275
|
+
const fileHash = input.fileHash ?? (() => null);
|
|
6824
7276
|
let lastBuildStartedAt = input.now();
|
|
7277
|
+
const committedHash = /* @__PURE__ */ new Map();
|
|
6825
7278
|
return {
|
|
6826
7279
|
/**
|
|
6827
7280
|
* Decide whether a change beneath `dir` warrants a rebuild (see {@link ChangeGate.accept}).
|
|
@@ -6839,6 +7292,12 @@ function createChangeGate(input) {
|
|
|
6839
7292
|
if (changed === outDirAbs || changed.startsWith(`${outDirAbs}${node_path$1.default.sep}`)) return false;
|
|
6840
7293
|
const mtime = input.fileMtime(changed);
|
|
6841
7294
|
if (mtime !== null && mtime < lastBuildStartedAt) return false;
|
|
7295
|
+
const hash = fileHash(changed);
|
|
7296
|
+
if (hash === null) {
|
|
7297
|
+
committedHash.delete(changed);
|
|
7298
|
+
return true;
|
|
7299
|
+
}
|
|
7300
|
+
if (committedHash.get(changed) === hash) return false;
|
|
6842
7301
|
return true;
|
|
6843
7302
|
},
|
|
6844
7303
|
/**
|
|
@@ -6849,6 +7308,20 @@ function createChangeGate(input) {
|
|
|
6849
7308
|
*/
|
|
6850
7309
|
markBuildStart() {
|
|
6851
7310
|
lastBuildStartedAt = input.now();
|
|
7311
|
+
},
|
|
7312
|
+
/**
|
|
7313
|
+
* Baseline exactly the paths the just-succeeded build consumed (see {@link ChangeGate.commitBuilt}).
|
|
7314
|
+
*
|
|
7315
|
+
* @param changed - The paths the just-succeeded build consumed.
|
|
7316
|
+
* @example
|
|
7317
|
+
* gate.commitBuilt(["content/intro/en.md"]);
|
|
7318
|
+
*/
|
|
7319
|
+
commitBuilt(changed) {
|
|
7320
|
+
for (const file of changed) {
|
|
7321
|
+
const key = node_path$1.default.resolve(file);
|
|
7322
|
+
const hash = fileHash(key);
|
|
7323
|
+
if (hash !== null) committedHash.set(key, hash);
|
|
7324
|
+
}
|
|
6852
7325
|
}
|
|
6853
7326
|
};
|
|
6854
7327
|
}
|
|
@@ -7041,19 +7514,46 @@ function createDevHandler(ctx, hub) {
|
|
|
7041
7514
|
};
|
|
7042
7515
|
}
|
|
7043
7516
|
/**
|
|
7517
|
+
* Build the per-run {@link BuildRunOverrides} for a dev build from the session feature
|
|
7518
|
+
* opt-ins: minification is always off in dev (no benefit, slower), and each expensive
|
|
7519
|
+
* output stays off unless its flag re-enables it (`ogImage: false` disables OG generation
|
|
7520
|
+
* regardless of the persisted config). The persisted plugin config is never mutated — the
|
|
7521
|
+
* overrides apply to the dev run only.
|
|
7522
|
+
*
|
|
7523
|
+
* @param features - The resolved per-session dev feature opt-ins.
|
|
7524
|
+
* @returns The config overrides merged into the dev build run.
|
|
7525
|
+
* @example
|
|
7526
|
+
* devBuildOverrides({ og: false, sitemap: false, feeds: false, localeRedirects: false });
|
|
7527
|
+
*/
|
|
7528
|
+
function devBuildOverrides(features) {
|
|
7529
|
+
return {
|
|
7530
|
+
minify: false,
|
|
7531
|
+
...features.feeds ? {} : { feeds: false },
|
|
7532
|
+
...features.sitemap ? {} : { sitemap: false },
|
|
7533
|
+
...features.og ? {} : { ogImage: false },
|
|
7534
|
+
...features.localeRedirects ? {} : { localeRedirects: false }
|
|
7535
|
+
};
|
|
7536
|
+
}
|
|
7537
|
+
/**
|
|
7044
7538
|
* Run the dev loop: an initial build, an in-process static server that injects the
|
|
7045
7539
|
* live-reload client, a recursive watcher over `config.watchDirs`, and a debounced
|
|
7046
7540
|
* rebuild that re-renders and pushes a browser reload. Resolves on SIGINT/SIGTERM,
|
|
7047
|
-
* which stops the server, closes the watchers, and cancels any pending rebuild.
|
|
7541
|
+
* which stops the server, closes the watchers, and cancels any pending rebuild. The dev
|
|
7542
|
+
* build disables minification + expensive outputs (per {@link devBuildOverrides}); every
|
|
7543
|
+
* rebuild also skips the clean so caches + unchanged assets survive (no mid-rebuild 404).
|
|
7544
|
+
* Because rebuilds skip the clean, a DELETED or renamed content slug's stale page lingers
|
|
7545
|
+
* (and is served) until you restart `serve` or run a production `build`.
|
|
7048
7546
|
*
|
|
7049
7547
|
* @param ctx - The cli plugin context (config, state seams, `require`).
|
|
7050
7548
|
* @param port - The port to bind the dev server to.
|
|
7549
|
+
* @param features - Per-session dev feature opt-ins (`og`/`sitemap`/`feeds`/`localeRedirects`).
|
|
7051
7550
|
* @returns Resolves once the server has been torn down by a termination signal.
|
|
7052
7551
|
* @example
|
|
7053
|
-
* await runDevServer(ctx, 4173);
|
|
7552
|
+
* await runDevServer(ctx, 4173, { og: false, sitemap: false, feeds: false, localeRedirects: false });
|
|
7054
7553
|
*/
|
|
7055
|
-
async function runDevServer(ctx, port) {
|
|
7056
|
-
|
|
7554
|
+
async function runDevServer(ctx, port, features) {
|
|
7555
|
+
const overrides = devBuildOverrides(features);
|
|
7556
|
+
await ctx.require(buildPlugin).run({ overrides });
|
|
7057
7557
|
const hub = createReloadHub();
|
|
7058
7558
|
const server = ctx.state.serveStatic({
|
|
7059
7559
|
port,
|
|
@@ -7063,19 +7563,27 @@ async function runDevServer(ctx, port) {
|
|
|
7063
7563
|
const gate = createChangeGate({
|
|
7064
7564
|
outDir: ctx.config.outDir,
|
|
7065
7565
|
fileMtime: ctx.state.fileMtime,
|
|
7066
|
-
now: ctx.state.clock
|
|
7566
|
+
now: ctx.state.clock,
|
|
7567
|
+
fileHash: ctx.state.fileHash
|
|
7067
7568
|
});
|
|
7068
7569
|
const rebuilder = createRebuilder({
|
|
7069
7570
|
debounceMs: ctx.config.debounceMs,
|
|
7070
7571
|
/**
|
|
7071
|
-
* Re-run the SSG build for a rebuild
|
|
7572
|
+
* Re-run the SSG build for a rebuild: skip the clean so the prior assets + on-disk
|
|
7573
|
+
* caches survive (and no in-flight request hits an empty outDir), with the dev
|
|
7574
|
+
* overrides applied.
|
|
7072
7575
|
*
|
|
7576
|
+
* @param changed - The paths changed since the last build (incremental rebuild hint).
|
|
7073
7577
|
* @returns The rebuild summary.
|
|
7074
7578
|
* @example
|
|
7075
|
-
* await runBuild();
|
|
7579
|
+
* await runBuild(["content/intro/en.md"]);
|
|
7076
7580
|
*/
|
|
7077
|
-
runBuild() {
|
|
7078
|
-
return ctx.require(buildPlugin).run(
|
|
7581
|
+
runBuild(changed) {
|
|
7582
|
+
return ctx.require(buildPlugin).run({
|
|
7583
|
+
skipClean: true,
|
|
7584
|
+
overrides,
|
|
7585
|
+
changed
|
|
7586
|
+
});
|
|
7079
7587
|
},
|
|
7080
7588
|
/**
|
|
7081
7589
|
* Show the compact in-place "rebuilding {label}" line before the build runs.
|
|
@@ -7092,15 +7600,18 @@ async function runDevServer(ctx, port) {
|
|
|
7092
7600
|
* Render the reload line and push a browser reload after a rebuild.
|
|
7093
7601
|
*
|
|
7094
7602
|
* @param info - The changed file plus the rebuild's page count and duration.
|
|
7603
|
+
* @param changed - The paths this successful build consumed (baselined for no-op drops).
|
|
7095
7604
|
* @example
|
|
7096
|
-
* onReloaded({ file: "a.md", pageCount: 1, durationMs: 10 });
|
|
7605
|
+
* onReloaded({ file: "a.md", pageCount: 1, durationMs: 10 }, ["content/a.md"]);
|
|
7097
7606
|
*/
|
|
7098
|
-
onReloaded(info) {
|
|
7607
|
+
onReloaded(info, changed) {
|
|
7608
|
+
gate.commitBuilt(changed);
|
|
7099
7609
|
ctx.state.render.reload(info);
|
|
7100
7610
|
hub.reloadAll();
|
|
7101
7611
|
},
|
|
7102
7612
|
/**
|
|
7103
|
-
* Render a rebuild failure (the dev loop keeps running).
|
|
7613
|
+
* Render a rebuild failure (the dev loop keeps running). A failed build baselines
|
|
7614
|
+
* nothing (commitBuilt only runs on success), so an identical retry save still rebuilds.
|
|
7104
7615
|
*
|
|
7105
7616
|
* @param error - The thrown rebuild error.
|
|
7106
7617
|
* @example
|
|
@@ -7111,7 +7622,8 @@ async function runDevServer(ctx, port) {
|
|
|
7111
7622
|
}
|
|
7112
7623
|
});
|
|
7113
7624
|
const watchers = ctx.config.watchDirs.map((dir) => ctx.state.watch(dir, (filename) => {
|
|
7114
|
-
if (gate.accept(dir, filename))
|
|
7625
|
+
if (!gate.accept(dir, filename)) return;
|
|
7626
|
+
rebuilder.schedule(filename === void 0 ? dir : node_path$1.default.join(dir, filename));
|
|
7115
7627
|
}));
|
|
7116
7628
|
ctx.state.render.serverReady({
|
|
7117
7629
|
local: `http://localhost:${port}`,
|
|
@@ -7123,6 +7635,7 @@ async function runDevServer(ctx, port) {
|
|
|
7123
7635
|
for (const watcher of watchers) watcher.close();
|
|
7124
7636
|
hub.close();
|
|
7125
7637
|
server.stop();
|
|
7638
|
+
ctx.state.render.dispose();
|
|
7126
7639
|
});
|
|
7127
7640
|
}
|
|
7128
7641
|
//#endregion
|
|
@@ -7323,6 +7836,28 @@ async function confirmDeploy(ctx, yes) {
|
|
|
7323
7836
|
if (!confirmed) ctx.state.render.warn("deploy skipped");
|
|
7324
7837
|
return confirmed;
|
|
7325
7838
|
}
|
|
7839
|
+
/** Matches the prerequisite/credential failures a direct deploy most often hits (missing token/account). */
|
|
7840
|
+
const PREREQUISITE_ERROR = /required variable|not defined|cloudflare|token|account|unauthor|wrangler/i;
|
|
7841
|
+
/**
|
|
7842
|
+
* A short, actionable "how to fix" hint for a failed deploy, rendered under the error so
|
|
7843
|
+
* the user is never left at a raw stack trace. A missing-credential/prerequisite failure
|
|
7844
|
+
* (the common case for a first direct deploy) gets the concrete secret-setup steps;
|
|
7845
|
+
* anything else points at the guided deploy, which diagnoses prerequisites step by step.
|
|
7846
|
+
*
|
|
7847
|
+
* @param error - The thrown deploy error.
|
|
7848
|
+
* @returns The multi-line hint (newline-separated; rendered indented under a `›`).
|
|
7849
|
+
* @example
|
|
7850
|
+
* render.info(deployFailureHint(err));
|
|
7851
|
+
*/
|
|
7852
|
+
function deployFailureHint(error) {
|
|
7853
|
+
if (PREREQUISITE_ERROR.test(String(error))) return [
|
|
7854
|
+
"how to fix:",
|
|
7855
|
+
"1. run `bun run deploy` (without `--cli`) — the guided setup diagnoses prerequisites and offers to create a starter .env",
|
|
7856
|
+
"2. or set them yourself in .env: CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID",
|
|
7857
|
+
" token: https://dash.cloudflare.com/profile/api-tokens (template: Cloudflare Pages — Edit)"
|
|
7858
|
+
].join("\n");
|
|
7859
|
+
return "how to fix: run `bun run deploy` (without `--cli`) — the guided setup diagnoses prerequisites — then retry";
|
|
7860
|
+
}
|
|
7326
7861
|
/**
|
|
7327
7862
|
* Create the cli plugin API surface — exactly `build`, `serve`, `preview`, `deploy`.
|
|
7328
7863
|
* Each method renders `state.render.header(<command>)` first, then does its work;
|
|
@@ -7355,17 +7890,25 @@ function createApi$1(ctx) {
|
|
|
7355
7890
|
},
|
|
7356
7891
|
/**
|
|
7357
7892
|
* Dev loop: build once, serve `dist/` in-process (live-reload injected), watch
|
|
7358
|
-
* `watchDirs`, debounced rebuild + reload.
|
|
7893
|
+
* `watchDirs`, debounced + incremental rebuild + reload. For a fast rebuild the dev
|
|
7894
|
+
* build disables minification + expensive, preview-irrelevant outputs (feeds /
|
|
7895
|
+
* sitemap / og-images / locale-redirects); pass `og`/`sitemap`/`feeds`/
|
|
7896
|
+
* `localeRedirects` to re-enable any of them for the session. Resolves on SIGINT/SIGTERM.
|
|
7359
7897
|
*
|
|
7360
|
-
* @param options - Optional port override
|
|
7898
|
+
* @param options - Optional port override + per-session dev feature opt-ins.
|
|
7361
7899
|
* @returns Resolves once the server has been torn down.
|
|
7362
7900
|
* @example
|
|
7363
|
-
* await api.serve({ port: 3000 });
|
|
7901
|
+
* await api.serve({ port: 3000, og: true });
|
|
7364
7902
|
*/
|
|
7365
7903
|
serve(options = {}) {
|
|
7366
7904
|
const { port = ctx.config.port } = options;
|
|
7367
7905
|
ctx.state.render.header("serve");
|
|
7368
|
-
return runDevServer(ctx, port
|
|
7906
|
+
return runDevServer(ctx, port, {
|
|
7907
|
+
og: options.og ?? false,
|
|
7908
|
+
sitemap: options.sitemap ?? false,
|
|
7909
|
+
feeds: options.feeds ?? false,
|
|
7910
|
+
localeRedirects: options.localeRedirects ?? false
|
|
7911
|
+
});
|
|
7369
7912
|
},
|
|
7370
7913
|
/**
|
|
7371
7914
|
* Static preview of the built `dist/` with CF-Pages clean-URL resolution.
|
|
@@ -7397,15 +7940,21 @@ function createApi$1(ctx) {
|
|
|
7397
7940
|
const { branch, yes = false } = options;
|
|
7398
7941
|
ctx.state.render.header("deploy");
|
|
7399
7942
|
if (options.guided === true) return runDeployWizard(ctx, options);
|
|
7400
|
-
|
|
7401
|
-
|
|
7402
|
-
|
|
7403
|
-
|
|
7404
|
-
|
|
7405
|
-
|
|
7406
|
-
|
|
7407
|
-
|
|
7408
|
-
|
|
7943
|
+
try {
|
|
7944
|
+
await ctx.require(deployPlugin).init({ ci: true });
|
|
7945
|
+
if (!await confirmDeploy(ctx, yes)) return {
|
|
7946
|
+
deployed: false,
|
|
7947
|
+
reason: "declined"
|
|
7948
|
+
};
|
|
7949
|
+
return {
|
|
7950
|
+
deployed: true,
|
|
7951
|
+
...await ctx.require(deployPlugin).run(branch === void 0 ? {} : { branch })
|
|
7952
|
+
};
|
|
7953
|
+
} catch (error) {
|
|
7954
|
+
ctx.state.render.error("deploy failed", error);
|
|
7955
|
+
ctx.state.render.info(deployFailureHint(error));
|
|
7956
|
+
throw error;
|
|
7957
|
+
}
|
|
7409
7958
|
}
|
|
7410
7959
|
};
|
|
7411
7960
|
}
|
|
@@ -7496,6 +8045,28 @@ const ANSI = {
|
|
|
7496
8045
|
cyan: `${ESC}[36m`,
|
|
7497
8046
|
gray: `${ESC}[90m`
|
|
7498
8047
|
};
|
|
8048
|
+
/**
|
|
8049
|
+
* The Moku brand pink (`#FF1E6F`) as an RGB triple, used for 24-bit truecolor output.
|
|
8050
|
+
* Degrades to {@link ANSI.magenta} on a 16-color TTY and to plain text off a TTY.
|
|
8051
|
+
*/
|
|
8052
|
+
const BRAND_PINK = {
|
|
8053
|
+
r: 255,
|
|
8054
|
+
g: 30,
|
|
8055
|
+
b: 111
|
|
8056
|
+
};
|
|
8057
|
+
/**
|
|
8058
|
+
* Build a 24-bit (truecolor) SGR foreground escape for the given RGB triple.
|
|
8059
|
+
*
|
|
8060
|
+
* @param r - Red channel (0–255).
|
|
8061
|
+
* @param g - Green channel (0–255).
|
|
8062
|
+
* @param b - Blue channel (0–255).
|
|
8063
|
+
* @returns The `ESC[38;2;r;g;bm` foreground sequence.
|
|
8064
|
+
* @example
|
|
8065
|
+
* fg24(255, 30, 111); // "\x1b[38;2;255;30;111m"
|
|
8066
|
+
*/
|
|
8067
|
+
function fg24(r, g, b) {
|
|
8068
|
+
return `${ESC}[38;2;${r};${g};${b}m`;
|
|
8069
|
+
}
|
|
7499
8070
|
/** ANSI: erase the entire current line, leaving the cursor where it is. */
|
|
7500
8071
|
const CLEAR_LINE = `${ESC}[2K`;
|
|
7501
8072
|
/** ANSI: erase from the cursor to the end of the screen (drops stale trailing rows). */
|
|
@@ -7567,6 +8138,36 @@ function supportsColor(stream = process.stdout, noColor = process.env.NO_COLOR)
|
|
|
7567
8138
|
return stream.isTTY === true && noColor === void 0;
|
|
7568
8139
|
}
|
|
7569
8140
|
/**
|
|
8141
|
+
* Whether the terminal advertises 24-bit (truecolor) support via `COLORTERM`, so the
|
|
8142
|
+
* renderer may emit the exact brand pink ({@link BRAND_PINK}) instead of the 16-color
|
|
8143
|
+
* `magenta` approximation. Always layered on top of {@link supportsColor} — truecolor
|
|
8144
|
+
* is never used when color itself is disabled.
|
|
8145
|
+
*
|
|
8146
|
+
* @param colorTerm - The `COLORTERM` value (defaults to `process.env.COLORTERM`).
|
|
8147
|
+
* @returns `true` when `COLORTERM` is `truecolor` or `24bit`.
|
|
8148
|
+
* @example
|
|
8149
|
+
* supportsTruecolor("truecolor"); // true
|
|
8150
|
+
*/
|
|
8151
|
+
function supportsTruecolor(colorTerm = process.env.COLORTERM) {
|
|
8152
|
+
return colorTerm === "truecolor" || colorTerm === "24bit";
|
|
8153
|
+
}
|
|
8154
|
+
/**
|
|
8155
|
+
* The braille spinner glyph for a given elapsed time, advancing one frame per
|
|
8156
|
+
* `frameMs`. Deriving the frame from wall-clock elapsed (rather than a tick counter)
|
|
8157
|
+
* keeps the spinner correct even when the animation ticker is briefly starved by a
|
|
8158
|
+
* synchronous build phase and several ticks coalesce — the glyph still reflects real
|
|
8159
|
+
* elapsed time instead of freezing on a stale frame.
|
|
8160
|
+
*
|
|
8161
|
+
* @param elapsedMs - Milliseconds since the live region opened.
|
|
8162
|
+
* @param frameMs - Milliseconds per frame (defaults to `80`).
|
|
8163
|
+
* @returns The active spinner glyph.
|
|
8164
|
+
* @example
|
|
8165
|
+
* spinnerFrameAt(240); // "⠹" (the 4th frame at 80ms/frame)
|
|
8166
|
+
*/
|
|
8167
|
+
function spinnerFrameAt(elapsedMs, frameMs = 80) {
|
|
8168
|
+
return SPINNER_FRAMES[Math.floor(Math.max(0, elapsedMs) / frameMs) % SPINNER_FRAMES.length] ?? "⠋";
|
|
8169
|
+
}
|
|
8170
|
+
/**
|
|
7570
8171
|
* Select the box glyph set for the given color mode (Unicode on a TTY, ASCII off it).
|
|
7571
8172
|
*
|
|
7572
8173
|
* @param color - Whether color/Unicode output is enabled.
|
|
@@ -7594,12 +8195,14 @@ function visibleWidth(text) {
|
|
|
7594
8195
|
* output in CI/pipes.
|
|
7595
8196
|
*
|
|
7596
8197
|
* @param color - Whether color is enabled (typically `supportsColor()`).
|
|
8198
|
+
* @param truecolor - Whether 24-bit output is enabled (typically `supportsTruecolor()`);
|
|
8199
|
+
* only consulted by {@link Palette.pink}. Defaults to `false` (16-color magenta).
|
|
7597
8200
|
* @returns The bound color palette.
|
|
7598
8201
|
* @example
|
|
7599
|
-
* const palette = makePalette(supportsColor());
|
|
8202
|
+
* const palette = makePalette(supportsColor(), supportsTruecolor());
|
|
7600
8203
|
* const line = palette.green("done");
|
|
7601
8204
|
*/
|
|
7602
|
-
function makePalette(color) {
|
|
8205
|
+
function makePalette(color, truecolor = false) {
|
|
7603
8206
|
return {
|
|
7604
8207
|
enabled: color,
|
|
7605
8208
|
/**
|
|
@@ -7679,23 +8282,39 @@ function makePalette(color) {
|
|
|
7679
8282
|
*/
|
|
7680
8283
|
cyan(text) {
|
|
7681
8284
|
return this.paint(ANSI.cyan, text);
|
|
8285
|
+
},
|
|
8286
|
+
/**
|
|
8287
|
+
* Color the given text the Moku brand pink: exact `#FF1E6F` (24-bit) when truecolor
|
|
8288
|
+
* is enabled, the 16-color `magenta` approximation otherwise, unchanged in plain mode.
|
|
8289
|
+
*
|
|
8290
|
+
* @param text - The text to colorize.
|
|
8291
|
+
* @returns The pink (or unchanged) text.
|
|
8292
|
+
* @example
|
|
8293
|
+
* palette.pink("▟▙ moku web");
|
|
8294
|
+
*/
|
|
8295
|
+
pink(text) {
|
|
8296
|
+
if (!color) return text;
|
|
8297
|
+
if (truecolor) return `${fg24(BRAND_PINK.r, BRAND_PINK.g, BRAND_PINK.b)}${text}${ANSI.reset}`;
|
|
8298
|
+
return this.paint(ANSI.magenta, text);
|
|
7682
8299
|
}
|
|
7683
8300
|
};
|
|
7684
8301
|
}
|
|
7685
8302
|
/**
|
|
7686
8303
|
* Frame a list of already-rendered content lines in a box, padding each line to the
|
|
7687
|
-
*
|
|
7688
|
-
*
|
|
8304
|
+
* widest visible line (or `minInnerWidth`, whichever is larger — so several boxes can be
|
|
8305
|
+
* forced to a shared width). Uses Unicode borders when `color` is enabled and ASCII
|
|
8306
|
+
* otherwise. Visible width ignores embedded ANSI so colored lines align.
|
|
7689
8307
|
*
|
|
7690
8308
|
* @param lines - The content lines (may contain ANSI color codes).
|
|
7691
8309
|
* @param color - Whether to use Unicode borders (and assume color-capable output).
|
|
8310
|
+
* @param minInnerWidth - Minimum inner (content) width to pad every row to. Defaults to `0`.
|
|
7692
8311
|
* @returns The boxed lines (top border, content rows, bottom border).
|
|
7693
8312
|
* @example
|
|
7694
|
-
* box(["Local: http://localhost:4173"], true);
|
|
8313
|
+
* box(["Local: http://localhost:4173"], true, 62);
|
|
7695
8314
|
*/
|
|
7696
|
-
function box(lines, color) {
|
|
8315
|
+
function box(lines, color, minInnerWidth = 0) {
|
|
7697
8316
|
const glyphs = boxGlyphs(color);
|
|
7698
|
-
const inner = Math.max(0, ...lines.map((line) => visibleWidth(line)));
|
|
8317
|
+
const inner = Math.max(0, minInnerWidth, ...lines.map((line) => visibleWidth(line)));
|
|
7699
8318
|
const horizontal = glyphs.horizontal.repeat(inner + 2);
|
|
7700
8319
|
const top = `${glyphs.topLeft}${horizontal}${glyphs.topRight}`;
|
|
7701
8320
|
const bottom = `${glyphs.bottomLeft}${horizontal}${glyphs.bottomRight}`;
|
|
@@ -7710,13 +8329,70 @@ function box(lines, color) {
|
|
|
7710
8329
|
}
|
|
7711
8330
|
//#endregion
|
|
7712
8331
|
//#region src/plugins/cli/render/panel.ts
|
|
7713
|
-
/** Per-command label shown
|
|
8332
|
+
/** Per-command label shown beside the lockup wordmark. */
|
|
7714
8333
|
const COMMAND_LABEL = {
|
|
7715
8334
|
build: "build",
|
|
7716
8335
|
serve: "serve · dev",
|
|
7717
8336
|
preview: "preview",
|
|
7718
8337
|
deploy: "deploy"
|
|
7719
8338
|
};
|
|
8339
|
+
/** Total visible width the header rule spans and the per-row timing column right-aligns to. */
|
|
8340
|
+
const RAIL_WIDTH = 66;
|
|
8341
|
+
/** Animation repaint cadence (ms) — how often the live region is redrawn when the loop is free. */
|
|
8342
|
+
const TICK_MS = 40;
|
|
8343
|
+
/** Spinner frame interval (ms) — one braille glyph advance per this many elapsed ms. */
|
|
8344
|
+
const SPIN_MS = 60;
|
|
8345
|
+
/** Inner (content) width of the BUILD/server boxes so their right edge lines up with the phase tree. */
|
|
8346
|
+
const BOX_INNER = RAIL_WIDTH - 4;
|
|
8347
|
+
/** The eight block glyphs the per-phase time-profile sparkline maps durations onto. */
|
|
8348
|
+
const SPARK_BARS = "▁▂▃▄▅▆▇█";
|
|
8349
|
+
/**
|
|
8350
|
+
* Build a sparkline from a list of values — one block glyph per value, height scaled to
|
|
8351
|
+
* the largest value so the tallest bar is `█`. A real micro-histogram (no fake data):
|
|
8352
|
+
* under the BUILD summary each bar is one phase's duration, so the slowest phase stands
|
|
8353
|
+
* out at a glance. Returns `""` for an empty list.
|
|
8354
|
+
*
|
|
8355
|
+
* @param values - The values to plot (e.g. per-phase durations in ms).
|
|
8356
|
+
* @returns The sparkline string.
|
|
8357
|
+
* @example
|
|
8358
|
+
* sparkline([12, 1701, 19698, 9]); // "▁▁█▁"
|
|
8359
|
+
*/
|
|
8360
|
+
function sparkline(values) {
|
|
8361
|
+
if (values.length === 0) return "";
|
|
8362
|
+
const max = Math.max(...values, 1);
|
|
8363
|
+
return values.map((value) => {
|
|
8364
|
+
return SPARK_BARS[Math.min(7, Math.floor(value / max * 7))] ?? SPARK_BARS[0];
|
|
8365
|
+
}).join("");
|
|
8366
|
+
}
|
|
8367
|
+
/**
|
|
8368
|
+
* The structural glyph set for the active color mode: Unicode on a color-capable TTY,
|
|
8369
|
+
* ASCII fallbacks off it. Only the NEW Velocity chrome (cube, rule, tree, bar, live
|
|
8370
|
+
* dot) degrades here — the `✓ ✗ ~ ➜ ›` status marks stay as-is in both modes.
|
|
8371
|
+
*
|
|
8372
|
+
* @param color - Whether color/Unicode output is enabled.
|
|
8373
|
+
* @returns The matching glyph set.
|
|
8374
|
+
* @example
|
|
8375
|
+
* const g = glyphSet(true);
|
|
8376
|
+
*/
|
|
8377
|
+
function glyphSet(color) {
|
|
8378
|
+
return color ? {
|
|
8379
|
+
cube: "▟▙",
|
|
8380
|
+
rule: "─",
|
|
8381
|
+
tree: "├─",
|
|
8382
|
+
barFill: "━",
|
|
8383
|
+
barTrack: "╴",
|
|
8384
|
+
liveOn: "◍",
|
|
8385
|
+
liveOff: "○"
|
|
8386
|
+
} : {
|
|
8387
|
+
cube: "*",
|
|
8388
|
+
rule: "-",
|
|
8389
|
+
tree: "-",
|
|
8390
|
+
barFill: "#",
|
|
8391
|
+
barTrack: "-",
|
|
8392
|
+
liveOn: "*",
|
|
8393
|
+
liveOff: "*"
|
|
8394
|
+
};
|
|
8395
|
+
}
|
|
7720
8396
|
/**
|
|
7721
8397
|
* Render one human-readable duration suffix (e.g. `· 84ms`).
|
|
7722
8398
|
*
|
|
@@ -7731,15 +8407,49 @@ function durationSuffix(palette, durationMs) {
|
|
|
7731
8407
|
return ` ${palette.dim(`· ${durationMs}ms`)}`;
|
|
7732
8408
|
}
|
|
7733
8409
|
/**
|
|
8410
|
+
* Right-align `right` against `left` within {@link RAIL_WIDTH}, measuring visible width
|
|
8411
|
+
* so embedded ANSI never throws the timing column off.
|
|
8412
|
+
*
|
|
8413
|
+
* @param left - The left segment (may contain ANSI).
|
|
8414
|
+
* @param right - The right segment (may contain ANSI).
|
|
8415
|
+
* @param width - Total visible width to fill (defaults to {@link RAIL_WIDTH}).
|
|
8416
|
+
* @returns The padded line.
|
|
8417
|
+
* @example
|
|
8418
|
+
* railLine(" ├─ ✓ pages", "· 12ms");
|
|
8419
|
+
*/
|
|
8420
|
+
function railLine(left, right, width = RAIL_WIDTH) {
|
|
8421
|
+
const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right));
|
|
8422
|
+
return `${left}${" ".repeat(gap)}${right}`;
|
|
8423
|
+
}
|
|
8424
|
+
/**
|
|
8425
|
+
* The runtime facts line shown under the banner: the pinned core version (when known)
|
|
8426
|
+
* plus the live Node/Bun versions + platform — the ACTUAL running runtime, not the
|
|
8427
|
+
* `engines` floor. Every value is real (read from `@moku-labs/core`'s pinned dependency
|
|
8428
|
+
* and `process.versions`), so nothing on this line is faked.
|
|
8429
|
+
*
|
|
8430
|
+
* @param coreVersion - The pinned `@moku-labs/core` version (appended last — it rarely
|
|
8431
|
+
* matters — and omitted entirely when unknown).
|
|
8432
|
+
* @returns The facts string (e.g. `node 24.3.0 · bun 1.3.9 · darwin arm64 · core 0.1.0-alpha.6`).
|
|
8433
|
+
* @example
|
|
8434
|
+
* runtimeFacts("0.1.0-alpha.6");
|
|
8435
|
+
*/
|
|
8436
|
+
function runtimeFacts(coreVersion) {
|
|
8437
|
+
const node = `node ${process.versions.node}`;
|
|
8438
|
+
const bun = process.versions.bun ? ` · bun ${process.versions.bun}` : "";
|
|
8439
|
+
const core = coreVersion ? ` · core ${coreVersion}` : "";
|
|
8440
|
+
return `${node}${bun} · ${process.platform} ${process.arch}${core}`;
|
|
8441
|
+
}
|
|
8442
|
+
/**
|
|
7734
8443
|
* Create the Panel {@link CliRenderer}. Output is written through the injected sink
|
|
7735
|
-
* (default `console.log`/`console.error`) and colorized only when color is enabled,
|
|
7736
|
-
*
|
|
7737
|
-
* ASCII lines in CI/pipes.
|
|
8444
|
+
* (default `console.log`/`console.error`) and colorized only when color is enabled, so
|
|
8445
|
+
* the identical render path yields the animated, box-free Velocity UI on a TTY and
|
|
8446
|
+
* plain ASCII lines in CI/pipes.
|
|
7738
8447
|
*
|
|
7739
|
-
* @param options - Optional sinks
|
|
8448
|
+
* @param options - Optional sinks, color/truecolor overrides, clock, and version (see
|
|
8449
|
+
* {@link PanelOptions}).
|
|
7740
8450
|
* @returns The renderer mounted on `state.render` and driven by the API + hooks.
|
|
7741
8451
|
* @example
|
|
7742
|
-
* const render = createPanelRenderer();
|
|
8452
|
+
* const render = createPanelRenderer({ version: "0.1.0-alpha" });
|
|
7743
8453
|
* render.header("build");
|
|
7744
8454
|
*/
|
|
7745
8455
|
function createPanelRenderer(options = {}) {
|
|
@@ -7750,26 +8460,24 @@ function createPanelRenderer(options = {}) {
|
|
|
7750
8460
|
});
|
|
7751
8461
|
const now = options.now ?? Date.now;
|
|
7752
8462
|
const color = options.color ?? supportsColor();
|
|
7753
|
-
const palette = makePalette(color);
|
|
8463
|
+
const palette = makePalette(color, options.truecolor ?? (color && supportsTruecolor()));
|
|
8464
|
+
const version = options.version ?? "dev";
|
|
8465
|
+
const coreVersion = options.coreVersion;
|
|
8466
|
+
const g = glyphSet(color);
|
|
7754
8467
|
let phaseRows = [];
|
|
7755
8468
|
let phaseDrawn = 0;
|
|
7756
8469
|
let phaseOpen = false;
|
|
8470
|
+
let blockStartedAt = 0;
|
|
7757
8471
|
let rebuilding = false;
|
|
7758
8472
|
let rebuildLabel = "";
|
|
7759
8473
|
let rebuildStartedAt = 0;
|
|
7760
|
-
let
|
|
8474
|
+
let idle = false;
|
|
8475
|
+
let idleStartedAt = 0;
|
|
8476
|
+
let serveMode = false;
|
|
7761
8477
|
let ticker;
|
|
7762
8478
|
/**
|
|
7763
|
-
*
|
|
7764
|
-
*
|
|
7765
|
-
* @returns The active braille spinner frame.
|
|
7766
|
-
* @example
|
|
7767
|
-
* frameGlyph(); // "⠙"
|
|
7768
|
-
*/
|
|
7769
|
-
const frameGlyph = () => SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length] ?? "⠋";
|
|
7770
|
-
/**
|
|
7771
|
-
* Render one phase row: a green `✓ name · time` when done, else a spinning cyan glyph
|
|
7772
|
-
* before the dim name.
|
|
8479
|
+
* Render one phase-tree row: a spinning cyan glyph + dim name while running, or a green
|
|
8480
|
+
* `✓` + name with the duration right-aligned in the dim timing column once done.
|
|
7773
8481
|
*
|
|
7774
8482
|
* @param row - The phase row to render.
|
|
7775
8483
|
* @returns The rendered row line (no trailing newline).
|
|
@@ -7777,12 +8485,34 @@ function createPanelRenderer(options = {}) {
|
|
|
7777
8485
|
* renderPhaseRow({ name: "pages", done: true, durationMs: 12 });
|
|
7778
8486
|
*/
|
|
7779
8487
|
const renderPhaseRow = (row) => {
|
|
7780
|
-
|
|
7781
|
-
return ` ${palette.
|
|
8488
|
+
const branch = palette.dim(g.tree);
|
|
8489
|
+
if (row.done) return railLine(` ${branch} ${palette.green("✓")} ${row.name}`, palette.dim(`· ${row.durationMs}ms`));
|
|
8490
|
+
return ` ${branch} ${palette.cyan(spinnerFrameAt(now() - blockStartedAt, SPIN_MS))} ${palette.dim(row.name)}`;
|
|
7782
8491
|
};
|
|
7783
8492
|
/**
|
|
7784
|
-
*
|
|
7785
|
-
*
|
|
8493
|
+
* Render the indeterminate "comet" build bar — a short pink fill window sweeping across
|
|
8494
|
+
* a dim track — for the given elapsed time. Animated purely from wall-clock elapsed so
|
|
8495
|
+
* it never needs a known phase total.
|
|
8496
|
+
*
|
|
8497
|
+
* @param elapsedMs - Milliseconds since the phase block opened.
|
|
8498
|
+
* @returns The rendered bar row (no trailing newline).
|
|
8499
|
+
* @example
|
|
8500
|
+
* renderBuildBar(300);
|
|
8501
|
+
*/
|
|
8502
|
+
const renderBuildBar = (elapsedMs) => {
|
|
8503
|
+
const length = 28;
|
|
8504
|
+
const window = 6;
|
|
8505
|
+
const head = Math.floor(elapsedMs / 28) % 34;
|
|
8506
|
+
let bar = "";
|
|
8507
|
+
for (let index = 0; index < length; index++) {
|
|
8508
|
+
const lit = index <= head && index > head - window;
|
|
8509
|
+
bar += lit ? palette.pink(g.barFill) : palette.dim(g.barTrack);
|
|
8510
|
+
}
|
|
8511
|
+
return ` ${bar}`;
|
|
8512
|
+
};
|
|
8513
|
+
/**
|
|
8514
|
+
* Repaint the live phase block in place (tree rows + animated build bar): move up over
|
|
8515
|
+
* the prior draw, rewrite each row, then the bar, clearing any stale trailing lines.
|
|
7786
8516
|
*
|
|
7787
8517
|
* @example
|
|
7788
8518
|
* paintPhaseBlock();
|
|
@@ -7790,8 +8520,9 @@ function createPanelRenderer(options = {}) {
|
|
|
7790
8520
|
const paintPhaseBlock = () => {
|
|
7791
8521
|
let frame = cursorUp(phaseDrawn);
|
|
7792
8522
|
for (const row of phaseRows) frame += `${CLEAR_LINE}${renderPhaseRow(row)}\n`;
|
|
8523
|
+
frame += `${CLEAR_LINE}${renderBuildBar(now() - blockStartedAt)}\n`;
|
|
7793
8524
|
writeRaw(frame + CLEAR_BELOW);
|
|
7794
|
-
phaseDrawn = phaseRows.length;
|
|
8525
|
+
phaseDrawn = phaseRows.length + 1;
|
|
7795
8526
|
};
|
|
7796
8527
|
/**
|
|
7797
8528
|
* Repaint the single in-place rebuild line (spinner + label + live elapsed seconds).
|
|
@@ -7800,20 +8531,31 @@ function createPanelRenderer(options = {}) {
|
|
|
7800
8531
|
* paintRebuildLine();
|
|
7801
8532
|
*/
|
|
7802
8533
|
const paintRebuildLine = () => {
|
|
7803
|
-
const
|
|
7804
|
-
const
|
|
7805
|
-
writeRaw(`\r${CLEAR_LINE} ${
|
|
8534
|
+
const spinner = palette.cyan(spinnerFrameAt(now() - rebuildStartedAt, SPIN_MS));
|
|
8535
|
+
const elapsed = palette.dim(`· ${((now() - rebuildStartedAt) / 1e3).toFixed(1)}s`);
|
|
8536
|
+
writeRaw(`\r${CLEAR_LINE} ${spinner} rebuilding ${rebuildLabel} ${elapsed}`);
|
|
7806
8537
|
};
|
|
7807
8538
|
/**
|
|
7808
|
-
*
|
|
8539
|
+
* Repaint the persistent in-place `◍ live` idle pulse beneath the serve panel — the
|
|
8540
|
+
* dot breathes (pink → dim) on a calm ~0.6s cycle so a quiet dev session always reads
|
|
8541
|
+
* as alive without strobing.
|
|
8542
|
+
*
|
|
8543
|
+
* @example
|
|
8544
|
+
* paintIdleLine();
|
|
8545
|
+
*/
|
|
8546
|
+
const paintIdleLine = () => {
|
|
8547
|
+
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…")}`);
|
|
8548
|
+
};
|
|
8549
|
+
/**
|
|
8550
|
+
* Advance whichever live region is active by one frame (driven by the shared ticker).
|
|
7809
8551
|
*
|
|
7810
8552
|
* @example
|
|
7811
8553
|
* onTick();
|
|
7812
8554
|
*/
|
|
7813
8555
|
const onTick = () => {
|
|
7814
|
-
spinnerFrame += 1;
|
|
7815
8556
|
if (rebuilding) paintRebuildLine();
|
|
7816
8557
|
else if (phaseOpen) paintPhaseBlock();
|
|
8558
|
+
else if (idle) paintIdleLine();
|
|
7817
8559
|
};
|
|
7818
8560
|
/**
|
|
7819
8561
|
* Start the animation ticker (TTY only; idempotent; `unref`'d so it never blocks exit).
|
|
@@ -7823,7 +8565,7 @@ function createPanelRenderer(options = {}) {
|
|
|
7823
8565
|
*/
|
|
7824
8566
|
const startTicker = () => {
|
|
7825
8567
|
if (!color || ticker) return;
|
|
7826
|
-
ticker = setInterval(onTick,
|
|
8568
|
+
ticker = setInterval(onTick, TICK_MS);
|
|
7827
8569
|
ticker.unref?.();
|
|
7828
8570
|
};
|
|
7829
8571
|
/**
|
|
@@ -7846,22 +8588,47 @@ function createPanelRenderer(options = {}) {
|
|
|
7846
8588
|
const writeBlock = (lines) => {
|
|
7847
8589
|
for (const line of lines) write(line);
|
|
7848
8590
|
};
|
|
8591
|
+
/**
|
|
8592
|
+
* Resume the serve idle pulse on a fresh bottom line (TTY serve sessions only). A no-op
|
|
8593
|
+
* outside serve so standalone rebuild/error calls in unit tests never leave a ticker
|
|
8594
|
+
* running.
|
|
8595
|
+
*
|
|
8596
|
+
* @example
|
|
8597
|
+
* resumeIdle();
|
|
8598
|
+
*/
|
|
8599
|
+
const resumeIdle = () => {
|
|
8600
|
+
if (!(color && serveMode)) {
|
|
8601
|
+
stopTicker();
|
|
8602
|
+
return;
|
|
8603
|
+
}
|
|
8604
|
+
idle = true;
|
|
8605
|
+
idleStartedAt = now();
|
|
8606
|
+
paintIdleLine();
|
|
8607
|
+
startTicker();
|
|
8608
|
+
};
|
|
7849
8609
|
return {
|
|
7850
8610
|
/**
|
|
7851
|
-
* Render the
|
|
8611
|
+
* Render the `▟▙ moku web` lockup + per-command label, a dim rule, and the runtime
|
|
8612
|
+
* facts line (live Node/Bun versions + platform). Called once per command (one
|
|
8613
|
+
* command = one process), so it never repeats within a run.
|
|
7852
8614
|
*
|
|
7853
|
-
* @param command - The command being run, shown beside the
|
|
8615
|
+
* @param command - The command being run, shown beside the wordmark.
|
|
7854
8616
|
* @example
|
|
7855
8617
|
* render.header("serve");
|
|
7856
8618
|
*/
|
|
7857
8619
|
header(command) {
|
|
7858
|
-
writeBlock(
|
|
8620
|
+
writeBlock([
|
|
8621
|
+
railLine(` ${palette.pink(g.cube)} ${palette.pink(palette.bold("moku web"))} ${palette.dim(COMMAND_LABEL[command])}`, palette.dim(version)),
|
|
8622
|
+
` ${palette.dim(g.rule.repeat(RAIL_WIDTH - 1))}`,
|
|
8623
|
+
` ${palette.dim(runtimeFacts(coreVersion))}`
|
|
8624
|
+
]);
|
|
7859
8625
|
},
|
|
7860
8626
|
/**
|
|
7861
|
-
* Render a per-phase row from a `build:phase` event. On a TTY each phase is ONE
|
|
7862
|
-
* that updates in place (spinning glyph while running → green ✓ + duration
|
|
7863
|
-
*
|
|
7864
|
-
* no-op while a serve()
|
|
8627
|
+
* Render a live per-phase row from a `build:phase` event. On a TTY each phase is ONE
|
|
8628
|
+
* tree row that updates in place (spinning glyph while running → green ✓ + duration
|
|
8629
|
+
* when done) beneath an animated indeterminate build bar; off a TTY one line is
|
|
8630
|
+
* printed per completed phase (no start/done duplication). A no-op while a serve()
|
|
8631
|
+
* rebuild is in flight — those show the compact rebuild line.
|
|
7865
8632
|
*
|
|
7866
8633
|
* @param phase - The `build:phase` payload.
|
|
7867
8634
|
* @example
|
|
@@ -7877,6 +8644,7 @@ function createPanelRenderer(options = {}) {
|
|
|
7877
8644
|
phaseRows = [];
|
|
7878
8645
|
phaseDrawn = 0;
|
|
7879
8646
|
phaseOpen = true;
|
|
8647
|
+
blockStartedAt = now();
|
|
7880
8648
|
}
|
|
7881
8649
|
const done = phase.status === "done";
|
|
7882
8650
|
const existing = phaseRows.find((row) => row.name === phase.phase);
|
|
@@ -7892,7 +8660,9 @@ function createPanelRenderer(options = {}) {
|
|
|
7892
8660
|
startTicker();
|
|
7893
8661
|
},
|
|
7894
8662
|
/**
|
|
7895
|
-
* Render the BUILD summary
|
|
8663
|
+
* Render the BUILD summary line + a one-shot throughput sparkline from a
|
|
8664
|
+
* `build:complete` event, finalizing the live phase tree (dropping its animated bar)
|
|
8665
|
+
* first.
|
|
7896
8666
|
*
|
|
7897
8667
|
* @param summary - The `build:complete` payload.
|
|
7898
8668
|
* @example
|
|
@@ -7900,33 +8670,52 @@ function createPanelRenderer(options = {}) {
|
|
|
7900
8670
|
*/
|
|
7901
8671
|
built(summary) {
|
|
7902
8672
|
if (rebuilding) return;
|
|
8673
|
+
if (color && phaseOpen) {
|
|
8674
|
+
let frame = cursorUp(phaseDrawn);
|
|
8675
|
+
for (const row of phaseRows) frame += `${CLEAR_LINE}${renderPhaseRow(row)}\n`;
|
|
8676
|
+
writeRaw(frame + CLEAR_BELOW);
|
|
8677
|
+
}
|
|
8678
|
+
const phaseDurations = phaseRows.map((row) => row.durationMs).filter((value) => value !== void 0);
|
|
7903
8679
|
phaseOpen = false;
|
|
7904
8680
|
phaseDrawn = 0;
|
|
7905
8681
|
stopTicker();
|
|
7906
8682
|
const pages = palette.bold(String(summary.pageCount));
|
|
7907
|
-
|
|
7908
|
-
|
|
7909
|
-
|
|
7910
|
-
|
|
7911
|
-
|
|
7912
|
-
|
|
8683
|
+
const dot = palette.dim("·");
|
|
8684
|
+
const lines = [railLine(`${palette.green("✓")} ${palette.bold("BUILD")} ${dot} ${pages} pages`, `${summary.durationMs}ms ${dot} ${summary.outDir}/`, BOX_INNER)];
|
|
8685
|
+
if (color && summary.durationMs > 0) {
|
|
8686
|
+
const rate = Math.max(1, Math.round(summary.pageCount / (summary.durationMs / 1e3)));
|
|
8687
|
+
const spark = phaseDurations.length > 0 ? palette.pink(sparkline(phaseDurations)) : "";
|
|
8688
|
+
const rateLabel = palette.dim(`${rate} pages/s`);
|
|
8689
|
+
lines.push(railLine(spark, rateLabel, BOX_INNER));
|
|
8690
|
+
}
|
|
8691
|
+
writeBlock(box(lines, color, BOX_INNER));
|
|
7913
8692
|
},
|
|
7914
8693
|
/**
|
|
7915
|
-
* Render the
|
|
8694
|
+
* Render the server-ready rail (Local / Network URLs + watched dirs) and, on a TTY,
|
|
8695
|
+
* begin the persistent breathing `◍ live` idle pulse beneath it.
|
|
7916
8696
|
*
|
|
7917
8697
|
* @param info - Local/Network URLs and optionally the watched directories.
|
|
7918
8698
|
* @example
|
|
7919
8699
|
* render.serverReady({ local: "http://localhost:4173", network: null });
|
|
7920
8700
|
*/
|
|
7921
8701
|
serverReady(info) {
|
|
7922
|
-
const
|
|
7923
|
-
|
|
7924
|
-
|
|
8702
|
+
const network = info.network ? palette.cyan(info.network) : palette.dim("unavailable");
|
|
8703
|
+
const lines = [`${palette.green("➜")} ${palette.bold("Local")} ${palette.cyan(info.local)}`, `${palette.green("➜")} ${palette.bold("Network")} ${network}`];
|
|
8704
|
+
if (info.watching && info.watching.length > 0) lines.push(`${palette.dim("watching")} ${palette.dim(info.watching.join(", "))}`);
|
|
8705
|
+
writeBlock(box(lines, color, BOX_INNER));
|
|
8706
|
+
if (color) {
|
|
8707
|
+
serveMode = true;
|
|
8708
|
+
idle = true;
|
|
8709
|
+
idleStartedAt = now();
|
|
8710
|
+
paintIdleLine();
|
|
8711
|
+
startTicker();
|
|
8712
|
+
}
|
|
7925
8713
|
},
|
|
7926
8714
|
/**
|
|
7927
8715
|
* Begin a serve() rebuild: show ONE compact "rebuilding {label}" line (an animated
|
|
7928
|
-
* spinner with live elapsed on a TTY; a plain "~ {label}" line otherwise)
|
|
7929
|
-
* the verbose phase
|
|
8716
|
+
* spinner with live elapsed on a TTY; a plain "~ {label}" line otherwise), taking over
|
|
8717
|
+
* the idle-pulse line, and mute the verbose phase tree + BUILD summary until
|
|
8718
|
+
* {@link reload}/{@link error} settles it.
|
|
7930
8719
|
*
|
|
7931
8720
|
* @param label - The changed watch target shown in the line.
|
|
7932
8721
|
* @example
|
|
@@ -7934,9 +8723,9 @@ function createPanelRenderer(options = {}) {
|
|
|
7934
8723
|
*/
|
|
7935
8724
|
rebuildStart(label) {
|
|
7936
8725
|
rebuilding = true;
|
|
8726
|
+
idle = false;
|
|
7937
8727
|
rebuildLabel = label;
|
|
7938
8728
|
rebuildStartedAt = now();
|
|
7939
|
-
spinnerFrame = 0;
|
|
7940
8729
|
if (!color) {
|
|
7941
8730
|
write(` ${palette.yellow("~")} ${label}`);
|
|
7942
8731
|
return;
|
|
@@ -7946,9 +8735,9 @@ function createPanelRenderer(options = {}) {
|
|
|
7946
8735
|
},
|
|
7947
8736
|
/**
|
|
7948
8737
|
* Settle the current rebuild: replace the in-place "rebuilding…" line with a compact
|
|
7949
|
-
* "✓ rebuilt N pages · Xms · reloaded" (
|
|
7950
|
-
* Called standalone (no preceding {@link rebuildStart}) it also
|
|
7951
|
-
* line so the changed target stays visible.
|
|
8738
|
+
* "✓ rebuilt N pages · Xms · reloaded", then (in a serve session) resume the idle pulse
|
|
8739
|
+
* on a fresh bottom line. Called standalone (no preceding {@link rebuildStart}) it also
|
|
8740
|
+
* prints the "~ file" line so the changed target stays visible.
|
|
7952
8741
|
*
|
|
7953
8742
|
* @param info - The changed file plus the rebuild's page count and duration.
|
|
7954
8743
|
* @example
|
|
@@ -7957,30 +8746,26 @@ function createPanelRenderer(options = {}) {
|
|
|
7957
8746
|
reload(info) {
|
|
7958
8747
|
const settledRebuild = rebuilding;
|
|
7959
8748
|
rebuilding = false;
|
|
7960
|
-
stopTicker();
|
|
7961
8749
|
const line = ` ${palette.green("✓")} rebuilt ${palette.bold(String(info.pageCount))} pages ${palette.dim(`· ${info.durationMs}ms · reloaded`)}`;
|
|
7962
8750
|
if (settledRebuild && color) {
|
|
7963
8751
|
writeRaw(`\r${CLEAR_LINE}${line}\n`);
|
|
8752
|
+
resumeIdle();
|
|
7964
8753
|
return;
|
|
7965
8754
|
}
|
|
7966
8755
|
if (!settledRebuild) write(` ${palette.yellow("~")} ${info.file}`);
|
|
7967
8756
|
write(line);
|
|
7968
8757
|
},
|
|
7969
8758
|
/**
|
|
7970
|
-
* Render the deploy result
|
|
8759
|
+
* Render the deploy result from a `deploy:complete` event: a `✓ DEPLOYED → url` line
|
|
8760
|
+
* with the URL the hero value, then a dim `branch · id · time` line beneath it.
|
|
7971
8761
|
*
|
|
7972
8762
|
* @param result - The `deploy:complete` payload.
|
|
7973
8763
|
* @example
|
|
7974
8764
|
* render.deployed({ url: "https://x.pages.dev", deploymentId: "id", branch: "main", durationMs: 1200 });
|
|
7975
8765
|
*/
|
|
7976
8766
|
deployed(result) {
|
|
7977
|
-
|
|
7978
|
-
|
|
7979
|
-
`${palette.dim("url")} ${palette.cyan(result.url)}`,
|
|
7980
|
-
`${palette.dim("branch")} ${result.branch}`,
|
|
7981
|
-
`${palette.dim("id")} ${result.deploymentId}`,
|
|
7982
|
-
`${palette.dim("time")} ${result.durationMs}ms`
|
|
7983
|
-
], color));
|
|
8767
|
+
const meta = palette.dim(`branch ${result.branch} · ${result.deploymentId} · ${result.durationMs}ms`);
|
|
8768
|
+
writeBlock([` ${palette.green("✓")} ${palette.bold("DEPLOYED")} ${palette.dim("→")} ${palette.cyan(result.url)}`, ` ${meta}`]);
|
|
7984
8769
|
},
|
|
7985
8770
|
/**
|
|
7986
8771
|
* Render a neutral informational line.
|
|
@@ -8005,7 +8790,8 @@ function createPanelRenderer(options = {}) {
|
|
|
8005
8790
|
writeError(` ${palette.yellow("⚠")} ${message}`);
|
|
8006
8791
|
},
|
|
8007
8792
|
/**
|
|
8008
|
-
* Render an error line (to stderr), optionally with a cause.
|
|
8793
|
+
* Render an error line (to stderr), optionally with a cause. A failing rebuild settles
|
|
8794
|
+
* its in-place spinner line first; in a serve session the idle pulse then resumes.
|
|
8009
8795
|
*
|
|
8010
8796
|
* @param message - The error summary to print.
|
|
8011
8797
|
* @param cause - Optional underlying error/value to print beneath the summary.
|
|
@@ -8013,16 +8799,18 @@ function createPanelRenderer(options = {}) {
|
|
|
8013
8799
|
* render.error("build failed", err);
|
|
8014
8800
|
*/
|
|
8015
8801
|
error(message, cause) {
|
|
8802
|
+
const wasRebuilding = rebuilding;
|
|
8016
8803
|
if (rebuilding) {
|
|
8017
8804
|
rebuilding = false;
|
|
8018
|
-
stopTicker();
|
|
8019
8805
|
if (color) writeRaw(`\r${CLEAR_LINE}`);
|
|
8020
8806
|
}
|
|
8021
8807
|
writeError(` ${palette.red("✗")} ${message}`);
|
|
8022
8808
|
if (cause !== void 0) writeError(String(cause));
|
|
8809
|
+
if (wasRebuilding) resumeIdle();
|
|
8810
|
+
else stopTicker();
|
|
8023
8811
|
},
|
|
8024
8812
|
/**
|
|
8025
|
-
* Render a section heading (a blank line + a bold
|
|
8813
|
+
* Render a section heading (a blank line + a bold pink label) for a multi-step flow.
|
|
8026
8814
|
*
|
|
8027
8815
|
* @param text - The heading label.
|
|
8028
8816
|
* @example
|
|
@@ -8030,7 +8818,7 @@ function createPanelRenderer(options = {}) {
|
|
|
8030
8818
|
*/
|
|
8031
8819
|
heading(text) {
|
|
8032
8820
|
write("");
|
|
8033
|
-
write(` ${palette.bold(palette.
|
|
8821
|
+
write(` ${palette.bold(palette.pink(text))}`);
|
|
8034
8822
|
},
|
|
8035
8823
|
/**
|
|
8036
8824
|
* Render a diagnostic line: green `✓` (pass) or red `✗` (fail) + label, with optional
|
|
@@ -8045,6 +8833,19 @@ function createPanelRenderer(options = {}) {
|
|
|
8045
8833
|
check(ok, label, detail) {
|
|
8046
8834
|
write(` ${ok ? palette.green("✓") : palette.red("✗")} ${label}`);
|
|
8047
8835
|
if (detail !== void 0) for (const line of detail.split("\n")) write(` ${palette.dim(line)}`);
|
|
8836
|
+
},
|
|
8837
|
+
/**
|
|
8838
|
+
* Stop every animation and release the interval timer (serve()'s teardown calls this).
|
|
8839
|
+
*
|
|
8840
|
+
* @example
|
|
8841
|
+
* render.dispose();
|
|
8842
|
+
*/
|
|
8843
|
+
dispose() {
|
|
8844
|
+
stopTicker();
|
|
8845
|
+
idle = false;
|
|
8846
|
+
rebuilding = false;
|
|
8847
|
+
phaseOpen = false;
|
|
8848
|
+
serveMode = false;
|
|
8048
8849
|
}
|
|
8049
8850
|
};
|
|
8050
8851
|
}
|
|
@@ -8190,6 +8991,23 @@ function defaultFileMtime(filePath) {
|
|
|
8190
8991
|
}
|
|
8191
8992
|
}
|
|
8192
8993
|
/**
|
|
8994
|
+
* Default file-content-hash probe — `sha256` of the file bytes, returning `null` for a
|
|
8995
|
+
* missing/unreadable path. serve()'s change gate compares this against the last
|
|
8996
|
+
* successfully-built bytes to drop a no-op save (a byte-identical double Ctrl-S).
|
|
8997
|
+
*
|
|
8998
|
+
* @param filePath - The absolute path to hash.
|
|
8999
|
+
* @returns The hex SHA-256 of the file's bytes, or `null` when it cannot be read.
|
|
9000
|
+
* @example
|
|
9001
|
+
* const hash = defaultFileHash("/abs/content/a.md");
|
|
9002
|
+
*/
|
|
9003
|
+
function defaultFileHash(filePath) {
|
|
9004
|
+
try {
|
|
9005
|
+
return (0, node_crypto.createHash)("sha256").update((0, node_fs.readFileSync)(filePath)).digest("hex");
|
|
9006
|
+
} catch {
|
|
9007
|
+
return null;
|
|
9008
|
+
}
|
|
9009
|
+
}
|
|
9010
|
+
/**
|
|
8193
9011
|
* Default LAN network-URL deriver — wraps {@link networkUrl} so the production seam
|
|
8194
9012
|
* reads `node:os` interfaces while tests can inject a deterministic value.
|
|
8195
9013
|
*
|
|
@@ -8201,6 +9019,101 @@ function defaultFileMtime(filePath) {
|
|
|
8201
9019
|
function defaultNetworkUrl(port) {
|
|
8202
9020
|
return networkUrl(port);
|
|
8203
9021
|
}
|
|
9022
|
+
/** Memoized banner facts — resolution touches the filesystem + git once, then caches. */
|
|
9023
|
+
let cachedBanner;
|
|
9024
|
+
/**
|
|
9025
|
+
* Run a read-only `git` command in `dir`, returning its trimmed stdout (`undefined` on
|
|
9026
|
+
* any failure — not a checkout, git missing, etc.). A thin wrapper so the version
|
|
9027
|
+
* resolver can issue a couple of git queries without repeating the spawn boilerplate.
|
|
9028
|
+
*
|
|
9029
|
+
* @param dir - The working directory to run git in.
|
|
9030
|
+
* @param args - The git arguments (no user input is ever interpolated).
|
|
9031
|
+
* @returns The trimmed command output, or `undefined` on failure.
|
|
9032
|
+
* @example
|
|
9033
|
+
* git("/Users/me/moku/web", ["tag", "--list", "v*"]);
|
|
9034
|
+
*/
|
|
9035
|
+
function git(dir, args) {
|
|
9036
|
+
try {
|
|
9037
|
+
return (0, node_child_process.execFileSync)("git", args, {
|
|
9038
|
+
cwd: dir,
|
|
9039
|
+
encoding: "utf8",
|
|
9040
|
+
stdio: [
|
|
9041
|
+
"ignore",
|
|
9042
|
+
"pipe",
|
|
9043
|
+
"ignore"
|
|
9044
|
+
]
|
|
9045
|
+
}).trim();
|
|
9046
|
+
} catch {
|
|
9047
|
+
return;
|
|
9048
|
+
}
|
|
9049
|
+
}
|
|
9050
|
+
/**
|
|
9051
|
+
* The framework's source/dev version, derived the SAME way the publish workflow computes
|
|
9052
|
+
* a release: the highest semver `v*` tag is the source of truth (`@moku-labs/web` is
|
|
9053
|
+
* released tag-only — the working-tree `package.json` deliberately carries no `version`).
|
|
9054
|
+
* A `-dev` suffix marks it as a local build off that release line (e.g. `v1.1.0-dev`), so
|
|
9055
|
+
* it never masquerades as the published release. Falls back to the short commit (then
|
|
9056
|
+
* `undefined`) only when no tags exist. `undefined` when `dir` is not a git checkout (a
|
|
9057
|
+
* published npm install — which carries its real `version` instead).
|
|
9058
|
+
*
|
|
9059
|
+
* @param dir - A directory inside the framework's own repository (the realpath of the
|
|
9060
|
+
* package root, so a symlinked local checkout reports the framework's tag — not the
|
|
9061
|
+
* consumer's).
|
|
9062
|
+
* @returns The dev version (e.g. `v1.1.0-dev`), or `undefined`.
|
|
9063
|
+
* @example
|
|
9064
|
+
* devVersion("/Users/me/moku/web"); // "v1.1.0-dev"
|
|
9065
|
+
*/
|
|
9066
|
+
function devVersion(dir) {
|
|
9067
|
+
const latestTag = git(dir, [
|
|
9068
|
+
"tag",
|
|
9069
|
+
"--list",
|
|
9070
|
+
"v*",
|
|
9071
|
+
"--sort=-v:refname"
|
|
9072
|
+
])?.split("\n")[0]?.trim();
|
|
9073
|
+
if (latestTag) return `${latestTag}-dev`;
|
|
9074
|
+
const sha = git(dir, [
|
|
9075
|
+
"rev-parse",
|
|
9076
|
+
"--short",
|
|
9077
|
+
"HEAD"
|
|
9078
|
+
]);
|
|
9079
|
+
return sha ? `${sha}-dev` : void 0;
|
|
9080
|
+
}
|
|
9081
|
+
/**
|
|
9082
|
+
* Resolve the real version/runtime facts shown in the Panel banner (memoized). Reads the
|
|
9083
|
+
* `package.json` shipped beside the built bundle (`dist/../package.json`): a PUBLISHED
|
|
9084
|
+
* release carries a `version` and reports `v{version}`; a source/dev build (no `version`
|
|
9085
|
+
* field — `@moku-labs/web` is released tag-only) reports the latest semver tag + `-dev`
|
|
9086
|
+
* (e.g. `v1.1.0-dev`, the same tag the publish workflow treats as the version source), or
|
|
9087
|
+
* `"dev"` when git is unavailable. The pinned `@moku-labs/core` version comes from the
|
|
9088
|
+
* same file's `dependencies`.
|
|
9089
|
+
*
|
|
9090
|
+
* @returns The resolved {@link BannerFacts}.
|
|
9091
|
+
* @example
|
|
9092
|
+
* resolveBanner(); // { version: "v1.1.0-dev", coreVersion: "0.1.0-alpha.6" }
|
|
9093
|
+
*/
|
|
9094
|
+
function resolveBanner() {
|
|
9095
|
+
if (cachedBanner) return cachedBanner;
|
|
9096
|
+
let pkg = {};
|
|
9097
|
+
let pkgDir;
|
|
9098
|
+
try {
|
|
9099
|
+
const pkgUrl = new URL("../package.json", require("url").pathToFileURL(__filename).href);
|
|
9100
|
+
pkgDir = (0, node_fs.realpathSync)(node_path$1.default.dirname((0, node_url.fileURLToPath)(pkgUrl)));
|
|
9101
|
+
pkg = JSON.parse((0, node_fs.readFileSync)(pkgUrl, "utf8"));
|
|
9102
|
+
} catch {}
|
|
9103
|
+
const coreVersion = (pkg.dependencies?.["@moku-labs/core"] ?? "").replace(/^\D*/, "") || "unknown";
|
|
9104
|
+
const released = pkg.version;
|
|
9105
|
+
let version = "dev";
|
|
9106
|
+
if (released) version = `v${released}`;
|
|
9107
|
+
else {
|
|
9108
|
+
const dev = devVersion(pkgDir ?? process.cwd());
|
|
9109
|
+
if (dev) version = dev;
|
|
9110
|
+
}
|
|
9111
|
+
cachedBanner = {
|
|
9112
|
+
version,
|
|
9113
|
+
coreVersion
|
|
9114
|
+
};
|
|
9115
|
+
return cachedBanner;
|
|
9116
|
+
}
|
|
8204
9117
|
/**
|
|
8205
9118
|
* Create the initial cli plugin state with the production seams wired. Every field is
|
|
8206
9119
|
* an injectable seam (`render`, `confirm`, `clock`, `watch`, the server factories,
|
|
@@ -8214,8 +9127,12 @@ function defaultNetworkUrl(port) {
|
|
|
8214
9127
|
* const state = createState({ global: {}, config });
|
|
8215
9128
|
*/
|
|
8216
9129
|
function createState$1(_ctx) {
|
|
9130
|
+
const banner = resolveBanner();
|
|
8217
9131
|
return {
|
|
8218
|
-
render: createPanelRenderer(
|
|
9132
|
+
render: createPanelRenderer({
|
|
9133
|
+
version: banner.version,
|
|
9134
|
+
coreVersion: banner.coreVersion
|
|
9135
|
+
}),
|
|
8219
9136
|
confirm: defaultConfirm,
|
|
8220
9137
|
select: defaultSelect,
|
|
8221
9138
|
clock: Date.now,
|
|
@@ -8223,7 +9140,8 @@ function createState$1(_ctx) {
|
|
|
8223
9140
|
serveStatic: defaultServeStatic,
|
|
8224
9141
|
fileResponse: defaultFileResponse,
|
|
8225
9142
|
networkUrl: defaultNetworkUrl,
|
|
8226
|
-
fileMtime: defaultFileMtime
|
|
9143
|
+
fileMtime: defaultFileMtime,
|
|
9144
|
+
fileHash: defaultFileHash
|
|
8227
9145
|
};
|
|
8228
9146
|
}
|
|
8229
9147
|
//#endregion
|