@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 +29 -0
- package/README.md +61 -0
- package/dist/assets.d.ts +17 -2
- package/dist/assets.js +11 -3
- package/dist/core.d.ts +23 -2
- package/dist/core.js +30 -3
- 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,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
|
|
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,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
|
|
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
|
-
|
|
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">
|
|
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) {
|