@t09tanaka/stoneage 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 - 2026-07-05
4
+
5
+ - Added a global `assets.scripts` array, the every-page counterpart to
6
+ `assets.stylesheets`. Scripts declared here render on every page's `<head>`
7
+ (a `source` is emitted as a content-hashed immutable asset; an `inline` body
8
+ embeds directly), regardless of per-page `assets.client` islands. Global
9
+ scripts are part of the shared page fingerprint, so changing them regenerates
10
+ unchanged pages. Fixes the footgun where a page-level `islands` list silently
11
+ dropped a hook-injected site-wide tag.
12
+ - Added `{ tag: "script" }` support to `SiteConfig.head` and `metadata.head`,
13
+ external (`attrs.src`) or inline (`children`, e.g. JSON-LD or a tag-manager
14
+ snippet). `<` in `children` is escaped so a `</script>` in the body cannot
15
+ break out of the element.
16
+ - Added an `inline` body to `ClientScriptAsset`, symmetric with the stylesheet
17
+ `inline` option, so a standard analytics snippet (external loader plus inline
18
+ init) can be expressed without hand-bundling it into one file.
19
+ - Added a bottom-left dev progress widget to the `stoneage dev` hot-reload
20
+ client, reading a new Server-Sent Events lifecycle (`building` / `ready` with
21
+ the build `durationMs` / `build-error` with the message). It shows a live
22
+ `Building <elapsed>` timer, `Ready in <n>ms`, or a persistent build-error
23
+ readout that does not reload the page, and is light/dark and reduced-motion
24
+ aware.
25
+ - Added `STONEAGE_CHANGED_FILES` to the environment of the dev `--build-command`
26
+ (cwd-relative, newline-separated changed paths; omitted when more than 50 files
27
+ change at once). It is advisory only — build correctness never depends on it.
28
+ `stoneage dev` now measures and logs each rebuild's duration. Documented
29
+ keeping dev builds incremental by not deleting the output directory in the
30
+ build command.
31
+
3
32
  ## 0.3.0 - 2026-06-30
4
33
 
