@t09tanaka/stoneage 0.2.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,42 @@
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
+
32
+ ## 0.3.0 - 2026-06-30
33
+
34
+ - Added global stylesheet auto-inlining via `assets.inlineStylesheets` (inline
35
+ every generated stylesheet) and `assets.inlineStylesheetMaxBytes` (inline only
36
+ bundles whose built CSS is within the byte threshold). A per-stylesheet
37
+ `inline` flag still wins; when both options are set the byte threshold gates
38
+ inlining. Google Fonts WOFF2 stay vendored and preload hints are unchanged.
39
+
3
40
  ## 0.2.0 - 2026-06-30
4
41
 
5
42
  - Added immutable `Cache-Control: public, max-age=31536000, immutable` coverage
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
@@ -270,6 +315,36 @@ files; an inlined `<style>` is subject to the page's Content-Security-Policy
270
315
  `style-src` directive, so keep the external link if you serve a strict CSP
271
316
  without `'unsafe-inline'` (or a matching hash/nonce).
272
317
 
318
+ To inline without flagging each stylesheet, set a global policy on `assets`.
319
+ `assets.inlineStylesheets: true` inlines every generated stylesheet (top-level
320
+ and per-page client ones), and `assets.inlineStylesheetMaxBytes: <n>` inlines
321
+ only bundles whose built CSS is at most `n` bytes (UTF-8) — handy for inlining a
322
+ small critical bundle while larger ones stay external. A per-stylesheet `inline`
323
+ flag always wins over the global policy: `inline: false` opts a sheet out and
324
+ `inline: true` opts one in regardless of the threshold. When both options are
325
+ set, `inlineStylesheetMaxBytes` acts as a gate — a bundle over the limit stays
326
+ external even with `inlineStylesheets: true` (a non-positive or non-finite limit
327
+ inlines nothing). The hyogo critical-path setup combines this with font preload
328
+ — inline the small site and fonts CSS while still preloading the primary WOFF2:
329
+
330
+ ```ts
331
+ assets: {
332
+ inlineStylesheetMaxBytes: 16_384, // inline bundles ≤ 16 KB
333
+ stylesheets: [
334
+ { href: "/assets/site.css", source: "dist/site.css" },
335
+ googleFontsStylesheet("/assets/fonts.css", {
336
+ families: ["Zen Maru Gothic:wght@700;900", "Outfit:wght@700;900"],
337
+ text: subsetText,
338
+ preload: 2, // keep the primary WOFF2 off the critical chain
339
+ }),
340
+ ],
341
+ }
342
+ ```
343
+
344
+ Both stylesheets inline as `<style>` (when within the threshold), the WOFF2
345
+ files are still vendored as immutable assets, and the preload hints render ahead
346
+ of the inlined CSS so fonts are discovered without waiting on CSS.
347
+
273
348
  To pull self-hosted fonts off the critical request chain (HTML → CSS → WOFF2),
274
349
  emit `<link rel="preload" as="font" type="font/woff2" crossorigin>` hints with
275
350
  `googleFontsStylesheet("/assets/fonts.css", { families, text, preload: true })`.
@@ -572,6 +647,22 @@ one or more `--watch <path>` options to rebuild on source changes; browsers
572
647
  reload only after the build command succeeds. StoneAge ignores the output
573
648
  directory, `.git`, and `node_modules` while watching to avoid rebuild loops.
574
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
+
575
666
  `benchmark --compare-to <file>` compares the current `.stoneage/benchmark.json`
576
667
  metrics with a previous benchmark report and fails the command when size metrics
577
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,7 +314,39 @@ 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;
327
+ /**
328
+ * Inline every generated stylesheet's CSS into each document `<head>` as a
329
+ * `<style>` element instead of emitting render-blocking
330
+ * `<link rel="stylesheet">`s. Applies to both `assets.stylesheets` and the
331
+ * stylesheets resolved from per-page `assets.client` registries (inlining a
332
+ * client stylesheet turns it into a `<style>` on the pages that use it). A
333
+ * per-stylesheet `inline` flag always wins: `inline: false` opts a sheet out
334
+ * even when this is true, and `inline: true` opts one in. When
335
+ * {@link inlineStylesheetMaxBytes} is also set, the byte threshold gates this:
336
+ * a bundle larger than the threshold stays external even with this enabled.
337
+ * Defaults to the existing external-link behavior. Inlined CSS is subject to
338
+ * the page's CSP `style-src`; see {@link InlineStylesheetAsset} for the caveat.
339
+ */
340
+ inlineStylesheets?: boolean;
341
+ /**
342
+ * Auto-inline a generated stylesheet only when its built CSS is at most this
343
+ * many bytes (UTF-8), measured after Google Fonts CSS resolution. Lets small,
344
+ * critical bundles inline while larger ones stay external. A per-stylesheet
345
+ * `inline` flag still wins over this gate. When {@link inlineStylesheets} is
346
+ * also true, this acts as the gate: bundles over the threshold are not
347
+ * inlined. A non-positive or non-finite value inlines nothing.
348
+ */
349
+ inlineStylesheetMaxBytes?: number;
306
350
  public?: PublicAssetInput[];
