@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 +37 -0
- package/README.md +91 -0
- package/dist/assets.d.ts +17 -2
- package/dist/assets.js +11 -3
- package/dist/core.d.ts +46 -2
- package/dist/core.js +112 -18
- package/dist/dev.d.ts +5 -4
- package/dist/dev.js +171 -19
- package/package.json +1 -1
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
|
|
137
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
686
|
+
const explicitInline = stylesheet.inline;
|
|
643
687
|
if ("source" in stylesheet) {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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">
|
|
8
|
-
|
|
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.
|
|
85
|
-
|
|
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
|
|
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(
|
|
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,
|
|
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
|
-
|
|
129
|
-
logger.
|
|
130
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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) {
|