5
34
  - Added global stylesheet auto-inlining via `assets.inlineStylesheets` (inline
package/README.md CHANGED
@@ -250,6 +250,51 @@ filename, their HTML references are rewritten, and `publishing.headers` adds an
250
250
  immutable cache-control entry. Plain string paths and `assets.public` entries
251
251
  continue to use fixed filenames.
252
252
 
253
+ To load a script on **every** page — analytics, a tag manager, and the like —
254
+ use `assets.scripts`, the counterpart to `assets.stylesheets`. Unlike per-page
255
+ `assets.client` islands (which are merged per page and can be replaced when a
256
+ page sets its own `islands` list), `assets.scripts` always renders on every
257
+ page, so a site-wide tag can never silently drop out on a subset of pages. Each
258
+ entry is a `ClientScriptAsset`: give it a `src` (a `source` is emitted as a
259
+ content-hashed immutable asset) or an `inline` body embedded directly. Inline
260
+ scripts are subject to the page CSP `script-src`; send `'unsafe-inline'` (or a
261
+ hash/nonce) or use an external `src`. Google Analytics — external loader plus
262
+ inline init — is two entries:
263
+
264
+ ```ts
265
+ await buildSite({
266
+ outDir: "dist",
267
+ site,
268
+ assets: {
269
+ scripts: [
270
+ { src: "https://www.googletagmanager.com/gtag/js?id=G-XXXX", async: true },
271
+ {
272
+ inline:
273
+ "window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}" +
274
+ "gtag('js',new Date());gtag('config','G-XXXX');",
275
+ },
276
+ ],
277
+ },
278
+ routes,
279
+ });
280
+ ```
281
+
282
+ For tags that belong in the `<head>` markup itself — a tag manager snippet or
283
+ JSON-LD structured data — `SiteConfig.head` (and per-page `metadata.head`) also
284
+ accepts `{ tag: "script" }` with an external `attrs.src` or an inline `children`
285
+ body. StoneAge escapes `<` in `children`, so a `</script>` inside JSON-LD cannot
286
+ break out of the element:
287
+
288
+ ```ts
289
+ site.head = [
290
+ {
291
+ tag: "script",
292
+ attrs: { type: "application/ld+json" },
293
+ children: JSON.stringify({ "@context": "https://schema.org", "@type": "WebSite" }),
294
+ },
295
+ ];
296
+ ```
297
+
253
298
  For Google Fonts CSS2 subsets, use `googleFontsStylesheet("/assets/fonts.css",
254
299
  { families: ["Zen Maru Gothic:wght@700;900", "Outfit:wght@700;900"], text:
255
300
  subsetText })`. StoneAge fetches the CSS and WOFF2 files at build time, rewrites
@@ -602,6 +647,22 @@ one or more `--watch <path>` options to rebuild on source changes; browsers
602
647
  reload only after the build command succeeds. StoneAge ignores the output
603
648
  directory, `.git`, and `node_modules` while watching to avoid rebuild loops.
604
649
 
650
+ > **Keep incremental builds working (important):** pass a `--build-command`
651
+ > that does **not** delete the output directory. `buildSite` regenerates only
652
+ > the changed pages from the previous manifest and sweeps outputs whose source
653
+ > disappeared on its own, so a command containing `rm -rf <out-dir>` throws away
654
+ > the incremental cache on every change and re-renders every page — very slow on
655
+ > large sites. For development, use a build script separate from your production
656
+ > build that omits the clean step.
657
+
658
+ > The dev server passes the changed file paths to the build command as
659
+ > `STONEAGE_CHANGED_FILES` (cwd-relative, newline-separated; unset when many
660
+ > files change at once). A build can read this to narrow what it reloads. It is
661
+ > purely advisory — build correctness never depends on it.
662
+
663
+ > Rebuild progress is shown by a small dev-only widget in the bottom-left of the
664
+ > page (`Rebuilding… <elapsed>`, `Ready in <n>ms`, or a build-error message).
665
+
605
666
  `benchmark --compare-to <file>` compares the current `.stoneage/benchmark.json`
606
667
  metrics with a previous benchmark report and fails the command when size metrics
607
668
  grow beyond `--max-size-regression-percent` (default `0`). The comparison is
package/dist/assets.d.ts CHANGED
@@ -70,6 +70,18 @@ export type ClientScriptAsset = {
70
70
  defer?: boolean;
71
71
  async?: boolean;
72
72
  attributes?: Record<string, ClientAssetAttributeValue>;
73
+ } | {
74
+ /**
75
+ * Inline JavaScript embedded directly as the `<script>` body instead of an
76
+ * external `src`. Symmetric with {@link InlineStylesheetAsset}. The script
77
+ * is part of the HTML, so no hashed public file is written for it. Note: an
78
+ * inline `<script>` is subject to the page Content-Security-Policy
79
+ * `script-src`; sites that send a strict CSP without `'unsafe-inline'` (or a
80
+ * matching hash/nonce) should use an external `src` instead.
81
+ */
82
+ inline: string;
83
+ module?: boolean;
84
+ attributes?: Record<string, ClientAssetAttributeValue>;
73
85
  };
74
86
  export type ClientAssetDeclaration = {
75
87
  stylesheets?: ClientStylesheetAsset[];
@@ -133,8 +145,11 @@ export declare function stylesheet(href: string, options?: StylesheetAssetOption
133
145
  export declare function googleFontsStylesheet(href: string, options: GoogleFontsStylesheetOptions & {
134
146
  inline?: boolean;
135
147
  }): ClientStylesheetAsset;
136
- export declare function script(src: string, options?: Omit<ClientScriptAsset, "src">): ClientAssetDeclaration;
137
- export declare function island(src: string, options?: Omit<ClientScriptAsset, "src" | "module">): ClientAssetDeclaration;
148
+ export type ExternalClientScriptAsset = Extract<ClientScriptAsset, {
149
+ src: string;
150
+ }>;
151
+ export declare function script(src: string, options?: Omit<ExternalClientScriptAsset, "src">): ClientAssetDeclaration;
152
+ export declare function island(src: string, options?: Omit<ExternalClientScriptAsset, "src" | "module">): ClientAssetDeclaration;
138
153
  export declare function resolveClientAssets(registry: ClientAssetRegistry, ids: string[]): ResolvedClientAssets;
139
154
  export declare function assetsFromViteManifest(manifest: ViteManifest, options?: ViteManifestAssetOptions): ClientAssetRegistry;
140
155
  export declare function loadAssetsFromViteManifest(path: string, options?: ViteManifestAssetOptions): Promise<ClientAssetRegistry>;
package/dist/assets.js CHANGED
@@ -304,6 +304,16 @@ function stableStylesheetKey(stylesheet) {
304
304
  });
305
305
  }
306
306
  function stableScriptKey(script) {
307
+ const attributes = Object.entries(script.attributes ?? {})
308
+ .filter(([, value]) => value !== null && value !== undefined && value !== false)
309
+ .sort(([left], [right]) => left.localeCompare(right));
310
+ if ("inline" in script) {
311
+ return JSON.stringify({
312
+ inline: script.inline,
313
+ module: script.module === true,
314
+ attributes,
315
+ });
316
+ }
307
317
  return JSON.stringify({
308
318
  src: script.src,
309
319
  source: script.source,
@@ -311,9 +321,7 @@ function stableScriptKey(script) {
311
321
  module: script.module === true,
312
322
  defer: script.defer === true,
313
323
  async: script.async === true,
314
- attributes: Object.entries(script.attributes ?? {})
315
- .filter(([, value]) => value !== null && value !== undefined && value !== false)
316
- .sort(([left], [right]) => left.localeCompare(right)),
324
+ attributes,
317
325
  });
318
326
  }
319
327
  function collectViteChunkCss(manifest, chunk, base) {
package/dist/core.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ClientAssetRegistry, type ClientStylesheetAsset, type PublicAssetInput } from "./assets.js";
1
+ import { type ClientAssetRegistry, type ClientScriptAsset, type ClientStylesheetAsset, type PublicAssetInput } from "./assets.js";
2
2
  import { type DeferredFragmentDefaults } from "./fragment.js";
3
3
  export * from "./data.js";
4
4
  export type RouteParams = Record<string, string | number>;
@@ -63,9 +63,21 @@ export type TwitterMetadata = {
63
63
  description?: string;
64
64
  image?: string;
65
65
  };
66
+ export type HeadTagAttributes = Record<string, string | number | boolean | null | undefined>;
66
67
  export type HeadTag = {
67
68
  tag: "link" | "meta";
68
- attrs: Record<string, string | number | boolean | null | undefined>;
69
+ attrs: HeadTagAttributes;
70
+ } | {
71
+ /**
72
+ * A `<script>` in the document `<head>` — external (`attrs: { src }`) or
73
+ * inline (`children`), e.g. analytics loaders, tag managers, or JSON-LD
74
+ * (`attrs: { type: "application/ld+json" }` + `children: JSON.stringify(...)`).
75
+ * `children` is emitted verbatim except that `<` is escaped so a `</script>`
76
+ * inside the body cannot break out of the element.
77
+ */
78
+ tag: "script";
79
+ attrs?: HeadTagAttributes;
80
+ children?: string;
69
81
  };
70
82
  export type RobotsRule = {
71
83
  userAgent: string | string[];
@@ -302,6 +314,15 @@ export type BuildConfig = {
302
314
  site: SiteConfig;
303
315
  assets?: {
304
316
  stylesheets?: ClientStylesheetAsset[];
317
+ /**
318
+ * Scripts loaded on every page's `<head>`, the counterpart to
319
+ * {@link stylesheets}. Use this for site-wide tags such as analytics or a tag
320
+ * manager: unlike per-page `client` islands (which are merged per page and can
321
+ * be replaced by a page-level `islands` list), these always render on every
322
+ * page. A `source` is emitted as a content-hashed immutable asset; an `inline`
323
+ * script embeds its body directly (subject to the page CSP `script-src`).
324
+ */
325
+ scripts?: ClientScriptAsset[];
305
326
  client?: ClientAssetRegistry;
306
327
  /**
307
328
  * Inline every generated stylesheet's CSS into each document `<head>` as a
package/dist/core.js CHANGED
@@ -625,6 +625,7 @@ async function resolveStaticAssetReferences(assets) {
625
625
  inlineStylesheetMaxBytes: assets.inlineStylesheetMaxBytes,
626
626
  };
627
627
  const stylesheets = await Promise.all((assets.stylesheets ?? []).map(async (stylesheet) => resolveStylesheetAsset(stylesheet, resolveImmutablePublicAsset, collectFontPreloads, inlinePolicy)));
628
+ const scripts = await Promise.all((assets.scripts ?? []).map((script) => resolveClientScriptAsset(script, resolveImmutablePublicAsset)));
628
629
  // Per-page client stylesheets are resolved without a preload collector: a
629
630
  // page-scoped font must not become a global preload hint on every page.
630
631
  const client = assets.client
@@ -634,6 +635,7 @@ async function resolveStaticAssetReferences(assets) {
634
635
  assets: {
635
636
  ...assets,
636
637
  ...(assets.stylesheets ? { stylesheets } : {}),
638
+ ...(assets.scripts ? { scripts } : {}),
637
639
  ...(client ? { client } : {}),
638
640
  },
639
641
  publicAssets: [...publicAssetsByPath.values()],
@@ -769,6 +771,9 @@ async function resolveClientAssetRegistry(registry, resolveImmutablePublicAsset,
769
771
  return Object.fromEntries(entries);
770
772
  }
771
773
  async function resolveClientScriptAsset(script, resolveImmutablePublicAsset) {
774
+ if ("inline" in script) {
775
+ return script;
776
+ }
772
777
  if (!script.source || script.immutable === false) {
773
778
  const { source: _source, immutable: _immutable, ...runtimeScript } = script;
774
779
  return runtimeScript;
@@ -1048,6 +1053,7 @@ export async function buildSite(config) {
1048
1053
  const sharedPageFingerprintHash = hashValue({
1049
1054
  site: config.site,
1050
1055
  stylesheets: assets?.stylesheets ?? [],
1056
+ scripts: assets?.scripts ?? [],
1051
1057
  // The global inline policy can flip a stylesheet between external and
1052
1058
  // inlined; including the options invalidates every page when the policy
1053
1059
  // changes, even for pages whose CSS lives only in `assets.client` (whose
@@ -2131,6 +2137,7 @@ function renderDocument(site, metadata, canonical, body, assets, prefetch, trail
2131
2137
  : `<link rel="stylesheet" href="${escapeAttribute(stylesheetHref(stylesheet))}">`)
2132
2138
  .join("");
2133
2139
  const clientScripts = renderClientScripts([
2140
+ ...(assets?.scripts ?? []),
2134
2141
  ...extraClientScripts,
2135
2142
  ...pageClientAssets.scripts,
2136
2143
  ]);
@@ -2170,11 +2177,19 @@ function renderTwitterMetadata(siteTwitter, pageTwitter) {
2170
2177
  function renderMetaName(name, content) {
2171
2178
  return `<meta name="${escapeAttribute(name)}" content="${escapeAttribute(content)}">`;
2172
2179
  }
2180
+ // Neutralize a "</script>" breakout in inline <script> bodies and JSON-LD.
2181
+ // Escaping "<" to its unicode form is safe for both JavaScript and JSON.
2182
+ function escapeScriptContent(content) {
2183
+ return content.replace(/</g, "\\u003c");
2184
+ }
2173
2185
  function renderHeadTag(tag) {
2186
+ if (tag.tag === "script") {
2187
+ return `<script${renderHeadAttributes(tag.attrs)}>${escapeScriptContent(tag.children ?? "")}</script>`;
2188
+ }
2174
2189
  return `<${tag.tag}${renderHeadAttributes(tag.attrs)}>`;
2175
2190
  }
2176
2191
  function renderHeadAttributes(attrs) {
2177
- return Object.entries(attrs)
2192
+ return Object.entries(attrs ?? {})
2178
2193
  .sort(([left], [right]) => left.localeCompare(right))
2179
2194
  .map(([name, value]) => {
2180
2195
  if (value === false || value === null || value === undefined) {
@@ -2211,7 +2226,15 @@ function escapeInlineStyleContent(css) {
2211
2226
  return css.replace(/<\/(style)/gi, "<\\/$1");
2212
2227
  }
2213
2228
  function renderClientScripts(scripts) {
2214
- return scripts.map((script) => `<script${renderScriptAttributes(script)}></script>`).join("");
2229
+ return scripts
2230
+ .map((script) => "inline" in script
2231
+ ? `<script${renderInlineScriptAttributes(script)}>${escapeScriptContent(script.inline)}</script>`
2232
+ : `<script${renderScriptAttributes(script)}></script>`)
2233
+ .join("");
2234
+ }
2235
+ function renderInlineScriptAttributes(script) {
2236
+ const output = script.module ? ' type="module"' : "";
2237
+ return output + renderScriptCustomAttributes(script.attributes);
2215
2238
  }
2216
2239
  function renderScriptAttributes(script) {
2217
2240
  let output = script.module ? ' type="module"' : "";
@@ -2222,7 +2245,11 @@ function renderScriptAttributes(script) {
2222
2245
  if (script.async) {
2223
2246
  output += " async";
2224
2247
  }
2225
- for (const [name, value] of Object.entries(script.attributes ?? {}).sort(([left], [right]) => left.localeCompare(right))) {
2248
+ return output + renderScriptCustomAttributes(script.attributes);
2249
+ }
2250
+ function renderScriptCustomAttributes(attributes) {
2251
+ let output = "";
2252
+ for (const [name, value] of Object.entries(attributes ?? {}).sort(([left], [right]) => left.localeCompare(right))) {
2226
2253
  if (value === false || value === null || value === undefined) {
2227
2254
  continue;
2228
2255
  }
package/dist/dev.d.ts CHANGED
@@ -12,6 +12,7 @@ export type DevServer = {
12
12
  url: string;
13
13
  close: () => Promise<void>;
14
14
  reload: () => void;
15
+ rebuild: () => void;
15
16
  };
16
17
  export type DevServerFile = {
17
18
  absolutePath: string;
@@ -19,12 +20,12 @@ export type DevServerFile = {
19
20
  injectHotReload: boolean;
20
21
  };
21
22
  export type BuildScheduler = {
22
- schedule: () => Promise<void>;
23
+ schedule: (changedPath?: string) => Promise<void>;
23
24
  };
24
25
  type BuildSchedulerOptions = {
25
26
  debounceMs: number;
26
- runBuild: () => Promise<void>;
27
- onReload: () => void;
27
+ runBuild: (changedFiles: string[]) => Promise<void>;
28
+ onReload: (durationMs: number) => void;
28
29
  onError: (error: Error) => void;
29
30
  onStart?: () => void;
30
31
  };
@@ -32,5 +33,5 @@ export declare function injectHotReloadClient(html: string): string;
32
33
  export declare function resolveDevServerFile(outDir: string, requestUrl: string): Promise<DevServerFile | undefined>;
33
34
  export declare function createBuildScheduler(options: BuildSchedulerOptions): BuildScheduler;
34
35
  export declare function startDevServer(options: DevServerOptions): Promise<DevServer>;
35
- export declare function runBuildCommand(command: string, cwd?: string): Promise<void>;
36
+ export declare function runBuildCommand(command: string, cwd?: string, changedFiles?: string[]): Promise<void>;
36
37
  export {};
package/dist/dev.js CHANGED
@@ -4,8 +4,132 @@ import { mkdir, readFile, readdir, realpath, stat } from "node:fs/promises";
4
4
  import { createServer } from "node:http";
5
5
  import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
6
6
  const reloadEventsPath = "/__stoneage/events";
7
- const hotReloadClient = `<script type="module">const events = new EventSource("${reloadEventsPath}");
8
- events.addEventListener("reload", () => location.reload());
7
+ const hotReloadClient = `<script type="module">
8
+ (() => {
9
+ const ENDPOINT = "${reloadEventsPath}";
10
+ const STORE_KEY = "__stoneage_dev_status";
11
+
12
+ // Format a millisecond duration the way a stopwatch would read it out:
13
+ // sub-second stays in ms, anything longer switches to seconds.
14
+ const fmtMs = (ms) => (ms < 1000 ? Math.round(ms) + "ms" : (ms / 1000).toFixed(2) + "s");
15
+ const fmtElapsed = (ms) => (ms / 1000).toFixed(1) + "s";
16
+
17
+ class StoneAgeDevStatus extends HTMLElement {
18
+ constructor() {
19
+ super();
20
+ const root = this.attachShadow({ mode: "open" });
21
+ root.innerHTML = \`<style>
22
+ :host { all: initial; }
23
+ .w { position: fixed; bottom: 14px; left: 14px; z-index: 2147483647;
24
+ display: none; align-items: stretch; overflow: hidden;
25
+ min-height: 30px; max-width: min(52ch, 70vw); border-radius: 7px;
26
+ color: #e2e8f0; background: #0b1220;
27
+ border: 1px solid rgba(148,163,184,.22);
28
+ box-shadow: 0 6px 22px -8px rgba(0,0,0,.6);
29
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
30
+ transition: opacity .25s ease; }
31
+ .w[data-state="building"], .w[data-state="ready"], .w[data-state="error"] { display: inline-flex; }
32
+ /* Left accent rail — the instrument's status light. */
33
+ .rail { width: 3px; flex: 0 0 auto; background: #64748b; }
34
+ .w[data-state="building"] .rail { background: #f0b429; }
35
+ .w[data-state="ready"] .rail { background: #34d399; }
36
+ .w[data-state="error"] .rail { background: #f43f5e; }
37
+ .body { display: flex; align-items: center; gap: 9px; padding: 6px 12px 6px 11px; min-width: 0; }
38
+ .name { font-size: 9.5px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase;
39
+ color: #94a3b8; white-space: nowrap; }
40
+ .w[data-state="error"] .name { color: #fb7185; }
41
+ /* The hero: a tabular readout so ticking digits never reflow. */
42
+ .value { font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
43
+ font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums;
44
+ color: #f8fafc; white-space: nowrap; }
45
+ .value:empty { display: none; }
46
+ /* Only the error message wraps; the timer readout always stays on one line.
47
+ A definite width in the error state keeps the message from collapsing to
48
+ one character per line inside the content-sized flex box. */
49
+ .w[data-state="error"] { width: min(46ch, 72vw); }
50
+ .w[data-state="error"] .value { font-weight: 500; color: #fecdd3; min-width: 0; flex: 1 1 auto;
51
+ white-space: pre-wrap; overflow-wrap: anywhere; max-height: 4.5em; overflow-y: auto; }
52
+ @keyframes saw { 0% { transform: translateX(-100%) } 100% { transform: translateX(100%) } }
53
+ .w[data-state="building"] .rail { position: relative; }
54
+ .w[data-state="building"] .rail::after {
55
+ content: ""; position: absolute; inset: 0;
56
+ background: linear-gradient(180deg, transparent, #fde68a, transparent);
57
+ animation: saw 1.1s linear infinite; }
58
+ @media (prefers-color-scheme: light) {
59
+ .w { color: #1e293b; background: #ffffff; border-color: rgba(15,23,42,.12);
60
+ box-shadow: 0 6px 22px -10px rgba(15,23,42,.35); }
61
+ .name { color: #64748b; }
62
+ .value { color: #0f172a; }
63
+ .w[data-state="error"] .name { color: #e11d48; }
64
+ .w[data-state="error"] .value { color: #9f1239; }
65
+ }
66
+ @media (prefers-reduced-motion: reduce) {
67
+ .w { transition: none; }
68
+ .w[data-state="building"] .rail::after { animation: none; }
69
+ }
70
+ </style><div class="w" data-state="idle" role="status" aria-live="polite"><span class="rail"></span><span class="body"><span class="name"></span><span class="value"></span></span></div>\`;
71
+ this.box = root.querySelector(".w");
72
+ this.nameEl = root.querySelector(".name");
73
+ this.valueEl = root.querySelector(".value");
74
+ }
75
+ set(state, name, value) {
76
+ this.box.dataset.state = state;
77
+ this.nameEl.textContent = name;
78
+ this.valueEl.textContent = value || "";
79
+ }
80
+ }
81
+ if (!customElements.get("stoneage-dev-status")) {
82
+ customElements.define("stoneage-dev-status", StoneAgeDevStatus);
83
+ }
84
+
85
+ const mount = () => {
86
+ const widget = document.createElement("stoneage-dev-status");
87
+ document.body.appendChild(widget);
88
+
89
+ // Show the just-finished build time briefly after the reload it triggered.
90
+ try {
91
+ const raw = sessionStorage.getItem(STORE_KEY);
92
+ if (raw) {
93
+ sessionStorage.removeItem(STORE_KEY);
94
+ const { durationMs, at } = JSON.parse(raw);
95
+ if (Date.now() - at < 5000) {
96
+ widget.set("ready", "Ready", fmtMs(durationMs));
97
+ setTimeout(() => widget.set("idle", "", ""), 2000);
98
+ }
99
+ }
100
+ } catch (_) {}
101
+
102
+ let timer = null;
103
+ const stopTimer = () => { if (timer) { clearInterval(timer); timer = null; } };
104
+
105
+ const events = new EventSource(ENDPOINT);
106
+ events.addEventListener("building", () => {
107
+ stopTimer();
108
+ const start = Date.now();
109
+ widget.set("building", "Building", "0.0s");
110
+ timer = setInterval(() => {
111
+ widget.set("building", "Building", fmtElapsed(Date.now() - start));
112
+ }, 100);
113
+ });
114
+ events.addEventListener("ready", (e) => {
115
+ stopTimer();
116
+ let durationMs = 0;
117
+ try { durationMs = JSON.parse(e.data).durationMs ?? 0; } catch (_) {}
118
+ try { sessionStorage.setItem(STORE_KEY, JSON.stringify({ durationMs, at: Date.now() })); } catch (_) {}
119
+ location.reload();
120
+ });
121
+ events.addEventListener("build-error", (e) => {
122
+ stopTimer();
123
+ let message = "Build failed";
124
+ try { message = JSON.parse(e.data).message || message; } catch (_) {}
125
+ widget.set("error", "Build failed", message);
126
+ });
127
+ events.addEventListener("reload", () => location.reload());
128
+ };
129
+
130
+ if (document.body) { mount(); }
131
+ else { document.addEventListener("DOMContentLoaded", mount); }
132
+ })();
9
133
  </script>`;
10
134
  export function injectHotReloadClient(html) {
11
135
  const closingBody = /<\/body\s*>/i.exec(html);
@@ -72,6 +196,7 @@ export function createBuildScheduler(options) {
72
196
  let timer;
73
197
  let active = Promise.resolve();
74
198
  let waiters = [];
199
+ const pendingChanges = new Set();
75
200
  const resolveWaiters = () => {
76
201
  const current = waiters;
77
202
  waiters = [];
@@ -80,17 +205,23 @@ export function createBuildScheduler(options) {
80
205
  }
81
206
  };
82
207
  const run = async () => {
208
+ const changedFiles = [...pendingChanges];
209
+ pendingChanges.clear();
210
+ options.onStart?.();
211
+ const startedAt = performance.now();
83
212
  try {
84
- options.onStart?.();
85
- await options.runBuild();
86
- options.onReload();
213
+ await options.runBuild(changedFiles);
214
+ options.onReload(performance.now() - startedAt);
87
215
  }
88
216
  catch (error) {
89
217
  options.onError(error instanceof Error ? error : new Error(String(error)));
90
218
  }
91
219
  };
92
220
  return {
93
- schedule: () => new Promise((resolveSchedule) => {
221
+ schedule: (changedPath) => new Promise((resolveSchedule) => {
222
+ if (changedPath) {
223
+ pendingChanges.add(changedPath);
224
+ }
94
225
  waiters.push(resolveSchedule);
95
226
  if (timer) {
96
227
  clearTimeout(timer);
@@ -110,30 +241,37 @@ export async function startDevServer(options) {
110
241
  const clients = new Set();
111
242
  const watchers = [];
112
243
  const outDir = resolve(options.outDir);
113
- const notifyReload = () => {
244
+ const cwd = options.cwd ?? process.cwd();
245
+ const sendEvent = (event, data) => {
246
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
114
247
  for (const client of clients) {
115
- client.write("event: reload\ndata: {}\n\n");
248
+ client.write(payload);
116
249
  }
117
250
  };
251
+ const notifyReload = () => sendEvent("reload", {});
118
252
  const scheduler = createBuildScheduler({
119
253
  debounceMs,
120
254
  runBuild: options.buildCommand
121
- ? () => runBuildCommand(options.buildCommand, options.cwd ?? process.cwd())
255
+ ? (changedFiles) => runBuildCommand(options.buildCommand, cwd, changedFiles)
122
256
  : async () => undefined,
123
257
  onStart: () => {
124
258
  if (options.buildCommand) {
125
259
  logger.log(`StoneAge dev rebuild started: ${options.buildCommand}`);
126
260
  }
261
+ sendEvent("building", {});
262
+ },
263
+ onReload: (durationMs) => {
264
+ logger.log(`StoneAge dev reload (${Math.round(durationMs)}ms)`);
265
+ sendEvent("ready", { durationMs: Math.round(durationMs) });
127
266
  },
128
- onReload: () => {
129
- logger.log("StoneAge dev reload");
130
- notifyReload();
267
+ onError: (error) => {
268
+ logger.error(`StoneAge dev build failed: ${error.message}`);
269
+ sendEvent("build-error", { message: error.message });
131
270
  },
132
- onError: (error) => logger.error(`StoneAge dev build failed: ${error.message}`),
133
271
  });
134
272
  if (options.buildCommand) {
135
273
  logger.log(`StoneAge dev initial build: ${options.buildCommand}`);
136
- await runBuildCommand(options.buildCommand, options.cwd ?? process.cwd()).catch((error) => {
274
+ await runBuildCommand(options.buildCommand, cwd).catch((error) => {
137
275
  logger.error(`StoneAge dev initial build failed: ${error instanceof Error ? error.message : String(error)}`);
138
276
  });
139
277
  }
@@ -174,8 +312,8 @@ export async function startDevServer(options) {
174
312
  });
175
313
  await listen(server, port, host);
176
314
  for (const watchPath of options.watchPaths ?? []) {
177
- watchers.push(...(await watchDirectories(resolve(options.cwd ?? process.cwd(), watchPath), outDir, () => {
178
- void scheduler.schedule();
315
+ watchers.push(...(await watchDirectories(resolve(cwd, watchPath), outDir, (changedPath) => {
316
+ void scheduler.schedule(relative(cwd, changedPath));
179
317
  })));
180
318
  }
181
319
  const address = server.address();
@@ -184,6 +322,9 @@ export async function startDevServer(options) {
184
322
  return {
185
323
  url,
186
324
  reload: notifyReload,
325
+ rebuild: () => {
326
+ void scheduler.schedule();
327
+ },
187
328
  close: async () => {
188
329
  for (const watcher of watchers) {
189
330
  watcher.close();
@@ -197,12 +338,21 @@ export async function startDevServer(options) {
197
338
  },
198
339
  };
199
340
  }
200
- export function runBuildCommand(command, cwd = process.cwd()) {
341
+ const changedFilesEnvLimit = 50;
342
+ export function runBuildCommand(command, cwd = process.cwd(), changedFiles = []) {
343
+ const env = { ...process.env };
344
+ if (changedFiles.length > 0 && changedFiles.length <= changedFilesEnvLimit) {
345
+ env.STONEAGE_CHANGED_FILES = changedFiles.join("\n");
346
+ }
347
+ else {
348
+ delete env.STONEAGE_CHANGED_FILES;
349
+ }
201
350
  return new Promise((resolveRun, rejectRun) => {
202
351
  const child = spawn(command, {
203
352
  cwd,
204
353
  shell: true,
205
354
  stdio: "inherit",
355
+ env,
206
356
  });
207
357
  child.on("error", rejectRun);
208
358
  child.on("exit", (code, signal) => {
@@ -232,7 +382,7 @@ async function watchDirectories(root, outDir, onChange) {
232
382
  const fileName = basename(root);
233
383
  watchers.push(watch(fileDir, (_eventType, filename) => {
234
384
  if (!filename || String(filename) === fileName) {
235
- onChange();
385
+ onChange(root);
236
386
  }
237
387
  }));
238
388
  return watchers;
@@ -259,8 +409,10 @@ async function watchDirectories(root, outDir, onChange) {
259
409
  void visit(changedPath);
260
410
  }
261
411
  }, () => undefined);
412
+ onChange(changedPath);
413
+ return;
262
414
  }
263
- onChange();
415
+ onChange(dir);
264
416
  }));
265
417
  const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
266
418
  for (const entry of entries) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@t09tanaka/stoneage",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "A data-site static site generator for fast plain HTML output.",
6
6
  "keywords": [