307
351
  /**
308
352
  * Directory whose tree is copied verbatim onto the output root. Each file
package/dist/core.js CHANGED
@@ -617,16 +617,25 @@ async function resolveStaticAssetReferences(assets) {
617
617
  }
618
618
  return hashedPath;
619
619
  };
620
- const stylesheets = await Promise.all((assets.stylesheets ?? []).map(async (stylesheet) => resolveStylesheetAsset(stylesheet, resolveImmutablePublicAsset, collectFontPreloads)));
620
+ // Global auto-inline policy, applied to any stylesheet that does not set its
621
+ // own `inline` flag. Threaded through both top-level and per-page client
622
+ // stylesheet resolution so the behavior is consistent everywhere.
623
+ const inlinePolicy = {
624
+ inlineStylesheets: assets.inlineStylesheets,
625
+ inlineStylesheetMaxBytes: assets.inlineStylesheetMaxBytes,
626
+ };
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)));
621
629
  // Per-page client stylesheets are resolved without a preload collector: a
622
630
  // page-scoped font must not become a global preload hint on every page.
623
631
  const client = assets.client
624
- ? await resolveClientAssetRegistry(assets.client, resolveImmutablePublicAsset)
632
+ ? await resolveClientAssetRegistry(assets.client, resolveImmutablePublicAsset, inlinePolicy)
625
633
  : undefined;
626
634
  return {
627
635
  assets: {
628
636
  ...assets,
629
637
  ...(assets.stylesheets ? { stylesheets } : {}),
638
+ ...(assets.scripts ? { scripts } : {}),
630
639
  ...(client ? { client } : {}),
631
640
  },
632
641
  publicAssets: [...publicAssetsByPath.values()],
@@ -634,25 +643,73 @@ async function resolveStaticAssetReferences(assets) {
634
643
  fontPreloads,
635
644
  };
636
645
  }
637
- async function resolveStylesheetAsset(stylesheet, resolveImmutablePublicAsset, onFontPreload) {
646
+ // Decide whether a stylesheet's built CSS should be inlined. A per-stylesheet
647
+ // `inline` flag always wins; otherwise the global policy applies, with
648
+ // `inlineStylesheetMaxBytes` acting as a byte-size gate over `inlineStylesheets`.
649
+ function shouldInlineStylesheet(explicitInline, css, policy) {
650
+ if (explicitInline !== undefined) {
651
+ return explicitInline;
652
+ }
653
+ if (!policy) {
654
+ return false;
655
+ }
656
+ const { inlineStylesheets, inlineStylesheetMaxBytes } = policy;
657
+ if (inlineStylesheetMaxBytes !== undefined) {
658
+ // The threshold gates inlining (even when `inlineStylesheets` is true): a
659
+ // non-positive/non-finite limit inlines nothing, otherwise the built CSS
660
+ // must fit within it.
661
+ if (!Number.isFinite(inlineStylesheetMaxBytes) || inlineStylesheetMaxBytes <= 0) {
662
+ return false;
663
+ }
664
+ return Buffer.byteLength(css, "utf8") <= inlineStylesheetMaxBytes;
665
+ }
666
+ return inlineStylesheets === true;
667
+ }
668
+ // Whether a global policy could ever inline an `inline`-unspecified stylesheet.
669
+ // Lets the resolver skip reading a source file (and keep the byte-identical
670
+ // external path) when inlining is impossible regardless of CSS size.
671
+ function policyMightInline(policy) {
672
+ if (!policy) {
673
+ return false;
674
+ }
675
+ const { inlineStylesheets, inlineStylesheetMaxBytes } = policy;
676
+ if (inlineStylesheetMaxBytes !== undefined) {
677
+ return Number.isFinite(inlineStylesheetMaxBytes) && inlineStylesheetMaxBytes > 0;
678
+ }
679
+ return inlineStylesheets === true;
680
+ }
681
+ async function resolveStylesheetAsset(stylesheet, resolveImmutablePublicAsset, onFontPreload, inlinePolicy) {
638
682
  // Plain href strings and already-inlined CSS pass through untouched.
639
683
  if (typeof stylesheet === "string" || isResolvedInlineStylesheet(stylesheet)) {
640
684
  return stylesheet;
641
685
  }
642
- const inline = stylesheet.inline === true;
686
+ const explicitInline = stylesheet.inline;
643
687
  if ("source" in stylesheet) {
644
- if (inline) {
645
- // Embed the CSS directly: no hashed public file and no `_headers` entry.
646
- return { inline: await readFile(stylesheet.source, "utf8") };
647
- }
648
- if (stylesheet.immutable === false) {
688
+ // `immutable: false` is a deliberate "serve this href as-is" opt-out, so the
689
+ // global inline policy does not touch it; only an explicit `inline: true`
690
+ // can still inline such a sheet.
691
+ if (explicitInline !== true && stylesheet.immutable === false) {
649
692
  return stripStylesheetBuildHints(stylesheet);
650
693
  }
651
- return resolveImmutablePublicAsset({ source: stylesheet.source }, stylesheet.href);
694
+ // When inlining is impossible (explicit opt-out, or no policy that could
695
+ // inline an unspecified sheet), keep the original source-backed external
696
+ // path so its hashed output stays byte-identical to before.
697
+ if (explicitInline === false || (explicitInline === undefined && !policyMightInline(inlinePolicy))) {
698
+ return resolveImmutablePublicAsset({ source: stylesheet.source }, stylesheet.href);
699
+ }
700
+ // Read the CSS once and reuse it for both the size check and inlining so the
701
+ // file is never read twice.
702
+ const css = await readFile(stylesheet.source, "utf8");
703
+ if (shouldInlineStylesheet(explicitInline, css, inlinePolicy)) {
704
+ // Embed the CSS directly: no hashed public file and no `_headers` entry.
705
+ return { inline: css };
706
+ }
707
+ return resolveImmutablePublicAsset({ content: css }, stylesheet.href);
652
708
  }
653
709
  // Google Fonts: an `immutable: false` reference is not vendored (legacy
654
- // behavior), so there are no woff2 to preload or CSS to inline.
655
- if (!inline && stylesheet.immutable === false) {
710
+ // behavior), so there are no woff2 to preload or CSS to inline. As above, the
711
+ // global policy leaves this opt-out alone unless `inline: true` is explicit.
712
+ if (explicitInline !== true && stylesheet.immutable === false) {
656
713
  return stripStylesheetBuildHints(stylesheet);
657
714
  }
658
715
  const { css, fontPaths } = await resolveGoogleFontsStylesheet(stylesheet.href, stylesheet.googleFonts, resolveImmutablePublicAsset);
@@ -663,7 +720,9 @@ async function resolveStylesheetAsset(stylesheet, resolveImmutablePublicAsset, o
663
720
  }
664
721
  }
665
722
  // The woff2 files are always vendored above; only the CSS is inlined.
666
- return inline ? { inline: css } : resolveImmutablePublicAsset({ content: css }, stylesheet.href);
723
+ return shouldInlineStylesheet(explicitInline, css, inlinePolicy)
724
+ ? { inline: css }
725
+ : resolveImmutablePublicAsset({ content: css }, stylesheet.href);
667
726
  }
668
727
  function isResolvedInlineStylesheet(stylesheet) {
669
728
  // The resolved inline form carries the CSS text as a string; the input forms
@@ -689,13 +748,17 @@ function selectFontPreloads(fontPaths, preload) {
689
748
  }
690
749
  return fontPaths.slice(0, Math.floor(preload));
691
750
  }
692
- async function resolveClientAssetRegistry(registry, resolveImmutablePublicAsset) {
751
+ async function resolveClientAssetRegistry(registry, resolveImmutablePublicAsset, inlinePolicy) {
693
752
  const entries = await Promise.all(Object.entries(registry).map(async ([id, declarations]) => [
694
753
  id,
695
754
  await Promise.all((Array.isArray(declarations) ? declarations : [declarations]).map(async (declaration) => ({
696
755
  ...(declaration.stylesheets
697
756
  ? {
698
- stylesheets: await Promise.all(declaration.stylesheets.map((stylesheet) => resolveStylesheetAsset(stylesheet, resolveImmutablePublicAsset))),
757
+ stylesheets: await Promise.all(declaration.stylesheets.map((stylesheet) =>
758
+ // No preload collector here: a page-scoped client font must
759
+ // not become a global hint. The global inline policy still
760
+ // applies so client CSS inlines consistently.
761
+ resolveStylesheetAsset(stylesheet, resolveImmutablePublicAsset, undefined, inlinePolicy))),
699
762
  }
700
763
  : {}),
701
764
  ...(declaration.scripts
@@ -708,6 +771,9 @@ async function resolveClientAssetRegistry(registry, resolveImmutablePublicAsset)
708
771
  return Object.fromEntries(entries);
709
772
  }
710
773
  async function resolveClientScriptAsset(script, resolveImmutablePublicAsset) {
774
+ if ("inline" in script) {
775
+ return script;
776
+ }
711
777
  if (!script.source || script.immutable === false) {
712
778
  const { source: _source, immutable: _immutable, ...runtimeScript } = script;
713
779
  return runtimeScript;
@@ -987,6 +1053,13 @@ export async function buildSite(config) {
987
1053
  const sharedPageFingerprintHash = hashValue({
988
1054
  site: config.site,
989
1055
  stylesheets: assets?.stylesheets ?? [],
1056
+ scripts: assets?.scripts ?? [],
1057
+ // The global inline policy can flip a stylesheet between external and
1058
+ // inlined; including the options invalidates every page when the policy
1059
+ // changes, even for pages whose CSS lives only in `assets.client` (whose
1060
+ // resolved forms are not part of this shared hash).
1061
+ inlineStylesheets: assets?.inlineStylesheets ?? false,
1062
+ inlineStylesheetMaxBytes: assets?.inlineStylesheetMaxBytes ?? null,
990
1063
  fontPreloads,
991
1064
  prefetch: config.prefetch ?? {},
992
1065
  trailingSlash,
@@ -2064,6 +2137,7 @@ function renderDocument(site, metadata, canonical, body, assets, prefetch, trail
2064
2137
  : `<link rel="stylesheet" href="${escapeAttribute(stylesheetHref(stylesheet))}">`)
2065
2138
  .join("");
2066
2139
  const clientScripts = renderClientScripts([
2140
+ ...(assets?.scripts ?? []),
2067
2141
  ...extraClientScripts,
2068
2142
  ...pageClientAssets.scripts,
2069
2143
  ]);
@@ -2103,11 +2177,19 @@ function renderTwitterMetadata(siteTwitter, pageTwitter) {
2103
2177
  function renderMetaName(name, content) {
2104
2178
  return `<meta name="${escapeAttribute(name)}" content="${escapeAttribute(content)}">`;
2105
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
+ }
2106
2185
  function renderHeadTag(tag) {
2186
+ if (tag.tag === "script") {
2187
+ return `<script${renderHeadAttributes(tag.attrs)}>${escapeScriptContent(tag.children ?? "")}</script>`;
2188
+ }
2107
2189
  return `<${tag.tag}${renderHeadAttributes(tag.attrs)}>`;
2108
2190
  }
2109
2191
  function renderHeadAttributes(attrs) {
2110
- return Object.entries(attrs)
2192
+ return Object.entries(attrs ?? {})
2111
2193
  .sort(([left], [right]) => left.localeCompare(right))
2112
2194
  .map(([name, value]) => {
2113
2195
  if (value === false || value === null || value === undefined) {
@@ -2144,7 +2226,15 @@ function escapeInlineStyleContent(css) {
2144
2226
  return css.replace(/<\/(style)/gi, "<\\/$1");
2145
2227
  }
2146
2228
  function renderClientScripts(scripts) {
2147
- 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);
2148
2238
  }
2149
2239
  function renderScriptAttributes(script) {
2150
2240
  let output = script.module ? ' type="module"' : "";
@@ -2155,7 +2245,11 @@ function renderScriptAttributes(script) {
2155
2245
  if (script.async) {
2156
2246
  output += " async";
2157
2247
  }
2158
- 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))) {
2159
2253
  if (value === false || value === null || value === undefined) {
2160
2254
  continue;
2161
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.2.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": [