@t09tanaka/stoneage 0.1.0 → 0.2.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,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 - 2026-06-30
4
+
5
+ - Added immutable `Cache-Control: public, max-age=31536000, immutable` coverage
6
+ for content-hashed, `/_assets/`, and `/_stoneage/` artifacts that previously
7
+ lacked it, including the deferred fragment client.
8
+ - Added opt-in `publishing.htmlCacheControl` to emit a host-wide `/*` HTML
9
+ revalidate default, overridden by more specific immutable asset rules.
10
+ - Added opt-in `publishing.securityHeaders` (HSTS, `X-Content-Type-Options`,
11
+ `X-Frame-Options`, `Referrer-Policy`, and an `extra` map) emitted as a `/*`
12
+ rule.
13
+ - Added `publishing.publishManifests` (default `true`) to drop the intermediate
14
+ `headers.json` / `redirects.json` from published output once host-native
15
+ `_headers` / `_redirects` are written.
16
+ - Added an `s3` deploy provider that emits a host-neutral `s3-deploy.json`
17
+ descriptor (with per-rule content-type and Cache-Control) and a
18
+ `cloudfront-function.js` for pretty-URL and 403/404 -> `/404.html` routing on
19
+ S3 + CloudFront, without an AWS SDK dependency.
20
+ - Added opt-in critical CSS inlining via stylesheet `inline`, emitting a
21
+ `<style>` element instead of a render-blocking `<link rel="stylesheet">`.
22
+ - Added self-hosted subset font preloading via `preload` (all or the first N
23
+ fonts) and ensured `font-display: swap`.
24
+ - Changed the published package to exclude `docs/`.
25
+
3
26
  ## 0.1.0 - 2026-06-29
4
27
 
5
28
  - Added the initial StoneAge data-site SSG release.
package/README.md CHANGED
@@ -254,7 +254,31 @@ For Google Fonts CSS2 subsets, use `googleFontsStylesheet("/assets/fonts.css",
254
254
  { families: ["Zen Maru Gothic:wght@700;900", "Outfit:wght@700;900"], text:
255
255
  subsetText })`. StoneAge fetches the CSS and WOFF2 files at build time, rewrites
256
256
  `@font-face` URLs to local content-hashed assets, and emits immutable cache
257
- headers for the generated CSS and font files.
257
+ headers for the generated CSS and font files. The fetched CSS keeps Google's
258
+ `font-display: swap` (the request defaults to `display=swap`), so fonts never
259
+ block first paint with invisible text.
260
+
261
+ To cut render-blocking requests, small critical stylesheets can be inlined into
262
+ the document `<head>` as a `<style>` element instead of a `<link
263
+ rel="stylesheet">`. Opt in per stylesheet with `inline: true` — for `source`
264
+ stylesheets `{ href, source, inline: true }`, and for Google Fonts
265
+ `googleFontsStylesheet("/assets/fonts.css", { families, text, inline: true })`.
266
+ Inlined CSS becomes part of the HTML, so it gets no hashed public file and no
267
+ `_headers` entry; for Google Fonts only the CSS is inlined while the WOFF2 files
268
+ are still vendored as immutable, cacheable assets. Inlining is best for small
269
+ files; an inlined `<style>` is subject to the page's Content-Security-Policy
270
+ `style-src` directive, so keep the external link if you serve a strict CSP
271
+ without `'unsafe-inline'` (or a matching hash/nonce).
272
+
273
+ To pull self-hosted fonts off the critical request chain (HTML → CSS → WOFF2),
274
+ emit `<link rel="preload" as="font" type="font/woff2" crossorigin>` hints with
275
+ `googleFontsStylesheet("/assets/fonts.css", { families, text, preload: true })`.
276
+ `preload: true` preloads every WOFF2 the stylesheet resolves; pass a number
277
+ (`preload: 1`) to preload only the first N fonts in CSS source order and avoid
278
+ over-preloading subset ranges the page may not use. Preload hints render before
279
+ the stylesheet and are only honored on top-level `assets.stylesheets`; fonts
280
+ declared in per-page client assets are not preloaded so a hint is never forced
281
+ onto every page.
258
282
 
259
283
  For Vite builds, `loadViteBuildAssetsFromManifest("dist/.vite/manifest.json", {
260
284
  sourceDir: "dist", base: "/", entriesOnly: true })` reads a Vite manifest and
@@ -744,6 +768,24 @@ never overwritten), and non-internal sources are left to the manifest. Like
744
768
  `redirects.json` itself, these stubs are not swept on incremental builds, so
745
769
  clear `dist/` for a clean rebuild after removing redirect rules.
746
770
 
771
+ Run `deploy --provider s3` to target AWS S3 + CloudFront. Instead of Netlify
772
+ files it writes two host-agnostic artifacts next to the output: `s3-deploy.json`
773
+ and `cloudfront-function.js`. `s3-deploy.json` is a provider-neutral deploy
774
+ descriptor derived from the same `redirects.json`/`headers.json` manifests: each
775
+ header rule carries the `contentType` and `cacheControl` to apply when uploading
776
+ the matching objects (so content-hashed assets keep
777
+ `public, max-age=31536000, immutable` and re-syncs do not drop it), a
778
+ `defaults.cacheControl` short-revalidation value covers files no rule matches,
779
+ `redirects` preserves the abstract redirect list, and `errorResponses` maps
780
+ CloudFront 403/404 origin errors to `/404.html`. `cloudfront-function.js` is a
781
+ viewer-request CloudFront Function that resolves pretty URLs to the object the
782
+ build actually wrote. Pass `--trailing-slash always` (default) for sites whose
783
+ pages live at `dir/index.html` (resolves `/dir/` and `/dir`), or
784
+ `--trailing-slash never` for `trailing-slash never` builds whose pages live at
785
+ `name.html` (resolves `/about` to `/about.html`); the chosen style is recorded
786
+ in `s3-deploy.json`. The command does not upload to AWS; it emits the metadata
787
+ and routing logic an upload step or infrastructure template consumes.
788
+
747
789
  `benchmark` measures generated output sizes without precompressing public files;
748
790
  runtime Brotli/Gzip compression is expected to be handled by the serving layer.
749
791
  The benchmark command accepts `--trailing-slash always` or
@@ -1 +1 @@
1
- export declare const stoneAgeAgentSkill = "---\nname: stoneage\ndescription: Use when working on a StoneAge static data-site, adding routes or artifacts, debugging generated output, validating publish readiness, or helping an agent use StoneAge in a project.\n---\n\n# StoneAge\n\nUse this skill when a project uses StoneAge or asks to generate, validate, inspect, optimize, or serve a StoneAge static data-site.\n\n## Project Direction\n\nStoneAge is a TypeScript static site generator for large data sites. Optimize for plain HTML output, explicit data flow, typed route generation, incremental generation from dependency hashes, publish validation, and small assets. Do not turn StoneAge into a general web application framework.\n\n## Minimal Example\n\nUse these real API names and import paths; do not invent others. Core build: `buildSite`. HTML pages: `defineRouteFamily`. Public data outputs: `defineArtifactFamily` with `jsonArtifact` / `csvArtifact`. Deferred HTML fragments: `defineFragmentFamily` plus `deferredFragment` from the `/fragment` subpath. HTML rendering: `html` / `renderToString` from the `/html` subpath.\n\n```ts\nimport {\n buildSite,\n defineRouteFamily,\n defineArtifactFamily,\n defineFragmentFamily,\n jsonArtifact,\n csvArtifact,\n type SiteConfig,\n} from \"@t09tanaka/stoneage\";\nimport { deferredFragment } from \"@t09tanaka/stoneage/fragment\";\nimport { html, renderToString } from \"@t09tanaka/stoneage/html\";\n\nconst members = [{ id: \"m1\", slug: \"ada\", name: \"Ada\", district: \"Central\" }];\n\nconst site: SiteConfig = {\n baseUrl: \"https://example.com\",\n title: \"Example\",\n description: \"A generated data-site.\",\n};\n\nconst memberDetails = defineFragmentFamily({\n name: \"memberDetails\",\n pattern: \"members/:slug/details\",\n entries: () =>\n members.map((member) => ({\n params: { slug: member.slug },\n dependencies: [\"member:\" + member.id],\n render: () => renderToString(html(\"section\", null, member.name + \" details\")),\n })),\n});\n\nconst memberPages = defineRouteFamily({\n name: \"members\",\n pattern: \"/members/:slug/\",\n entries: () =>\n members.map((member) => ({\n params: { slug: member.slug },\n dependencies: [\"member:\" + member.id],\n metadata: { title: member.name, description: member.name + \" (\" + member.district + \")\" },\n render: () =>\n renderToString(\n html(\n \"main\",\n null,\n html(\"h1\", null, member.name),\n deferredFragment({\n src: memberDetails.path({ slug: member.slug }),\n fallback: html(\"a\", { href: \"/members/\" + member.slug + \"/\" }, \"Read details\"),\n }),\n ),\n ),\n })),\n});\n\nconst memberData = defineArtifactFamily({\n name: \"memberData\",\n pattern: \"/data/members.json\",\n entries: () => [\n {\n params: {},\n dependencies: members.map((member) => \"member:\" + member.id),\n render: () => jsonArtifact(members),\n },\n ],\n});\n\nawait buildSite({\n outDir: \"dist\",\n site,\n routes: [memberPages],\n artifacts: [memberData],\n fragments: [memberDetails],\n});\n```\n\n`render` must return a string (wrap TSX or nodes with `renderToString`). Use `csvArtifact(text)` for CSV outputs. `dependencies` is a `Dependency[]`: a plain `\"key\"` string, or `{ key, hash }` / `{ key, value }` / `{ key, file }` when the site computes its own fingerprints.\n\n## Before Editing\n\n- Search existing buildSite usage before changing a site.\n- Read the local site build entrypoint, route families, artifact families, data normalization modules, and validation config.\n- Keep public data artifacts separate from HTML-only view models.\n- Prefer structured data models and parsers over ad hoc string manipulation.\n- If changing shared types, route patterns, artifact patterns, or build config contracts, inspect references and run TypeScript diagnostics.\n\n## Implementation Rules\n\n- Use route families for HTML pages and artifact families for public JSON, CSV, XML, text, or endpoint-style outputs.\n- Use fragment families for supplemental HTML that may load after the initial page. `deferredFragment()` provides a fragment self-link fallback by default; override it with a canonical page link when one exists, or pass `fallback: null` only when no-JS omission is acceptable. Do not defer the only copy of primary page content.\n- Attach dependencies to generated pages and artifacts so incremental rebuilds can skip unchanged output.\n- Use stable hashes for normalized data or source fingerprints when the site already computes them.\n- Keep rendering functions focused on HTML; keep normalization outside rendering.\n- Add metadata, sitemap, assets, redirects, headers, and validation settings explicitly in the build config.\n- Avoid browser runtime assumptions in core rendering code.\n\n## Useful Commands\n\n- `stoneage dev --out-dir <dir> --watch <path> --build-command \"<command>\"`: serve generated output with hot reload after successful rebuilds.\n- `stoneage validate --out-dir <dir>`: validate generated output before publishing.\n- `stoneage inspect --out-dir <dir> --dependency <key>`: list generated outputs depending on one dependency key.\n- `stoneage plan --out-dir <dir> --dependency <key>`: plan outputs affected by dependency keys.\n- `stoneage optimize --out-dir <dir>`: write precompressed public output sidecars.\n- `stoneage deploy --out-dir <dir> --provider netlify`: emit deploy artifacts.\n\n## Verification\n\n- Run the project's build command after route, artifact, or data-flow changes.\n- Run `stoneage validate` for publish-facing output changes.\n- Run targeted tests for changed data, routes, artifacts, CLI behavior, or validation logic.\n- For package changes in the StoneAge repository, run `npm run typecheck`, relevant `npm test -- <files>`, and `npm run check:package`.\n\n## Avoid\n\n- Do not mix public data exports into HTML-only view models.\n- Do not hide metadata or sitemap decisions inside templates.\n- Do not add compatibility layers, runtime hydration, or app-framework abstractions unless the user explicitly asks.\n- Do not rewrite generated output paths, route contracts, or dependency keys without checking existing manifests and callers.\n\n## References\n\nWhen a detail is not covered here, read the package docs instead of guessing the API: `getting-started.md` (minimal site), `site-build.md` (route families, artifact families, metadata, assets, validation, publishing), `fragments.md` (deferred fragments and triggers), `components.md` (`html()`, `renderToString()`, TSX, islands), `data-flow.md` (normalized data, dependencies, incremental rebuilds), and `migration.md` (SvelteKit). The public API is exported from `@t09tanaka/stoneage`, `@t09tanaka/stoneage/html`, and `@t09tanaka/stoneage/fragment`.\n";
1
+ export declare const stoneAgeAgentSkill = "---\nname: stoneage\ndescription: Use when working on a StoneAge static data-site, adding routes or artifacts, debugging generated output, validating publish readiness, or helping an agent use StoneAge in a project.\n---\n\n# StoneAge\n\nUse this skill when a project uses StoneAge or asks to generate, validate, inspect, optimize, or serve a StoneAge static data-site.\n\n## Project Direction\n\nStoneAge is a TypeScript static site generator for large data sites. Optimize for plain HTML output, explicit data flow, typed route generation, incremental generation from dependency hashes, publish validation, and small assets. Do not turn StoneAge into a general web application framework.\n\n## Minimal Example\n\nUse these real API names and import paths; do not invent others. Core build: `buildSite`. HTML pages: `defineRouteFamily`. Public data outputs: `defineArtifactFamily` with `jsonArtifact` / `csvArtifact`. Deferred HTML fragments: `defineFragmentFamily` plus `deferredFragment` from the `/fragment` subpath. HTML rendering: `html` / `renderToString` from the `/html` subpath.\n\n```ts\nimport {\n buildSite,\n defineRouteFamily,\n defineArtifactFamily,\n defineFragmentFamily,\n jsonArtifact,\n csvArtifact,\n type SiteConfig,\n} from \"@t09tanaka/stoneage\";\nimport { deferredFragment } from \"@t09tanaka/stoneage/fragment\";\nimport { html, renderToString } from \"@t09tanaka/stoneage/html\";\n\nconst members = [{ id: \"m1\", slug: \"ada\", name: \"Ada\", district: \"Central\" }];\n\nconst site: SiteConfig = {\n baseUrl: \"https://example.com\",\n title: \"Example\",\n description: \"A generated data-site.\",\n};\n\nconst memberDetails = defineFragmentFamily({\n name: \"memberDetails\",\n pattern: \"members/:slug/details\",\n entries: () =>\n members.map((member) => ({\n params: { slug: member.slug },\n dependencies: [\"member:\" + member.id],\n render: () => renderToString(html(\"section\", null, member.name + \" details\")),\n })),\n});\n\nconst memberPages = defineRouteFamily({\n name: \"members\",\n pattern: \"/members/:slug/\",\n entries: () =>\n members.map((member) => ({\n params: { slug: member.slug },\n dependencies: [\"member:\" + member.id],\n metadata: { title: member.name, description: member.name + \" (\" + member.district + \")\" },\n render: () =>\n renderToString(\n html(\n \"main\",\n null,\n html(\"h1\", null, member.name),\n deferredFragment({\n src: memberDetails.path({ slug: member.slug }),\n fallback: html(\"a\", { href: \"/members/\" + member.slug + \"/\" }, \"Read details\"),\n }),\n ),\n ),\n })),\n});\n\nconst memberData = defineArtifactFamily({\n name: \"memberData\",\n pattern: \"/data/members.json\",\n entries: () => [\n {\n params: {},\n dependencies: members.map((member) => \"member:\" + member.id),\n render: () => jsonArtifact(members),\n },\n ],\n});\n\nawait buildSite({\n outDir: \"dist\",\n site,\n routes: [memberPages],\n artifacts: [memberData],\n fragments: [memberDetails],\n});\n```\n\n`render` must return a string (wrap TSX or nodes with `renderToString`). Use `csvArtifact(text)` for CSV outputs. `dependencies` is a `Dependency[]`: a plain `\"key\"` string, or `{ key, hash }` / `{ key, value }` / `{ key, file }` when the site computes its own fingerprints.\n\n## Before Editing\n\n- Search existing buildSite usage before changing a site.\n- Read the local site build entrypoint, route families, artifact families, data normalization modules, and validation config.\n- Keep public data artifacts separate from HTML-only view models.\n- Prefer structured data models and parsers over ad hoc string manipulation.\n- If changing shared types, route patterns, artifact patterns, or build config contracts, inspect references and run TypeScript diagnostics.\n\n## Implementation Rules\n\n- Use route families for HTML pages and artifact families for public JSON, CSV, XML, text, or endpoint-style outputs.\n- Use fragment families for supplemental HTML that may load after the initial page. `deferredFragment()` provides a fragment self-link fallback by default; override it with a canonical page link when one exists, or pass `fallback: null` only when no-JS omission is acceptable. Do not defer the only copy of primary page content.\n- Attach dependencies to generated pages and artifacts so incremental rebuilds can skip unchanged output.\n- Use stable hashes for normalized data or source fingerprints when the site already computes them.\n- Keep rendering functions focused on HTML; keep normalization outside rendering.\n- Add metadata, sitemap, assets, redirects, headers, and validation settings explicitly in the build config.\n- Avoid browser runtime assumptions in core rendering code.\n\n## Useful Commands\n\n- `stoneage dev --out-dir <dir> --watch <path> --build-command \"<command>\"`: serve generated output with hot reload after successful rebuilds.\n- `stoneage validate --out-dir <dir>`: validate generated output before publishing.\n- `stoneage inspect --out-dir <dir> --dependency <key>`: list generated outputs depending on one dependency key.\n- `stoneage plan --out-dir <dir> --dependency <key>`: plan outputs affected by dependency keys.\n- `stoneage optimize --out-dir <dir>`: write precompressed public output sidecars.\n- `stoneage deploy --out-dir <dir> --provider <netlify|github-pages|s3>`: emit provider-specific deploy artifacts (s3 writes s3-deploy.json + cloudfront-function.js).\n\n## Verification\n\n- Run the project's build command after route, artifact, or data-flow changes.\n- Run `stoneage validate` for publish-facing output changes.\n- Run targeted tests for changed data, routes, artifacts, CLI behavior, or validation logic.\n- For package changes in the StoneAge repository, run `npm run typecheck`, relevant `npm test -- <files>`, and `npm run check:package`.\n\n## Avoid\n\n- Do not mix public data exports into HTML-only view models.\n- Do not hide metadata or sitemap decisions inside templates.\n- Do not add compatibility layers, runtime hydration, or app-framework abstractions unless the user explicitly asks.\n- Do not rewrite generated output paths, route contracts, or dependency keys without checking existing manifests and callers.\n\n## References\n\nWhen a detail is not covered here, read the package docs instead of guessing the API: `getting-started.md` (minimal site), `site-build.md` (route families, artifact families, metadata, assets, validation, publishing), `fragments.md` (deferred fragments and triggers), `components.md` (`html()`, `renderToString()`, TSX, islands), `data-flow.md` (normalized data, dependencies, incremental rebuilds), and `migration.md` (SvelteKit). The public API is exported from `@t09tanaka/stoneage`, `@t09tanaka/stoneage/html`, and `@t09tanaka/stoneage/fragment`.\n";
@@ -118,7 +118,7 @@ await buildSite({
118
118
  - \`stoneage inspect --out-dir <dir> --dependency <key>\`: list generated outputs depending on one dependency key.
119
119
  - \`stoneage plan --out-dir <dir> --dependency <key>\`: plan outputs affected by dependency keys.
120
120
  - \`stoneage optimize --out-dir <dir>\`: write precompressed public output sidecars.
121
- - \`stoneage deploy --out-dir <dir> --provider netlify\`: emit deploy artifacts.
121
+ - \`stoneage deploy --out-dir <dir> --provider <netlify|github-pages|s3>\`: emit provider-specific deploy artifacts (s3 writes s3-deploy.json + cloudfront-function.js).
122
122
 
123
123
  ## Verification
124
124
 
package/dist/assets.d.ts CHANGED
@@ -16,15 +16,51 @@ export type GoogleFontsStylesheetOptions = {
16
16
  fontBasePath?: string;
17
17
  userAgent?: string;
18
18
  fetch?: AssetFetch;
19
+ /**
20
+ * Emit `<link rel="preload" as="font" type="font/woff2" crossorigin>` hints for
21
+ * the vendored woff2 files so they leave the critical request chain (HTML →
22
+ * CSS → woff2). `true` preloads every woff2 the stylesheet resolves; a number
23
+ * preloads only the first N in CSS source order, so you can limit hints to the
24
+ * primary fonts and avoid over-preloading subset ranges the page may not use.
25
+ * Defaults to no preloads. Preload is only honored on top-level
26
+ * `assets.stylesheets`; fonts declared in per-page client assets are ignored.
27
+ */
28
+ preload?: boolean | number;
19
29
  };
20
- export type ClientStylesheetAsset = string | {
30
+ /**
31
+ * Resolved representation of a stylesheet whose CSS is embedded directly in the
32
+ * document `<head>` as a `<style>` element (see `inline` below). It carries the
33
+ * final CSS text rather than a public href. Note: an inlined `<style>` is
34
+ * subject to the page's Content-Security-Policy `style-src` directive; sites
35
+ * that send a strict CSP without `'unsafe-inline'` (or a matching hash/nonce)
36
+ * should keep the stylesheet as an external link instead.
37
+ */
38
+ export type InlineStylesheetAsset = {
39
+ inline: string;
40
+ };
41
+ export type ClientStylesheetAsset = string | InlineStylesheetAsset | {
21
42
  href: string;
22
43
  source: string;
23
44
  immutable?: boolean;
45
+ /**
46
+ * Inline the CSS into the document `<head>` as a `<style>` element instead
47
+ * of emitting a render-blocking `<link rel="stylesheet">`. The CSS is then
48
+ * part of the HTML, so no hashed public file or `_headers` entry is written
49
+ * for it. Best for small, critical CSS; see {@link InlineStylesheetAsset}
50
+ * for the CSP caveat.
51
+ */
52
+ inline?: boolean;
24
53
  } | {
25
54
  href: string;
26
55
  googleFonts: GoogleFontsStylesheetOptions;
27
56
  immutable?: boolean;
57
+ /**
58
+ * Inline the resolved Google Fonts CSS into the document `<head>`. The
59
+ * woff2 files are still vendored as immutable, cacheable assets; only the
60
+ * small CSS is embedded. See {@link InlineStylesheetAsset} for the CSP
61
+ * caveat.
62
+ */
63
+ inline?: boolean;
28
64
  };
29
65
  export type ClientScriptAsset = {
30
66
  src: string;
@@ -91,9 +127,12 @@ export declare function defineClientAssets<T extends ClientAssetRegistry>(assets
91
127
  export type StylesheetAssetOptions = {
92
128
  source: string;
93
129
  immutable?: boolean;
130
+ inline?: boolean;
94
131
  };
95
132
  export declare function stylesheet(href: string, options?: StylesheetAssetOptions): ClientAssetDeclaration;
96
- export declare function googleFontsStylesheet(href: string, options: GoogleFontsStylesheetOptions): ClientStylesheetAsset;
133
+ export declare function googleFontsStylesheet(href: string, options: GoogleFontsStylesheetOptions & {
134
+ inline?: boolean;
135
+ }): ClientStylesheetAsset;
97
136
  export declare function script(src: string, options?: Omit<ClientScriptAsset, "src">): ClientAssetDeclaration;
98
137
  export declare function island(src: string, options?: Omit<ClientScriptAsset, "src" | "module">): ClientAssetDeclaration;
99
138
  export declare function resolveClientAssets(registry: ClientAssetRegistry, ids: string[]): ResolvedClientAssets;
package/dist/assets.js CHANGED
@@ -10,9 +10,11 @@ export function stylesheet(href, options) {
10
10
  };
11
11
  }
12
12
  export function googleFontsStylesheet(href, options) {
13
+ const { inline, ...googleFonts } = options;
13
14
  return {
14
15
  href,
15
- googleFonts: options,
16
+ googleFonts,
17
+ ...(inline ? { inline: true } : {}),
16
18
  };
17
19
  }
18
20
  export function script(src, options = {}) {
@@ -271,26 +273,35 @@ async function bytesEqual(left, rightPath) {
271
273
  return left.equals(right);
272
274
  }
273
275
  function stableStylesheetKey(stylesheet) {
274
- return typeof stylesheet === "string"
275
- ? stylesheet
276
- : "source" in stylesheet
277
- ? JSON.stringify({
278
- href: stylesheet.href,
279
- source: stylesheet.source,
280
- immutable: stylesheet.immutable !== false,
281
- })
282
- : JSON.stringify({
283
- href: stylesheet.href,
284
- googleFonts: {
285
- families: stylesheet.googleFonts.families,
286
- text: stylesheet.googleFonts.text,
287
- display: stylesheet.googleFonts.display,
288
- endpoint: stylesheet.googleFonts.endpoint,
289
- fontBasePath: stylesheet.googleFonts.fontBasePath,
290
- userAgent: stylesheet.googleFonts.userAgent,
291
- },
292
- immutable: stylesheet.immutable !== false,
293
- });
276
+ if (typeof stylesheet === "string") {
277
+ return stylesheet;
278
+ }
279
+ // The resolved inline form carries CSS text (a string); input forms only carry
280
+ // a boolean `inline` flag, so the string check identifies an inlined stylesheet.
281
+ if (typeof stylesheet.inline === "string") {
282
+ return JSON.stringify({ inline: stylesheet.inline });
283
+ }
284
+ return "source" in stylesheet
285
+ ? JSON.stringify({
286
+ href: stylesheet.href,
287
+ source: stylesheet.source,
288
+ immutable: stylesheet.immutable !== false,
289
+ inline: stylesheet.inline === true,
290
+ })
291
+ : JSON.stringify({
292
+ href: stylesheet.href,
293
+ googleFonts: {
294
+ families: stylesheet.googleFonts.families,
295
+ text: stylesheet.googleFonts.text,
296
+ display: stylesheet.googleFonts.display,
297
+ endpoint: stylesheet.googleFonts.endpoint,
298
+ fontBasePath: stylesheet.googleFonts.fontBasePath,
299
+ userAgent: stylesheet.googleFonts.userAgent,
300
+ preload: stylesheet.googleFonts.preload ?? false,
301
+ },
302
+ immutable: stylesheet.immutable !== false,
303
+ inline: stylesheet.inline === true,
304
+ });
294
305
  }
295
306
  function stableScriptKey(script) {
296
307
  return JSON.stringify({
package/dist/cli.d.ts CHANGED
@@ -48,6 +48,7 @@ export type CliCommand = {
48
48
  outDir: string;
49
49
  provider: DeployProvider;
50
50
  cname?: string;
51
+ trailingSlash?: BuildConfig["trailingSlash"];
51
52
  } | {
52
53
  command: "dev";
53
54
  outDir: string;
package/dist/cli.js CHANGED
@@ -97,7 +97,7 @@ export function parseArgs(args) {
97
97
  outDir = readValue(option, remaining);
98
98
  continue;
99
99
  }
100
- if (command === "benchmark" && option === "--trailing-slash") {
100
+ if ((command === "benchmark" || command === "deploy") && option === "--trailing-slash") {
101
101
  trailingSlash = readTrailingSlash(option, remaining);
102
102
  continue;
103
103
  }
@@ -297,11 +297,15 @@ export function parseArgs(args) {
297
297
  if (cname !== undefined && provider !== "github-pages") {
298
298
  throw new Error("--cname is only supported with --provider github-pages");
299
299
  }
300
+ if (trailingSlash !== undefined && provider !== "s3") {
301
+ throw new Error("--trailing-slash is only supported with --provider s3");
302
+ }
300
303
  return {
301
304
  command,
302
305
  outDir,
303
306
  provider,
304
307
  ...(cname !== undefined ? { cname } : {}),
308
+ ...(trailingSlash === undefined ? {} : { trailingSlash }),
305
309
  };
306
310
  }
307
311
  if (command === "dev") {
@@ -512,6 +516,7 @@ export async function runCli(args = process.argv.slice(2)) {
512
516
  outDir: command.outDir,
513
517
  provider: command.provider,
514
518
  ...(command.cname !== undefined ? { cname: command.cname } : {}),
519
+ ...(command.trailingSlash !== undefined ? { trailingSlash: command.trailingSlash } : {}),
515
520
  });
516
521
  console.log("StoneAge deploy artifacts complete");
517
522
  console.log(`Provider: ${result.provider}`);
@@ -519,6 +524,11 @@ export async function runCli(args = process.argv.slice(2)) {
519
524
  if (result.provider === "github-pages") {
520
525
  console.log(`Artifacts: ${result.files.length > 0 ? result.files.join(", ") : "none"}`);
521
526
  }
527
+ else if (result.provider === "s3") {
528
+ console.log(`Artifacts: ${result.files.join(", ")}`);
529
+ console.log(`Redirects: ${result.redirects}`);
530
+ console.log(`Rules: ${result.headers}`);
531
+ }
522
532
  else {
523
533
  console.log(`Redirects: ${result.redirects}`);
524
534
  console.log(`Headers: ${result.headers}`);
@@ -760,8 +770,11 @@ async function readValidationOptionsConfig(path) {
760
770
  throw new Error(`Validation config requirePrecompressed must be a boolean: ${path}`);
761
771
  }
762
772
  const provider = value.provider;
763
- if (provider !== undefined && provider !== "netlify" && provider !== "github-pages") {
764
- throw new Error(`Validation config provider must be netlify or github-pages: ${path}`);
773
+ if (provider !== undefined &&
774
+ provider !== "netlify" &&
775
+ provider !== "github-pages" &&
776
+ provider !== "s3") {
777
+ throw new Error(`Validation config provider must be netlify, github-pages, or s3: ${path}`);
765
778
  }
766
779
  return {
767
780
  configHash: createHash("sha256").update(raw).digest("hex"),
@@ -987,7 +1000,7 @@ export async function validateOutput(outDir, options = {}) {
987
1000
  const publicDataExportTargets = await collectPublicDataExportTargets(outDir, manifest);
988
1001
  const publicDataExportIssues = await validatePublicDataExports(outDir, publicDataExportTargets);
989
1002
  const headersManifestIssues = manifest
990
- ? await validateHeadersManifest(outDir, manifest, report.publicAssets ?? [])
1003
+ ? await validateHeadersManifest(outDir, manifest, report.publicAssets ?? [], report.publishingHeaders ?? [])
991
1004
  : [];
992
1005
  const redirectManifestIssues = manifest ? await validateRedirectManifest(outDir, manifest) : [];
993
1006
  const robotsTxtIssues = await validateRobotsTxt(outDir);
@@ -1397,7 +1410,7 @@ async function validateOrphanHtmlOutputs(outDir, manifest) {
1397
1410
  message: `${file} is an HTML file in the public output directory but is not listed in the manifest.`,
1398
1411
  }));
1399
1412
  }
1400
- async function validateHeadersManifest(outDir, manifest, publicAssets = []) {
1413
+ async function validateHeadersManifest(outDir, manifest, publicAssets = [], globalHeaders = []) {
1401
1414
  const raw = await readOptionalText(join(outDir, "headers.json"));
1402
1415
  if (!raw) {
1403
1416
  return [];
@@ -1426,6 +1439,12 @@ async function validateHeadersManifest(outDir, manifest, publicAssets = []) {
1426
1439
  }
1427
1440
  const issues = [];
1428
1441
  const expected = expectedHeadersManifestEntries(manifest);
1442
+ // Host-wide `/*` rules (HTML Cache-Control / security headers) are not tied to
1443
+ // a file artifact; the build persists them in the report so they count as
1444
+ // expected entries here instead of tripping the "unexpected path" check.
1445
+ for (const entry of globalHeaders) {
1446
+ expected.set(entry.path, entry.headers);
1447
+ }
1429
1448
  const managedPublicAssets = new Set(publicAssets);
1430
1449
  const actual = new Map();
1431
1450
  const duplicatePaths = new Set();
@@ -1656,9 +1675,10 @@ function isGeneratedSitemapFile(path) {
1656
1675
  return path === "sitemap.xml" || /^sitemap-\d+\.xml$/.test(path);
1657
1676
  }
1658
1677
  async function validateDeployArtifacts(outDir, provider = "netlify") {
1659
- // GitHub Pages relies on redirect HTML stubs and CNAME, not Netlify
1660
- // _redirects/_headers, so skip the Netlify-specific artifact checks.
1661
- if (provider === "github-pages") {
1678
+ // GitHub Pages relies on redirect HTML stubs and CNAME, and S3 ships a
1679
+ // descriptor plus CloudFront Function rather than Netlify _redirects/_headers,
1680
+ // so skip the Netlify-specific artifact checks for both.
1681
+ if (provider === "github-pages" || provider === "s3") {
1662
1682
  return [];
1663
1683
  }
1664
1684
  const issues = [];
@@ -2921,8 +2941,8 @@ function readTrailingSlash(option, args) {
2921
2941
  }
2922
2942
  function readDeployProvider(option, args) {
2923
2943
  const value = readValue(option, args);
2924
- if (value !== "netlify" && value !== "github-pages") {
2925
- throw new Error(`Expected netlify or github-pages for ${option}`);
2944
+ if (value !== "netlify" && value !== "github-pages" && value !== "s3") {
2945
+ throw new Error(`Expected netlify, github-pages, or s3 for ${option}`);
2926
2946
  }
2927
2947
  return value;
2928
2948
  }
@@ -2971,7 +2991,7 @@ function helpText() {
2971
2991
  " --validation-config <file> Load metadata, budget, and publish gate settings",
2972
2992
  " --write-validation-config <file> Write a validation baseline from current output",
2973
2993
  " --require-precompressed Fail when Brotli or Gzip sidecars are missing",
2974
- " --provider <netlify|github-pages> Validate deploy artifacts for the given provider",
2994
+ " --provider <netlify|github-pages|s3> Validate deploy artifacts for the given provider",
2975
2995
  " --report <file> Write the validation summary JSON",
2976
2996
  "",
2977
2997
  "SvelteKit audit options:",
@@ -2988,8 +3008,10 @@ function helpText() {
2988
3008
  " Requires the optional 'sharp' dependency (npm install sharp)",
2989
3009
  "",
2990
3010
  "Deploy options:",
2991
- " --provider <netlify|github-pages> Write provider-specific deploy artifacts",
3011
+ " --provider <netlify|github-pages|s3> Write provider-specific deploy artifacts",
2992
3012
  " --cname <domain> Custom domain for github-pages (writes CNAME)",
3013
+ " --trailing-slash <always|never> URL style for the s3 CloudFront Function (default always)",
3014
+ " s3 emits s3-deploy.json + cloudfront-function.js",
2993
3015
  "",
2994
3016
  "Dev options:",
2995
3017
  " --host <host> Hostname to bind, default 127.0.0.1",
package/dist/core.d.ts CHANGED
@@ -114,6 +114,46 @@ export type PublishingConfig = {
114
114
  */
115
115
  nativeRedirects?: "netlify" | false;
116
116
  headers?: boolean;
117
+ /**
118
+ * When `false`, the build-intermediate publishing manifests (`headers.json`
119
+ * and `redirects.json`) are removed from the output directory after the
120
+ * host-native artifacts (`_headers` / `_redirects`) have been written, so the
121
+ * large JSON forms are not served. Intended for use together with
122
+ * `nativeRedirects`, since the standalone `deploy` command reads those
123
+ * manifests. Defaults to `true`, preserving the existing behaviour of leaving
124
+ * the manifests in the output.
125
+ */
126
+ publishManifests?: boolean;
127
+ /**
128
+ * `Cache-Control` applied to generated HTML documents via a `/*` rule in
129
+ * `_headers` / `headers.json`. Content-hashed assets keep their own
130
+ * `immutable` rule because their more specific paths take precedence. Off by
131
+ * default; set e.g. `"public, max-age=0, must-revalidate"` to opt in.
132
+ */
133
+ htmlCacheControl?: string;
134
+ /**
135
+ * Opt-in security response headers emitted as a `/*` rule in `_headers` /
136
+ * `headers.json`. Nothing is emitted unless this is set.
137
+ */
138
+ securityHeaders?: SecurityHeadersConfig;
139
+ };
140
+ export type SecurityHeadersConfig = {
141
+ /** Path the headers apply to. Defaults to `/*`. */
142
+ path?: string;
143
+ /**
144
+ * `Strict-Transport-Security`. `true` emits a sensible default
145
+ * (`max-age=63072000; includeSubDomains; preload`); a string sets a custom
146
+ * value. HTTPS-only, so off unless explicitly enabled.
147
+ */
148
+ hsts?: boolean | string;
149
+ /** `X-Content-Type-Options: nosniff` when `true`. */
150
+ contentTypeOptions?: boolean;
151
+ /** `X-Frame-Options`. `true` emits `DENY`; a string sets a custom value. */
152
+ frameOptions?: boolean | string;
153
+ /** `Referrer-Policy` value, emitted verbatim when set. */
154
+ referrerPolicy?: string;
155
+ /** Additional headers merged verbatim (names are lower-cased). */
156
+ extra?: Record<string, string>;
117
157
  };
118
158
  export type SitemapConfig = {
119
159
  maxUrlsPerFile?: number;
@@ -156,6 +196,10 @@ export type RouteFamily = {
156
196
  };
157
197
  export type ArtifactBody = string | Buffer | Uint8Array;
158
198
  export type ArtifactHeaders = Record<string, string>;
199
+ export type PublishingHeaderEntry = {
200
+ path: string;
201
+ headers: ArtifactHeaders;
202
+ };
159
203
  export type ArtifactResponse = {
160
204
  body: ArtifactBody;
161
205
  contentType?: string;
@@ -371,6 +415,12 @@ export type BuildReport = {
371
415
  bytes: number;
372
416
  }>;
373
417
  validation: BuildValidationReport;
418
+ /**
419
+ * Host-wide `/*` header rules (HTML `Cache-Control` / security headers) that
420
+ * are emitted to `headers.json` / `_headers` but are not tied to a file
421
+ * artifact. Persisted so `validate` can treat them as expected entries.
422
+ */
423
+ publishingHeaders?: PublishingHeaderEntry[];
374
424
  };
375
425
  export type DataFlowFamilySummary = {
376
426
  name: string;
package/dist/core.js CHANGED
@@ -497,16 +497,108 @@ function fragmentClientArtifact(publicPath) {
497
497
  };
498
498
  }
499
499
  const immutableStaticAssetCacheControl = "public, max-age=31536000, immutable";
500
+ // Content-hashed filenames (e.g. `site.<16 hex>.css`) plus the StoneAge-managed
501
+ // `/_assets/` and `/_stoneage/` namespaces are immutable: their bytes never
502
+ // change for a given path, so they can carry a long-lived `immutable` rule.
503
+ const contentHashedAssetPathPattern = /\.[0-9a-f]{16,}\.[A-Za-z0-9]+$/;
504
+ function isImmutableAssetPath(path) {
505
+ return (path.startsWith("/_assets/") ||
506
+ path.startsWith("/_stoneage/") ||
507
+ contentHashedAssetPathPattern.test(path));
508
+ }
509
+ function hasHeaderNamed(headers, name) {
510
+ if (!headers) {
511
+ return false;
512
+ }
513
+ const lower = name.toLowerCase();
514
+ return Object.keys(headers).some((key) => key.toLowerCase() === lower);
515
+ }
516
+ // Adds the immutable `Cache-Control` to content-hashed / `_assets` / `_stoneage`
517
+ // artifacts that do not already declare one (e.g. the fragment client at
518
+ // `/_stoneage/*`). Mutates `artifacts` so the headers persist in the manifest,
519
+ // keeping `validate` consistent because it reconstructs expectations from the
520
+ // same manifest.
521
+ function applyImmutableAssetHeaders(artifacts) {
522
+ for (const artifact of artifacts) {
523
+ if (!isImmutableAssetPath(artifact.path)) {
524
+ continue;
525
+ }
526
+ if (hasHeaderNamed(artifact.headers, "cache-control")) {
527
+ continue;
528
+ }
529
+ artifact.headers = {
530
+ ...(artifact.headers ?? {}),
531
+ "cache-control": immutableStaticAssetCacheControl,
532
+ };
533
+ }
534
+ }
535
+ const defaultHstsValue = "max-age=63072000; includeSubDomains; preload";
536
+ // Resolves the host-wide `/*` header rule(s) (HTML `Cache-Control` and security
537
+ // headers). These are not tied to a file artifact, so they are persisted in the
538
+ // build report and re-applied by `validate` from there.
539
+ function resolveGlobalPublishingHeaders(publishing) {
540
+ if (publishing.headers === false) {
541
+ return [];
542
+ }
543
+ const headersByPath = new Map();
544
+ const headersFor = (path) => {
545
+ const existing = headersByPath.get(path);
546
+ if (existing) {
547
+ return existing;
548
+ }
549
+ const created = {};
550
+ headersByPath.set(path, created);
551
+ return created;
552
+ };
553
+ if (publishing.htmlCacheControl !== undefined) {
554
+ headersFor("/*")["cache-control"] = publishing.htmlCacheControl;
555
+ }
556
+ const security = publishing.securityHeaders;
557
+ if (security) {
558
+ const target = headersFor(security.path ?? "/*");
559
+ if (security.hsts) {
560
+ target["strict-transport-security"] =
561
+ security.hsts === true ? defaultHstsValue : security.hsts;
562
+ }
563
+ if (security.contentTypeOptions) {
564
+ target["x-content-type-options"] = "nosniff";
565
+ }
566
+ if (security.frameOptions) {
567
+ target["x-frame-options"] =
568
+ security.frameOptions === true ? "DENY" : security.frameOptions;
569
+ }
570
+ if (security.referrerPolicy) {
571
+ target["referrer-policy"] = security.referrerPolicy;
572
+ }
573
+ for (const [name, value] of Object.entries(security.extra ?? {})) {
574
+ target[name.toLowerCase()] = value;
575
+ }
576
+ }
577
+ return [...headersByPath.entries()]
578
+ .filter(([, headers]) => Object.keys(headers).length > 0)
579
+ .map(([path, headers]) => ({ path, headers }));
580
+ }
500
581
  async function resolveStaticAssetReferences(assets) {
501
582
  if (!assets) {
502
583
  return {
503
584
  assets,
504
585
  publicAssets: [],
505
586
  headers: [],
587
+ fontPreloads: [],
506
588
  };
507
589
  }
508
590
  const publicAssetsByPath = new Map();
509
591
  const headersByPath = new Map();
592
+ const fontPreloads = [];
593
+ const seenFontPreloads = new Set();
594
+ const collectFontPreloads = (paths) => {
595
+ for (const path of paths) {
596
+ if (!seenFontPreloads.has(path)) {
597
+ seenFontPreloads.add(path);
598
+ fontPreloads.push(path);
599
+ }
600
+ }
601
+ };
510
602
  const resolveImmutablePublicAsset = async (asset, publicPath) => {
511
603
  const bytes = await immutableAssetBytes(asset);
512
604
  const hashedPath = contentHashedPublicPath(publicPath, contentHash(bytes));
@@ -525,7 +617,9 @@ async function resolveStaticAssetReferences(assets) {
525
617
  }
526
618
  return hashedPath;
527
619
  };
528
- const stylesheets = await Promise.all((assets.stylesheets ?? []).map(async (stylesheet) => resolveStylesheetAsset(stylesheet, resolveImmutablePublicAsset)));
620
+ const stylesheets = await Promise.all((assets.stylesheets ?? []).map(async (stylesheet) => resolveStylesheetAsset(stylesheet, resolveImmutablePublicAsset, collectFontPreloads)));
621
+ // Per-page client stylesheets are resolved without a preload collector: a
622
+ // page-scoped font must not become a global preload hint on every page.
529
623
  const client = assets.client
530
624
  ? await resolveClientAssetRegistry(assets.client, resolveImmutablePublicAsset)
531
625
  : undefined;
@@ -537,16 +631,63 @@ async function resolveStaticAssetReferences(assets) {
537
631
  },
538
632
  publicAssets: [...publicAssetsByPath.values()],
539
633
  headers: [...headersByPath.values()],
634
+ fontPreloads,
540
635
  };
541
636
  }
542
- async function resolveStylesheetAsset(stylesheet, resolveImmutablePublicAsset) {
543
- if (typeof stylesheet === "string" || stylesheet.immutable === false) {
637
+ async function resolveStylesheetAsset(stylesheet, resolveImmutablePublicAsset, onFontPreload) {
638
+ // Plain href strings and already-inlined CSS pass through untouched.
639
+ if (typeof stylesheet === "string" || isResolvedInlineStylesheet(stylesheet)) {
544
640
  return stylesheet;
545
641
  }
642
+ const inline = stylesheet.inline === true;
546
643
  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) {
649
+ return stripStylesheetBuildHints(stylesheet);
650
+ }
547
651
  return resolveImmutablePublicAsset({ source: stylesheet.source }, stylesheet.href);
548
652
  }
549
- return resolveGoogleFontsStylesheet(stylesheet.href, stylesheet.googleFonts, resolveImmutablePublicAsset);
653
+ // 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) {
656
+ return stripStylesheetBuildHints(stylesheet);
657
+ }
658
+ const { css, fontPaths } = await resolveGoogleFontsStylesheet(stylesheet.href, stylesheet.googleFonts, resolveImmutablePublicAsset);
659
+ if (onFontPreload) {
660
+ const selected = selectFontPreloads(fontPaths, stylesheet.googleFonts.preload);
661
+ if (selected.length > 0) {
662
+ onFontPreload(selected);
663
+ }
664
+ }
665
+ // The woff2 files are always vendored above; only the CSS is inlined.
666
+ return inline ? { inline: css } : resolveImmutablePublicAsset({ content: css }, stylesheet.href);
667
+ }
668
+ function isResolvedInlineStylesheet(stylesheet) {
669
+ // The resolved inline form carries the CSS text as a string; the input forms
670
+ // only ever carry a boolean `inline` flag, so the string check disambiguates.
671
+ return typeof stylesheet === "object" && typeof stylesheet.inline === "string";
672
+ }
673
+ // Drop the build-time `inline` flag from a non-vendored stylesheet so the
674
+ // rendered form is a clean external reference and never carries a stray boolean
675
+ // `inline` that the renderer might mistake for inlined CSS.
676
+ function stripStylesheetBuildHints(stylesheet) {
677
+ const { inline: _inline, ...rest } = stylesheet;
678
+ return rest;
679
+ }
680
+ function selectFontPreloads(fontPaths, preload) {
681
+ if (preload === undefined || preload === false) {
682
+ return [];
683
+ }
684
+ if (preload === true) {
685
+ return fontPaths;
686
+ }
687
+ if (!Number.isFinite(preload) || preload <= 0) {
688
+ return [];
689
+ }
690
+ return fontPaths.slice(0, Math.floor(preload));
550
691
  }
551
692
  async function resolveClientAssetRegistry(registry, resolveImmutablePublicAsset) {
552
693
  const entries = await Promise.all(Object.entries(registry).map(async ([id, declarations]) => [
@@ -584,6 +725,12 @@ async function immutableAssetBytes(asset) {
584
725
  const defaultGoogleFontsCssEndpoint = "https://fonts.googleapis.com/css2";
585
726
  const defaultGoogleFontsUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
586
727
  "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
728
+ /**
729
+ * Vendor the Google Fonts subset (CSS + woff2) and return the rewritten CSS
730
+ * together with the hashed woff2 public paths in CSS source order. The caller
731
+ * decides whether to write the CSS as a hashed file or inline it, and which
732
+ * fonts to preload.
733
+ */
587
734
  async function resolveGoogleFontsStylesheet(href, options, resolveImmutablePublicAsset) {
588
735
  const fetchAsset = options.fetch ?? defaultAssetFetch;
589
736
  const requestHeaders = {
@@ -610,7 +757,7 @@ async function resolveGoogleFontsStylesheet(href, options, resolveImmutablePubli
610
757
  fontPathsByUrl.set(font.url, await resolveImmutablePublicAsset({ content: bytes }, fontPublicPath));
611
758
  }
612
759
  const rewrittenCss = rewriteCssUrls(css, fontPathsByUrl);
613
- return resolveImmutablePublicAsset({ content: rewrittenCss }, href);
760
+ return { css: rewrittenCss, fontPaths: [...fontPathsByUrl.values()] };
614
761
  }
615
762
  const defaultAssetFetch = async (url, init) => {
616
763
  return fetch(url, init);
@@ -836,9 +983,11 @@ export async function buildSite(config) {
836
983
  };
837
984
  });
838
985
  validateOutputPlan(plannedPages, plannedArtifacts, config.notFound !== undefined);
986
+ const fontPreloads = staticAssets.fontPreloads;
839
987
  const sharedPageFingerprintHash = hashValue({
840
988
  site: config.site,
841
989
  stylesheets: assets?.stylesheets ?? [],
990
+ fontPreloads,
842
991
  prefetch: config.prefetch ?? {},
843
992
  trailingSlash,
844
993
  renderFingerprint: config.renderFingerprint,
@@ -866,7 +1015,7 @@ export async function buildSite(config) {
866
1015
  };
867
1016
  }
868
1017
  const body = await withDeferredFragmentDefaults({ defaultFallbackText: fragmentDefaultFallbackText }, () => task.entry.render());
869
- const html = renderDocument(config.site, task.metadata, canonical, body, assets, config.prefetch, trailingSlash, fragmentClientScripts);
1018
+ const html = renderDocument(config.site, task.metadata, canonical, body, assets, config.prefetch, trailingSlash, fragmentClientScripts, fontPreloads);
870
1019
  await writeText(absoluteOutputPath, html, {
871
1020
  compareExisting: compareExistingGeneratedOutput,
872
1021
  });
@@ -936,7 +1085,7 @@ export async function buildSite(config) {
936
1085
  });
937
1086
  const notFoundResults = config.notFound
938
1087
  ? [
939
- await buildNotFoundPage(config, config.notFound, trailingSlash, previousPages, sharedPageFingerprintHash, compareExistingGeneratedOutput, fragmentDefaultFallbackText, fragmentClientScripts, assets),
1088
+ await buildNotFoundPage(config, config.notFound, trailingSlash, previousPages, sharedPageFingerprintHash, compareExistingGeneratedOutput, fragmentDefaultFallbackText, fragmentClientScripts, assets, fontPreloads),
940
1089
  ]
941
1090
  : [];
942
1091
  const allPageResults = [...pageResults, ...notFoundResults];
@@ -1043,6 +1192,16 @@ export async function buildSite(config) {
1043
1192
  pages,
1044
1193
  artifacts,
1045
1194
  };
1195
+ // Bake the immutable `Cache-Control` into content-hashed / `_assets` /
1196
+ // `_stoneage` artifacts before the manifest is compared, persisted, and
1197
+ // published, so `headers.json`, `_headers`, and `validate` all stay in sync.
1198
+ const publishesHeaders = config.publishing !== undefined && config.publishing.headers !== false;
1199
+ if (publishesHeaders) {
1200
+ applyImmutableAssetHeaders(manifest.artifacts);
1201
+ }
1202
+ const globalPublishingHeaders = config.publishing !== undefined
1203
+ ? resolveGlobalPublishingHeaders(config.publishing)
1204
+ : [];
1046
1205
  const staleOutputsRemoved = config.partial
1047
1206
  ? 0
1048
1207
  : (await removeStaleGeneratedOutputs(config.outDir, previousManifest, manifest)) +
@@ -1072,6 +1231,14 @@ export async function buildSite(config) {
1072
1231
  artifactWrites,
1073
1232
  })
1074
1233
  : createReport(manifest, pagesWritten, artifactsWritten, publicAssetsCopied, publicAssetsSkipped, publicAssets, staleOutputsRemoved, sitemapRendered, sitemapFiles, routeWrites, artifactWrites, allPageResults.map((result) => result.validationPage), config.validation, config.buildFingerprint, config.renderFingerprint, fragmentDefaultFallbackText, fragmentClientPublicPath);
1234
+ // Persist the host-wide `/*` header rules so `validate` can reconstruct them
1235
+ // (they are not tied to a file artifact). Always set the fresh value, even on
1236
+ // the report-reuse path, and force a report rewrite when it changes so the
1237
+ // persisted report never lags the regenerated `headers.json`.
1238
+ report.publishingHeaders =
1239
+ globalPublishingHeaders.length > 0 ? globalPublishingHeaders : undefined;
1240
+ const publishingHeadersChanged = stableJson(previousReport?.publishingHeaders ?? []) !==
1241
+ stableJson(globalPublishingHeaders);
1075
1242
  const missingPreviousManifest = previousManifest.pages.length === 0 && previousManifest.artifacts.length === 0;
1076
1243
  const missingBuildReport = previousReport === undefined;
1077
1244
  const dataFlowSummaryReportPath = join(config.outDir, ".stoneage/data-flow-summary.json");
@@ -1087,6 +1254,7 @@ export async function buildSite(config) {
1087
1254
  staleOutputsRemoved > 0 ||
1088
1255
  missingPreviousManifest ||
1089
1256
  missingBuildReport ||
1257
+ publishingHeadersChanged ||
1090
1258
  shouldWriteSitemaps;
1091
1259
  const validationReportChanged = !shouldWriteBuildReportsWithoutComparisons &&
1092
1260
  !canReusePreviousReport &&
@@ -1131,7 +1299,7 @@ export async function buildSite(config) {
1131
1299
  await rm(legacyDataFlowReportPath, { force: true });
1132
1300
  }
1133
1301
  if (config.publishing) {
1134
- await writePublishingManifests(config.outDir, config.publishing, config.site.baseUrl, trailingSlash, manifest, redirectHtmlFiles, staticAssets.headers);
1302
+ await writePublishingManifests(config.outDir, config.publishing, config.site.baseUrl, trailingSlash, manifest, redirectHtmlFiles, staticAssets.headers, globalPublishingHeaders);
1135
1303
  }
1136
1304
  return {
1137
1305
  pagesWritten,
@@ -1142,7 +1310,7 @@ export async function buildSite(config) {
1142
1310
  report,
1143
1311
  };
1144
1312
  }
1145
- async function buildNotFoundPage(config, notFound, trailingSlash, previousPages, sharedPageFingerprintHash, compareExistingGeneratedOutput, fragmentDefaultFallbackText, fragmentClientScripts, assets) {
1313
+ async function buildNotFoundPage(config, notFound, trailingSlash, previousPages, sharedPageFingerprintHash, compareExistingGeneratedOutput, fragmentDefaultFallbackText, fragmentClientScripts, assets, fontPreloads) {
1146
1314
  const metadata = {
1147
1315
  ...resolvePageMetadata(undefined, undefined, notFound.metadata),
1148
1316
  // A 404 page must never be indexed or advertised in the sitemap.
@@ -1168,7 +1336,7 @@ async function buildNotFoundPage(config, notFound, trailingSlash, previousPages,
1168
1336
  };
1169
1337
  }
1170
1338
  const body = await withDeferredFragmentDefaults({ defaultFallbackText: fragmentDefaultFallbackText }, () => notFound.render());
1171
- const html = renderDocument(config.site, metadata, canonical, body, assets, config.prefetch, trailingSlash, fragmentClientScripts);
1339
+ const html = renderDocument(config.site, metadata, canonical, body, assets, config.prefetch, trailingSlash, fragmentClientScripts, fontPreloads);
1172
1340
  await writeText(absoluteOutputPath, html, {
1173
1341
  compareExisting: compareExistingGeneratedOutput,
1174
1342
  });
@@ -1843,7 +2011,7 @@ function sameDependencies(left, right) {
1843
2011
  }
1844
2012
  return leftCount === rightCount;
1845
2013
  }
1846
- function renderDocument(site, metadata, canonical, body, assets, prefetch, trailingSlash = "always", extraClientScripts = []) {
2014
+ function renderDocument(site, metadata, canonical, body, assets, prefetch, trailingSlash = "always", extraClientScripts = [], fontPreloads = []) {
1847
2015
  const title = renderTitle(metadata.title, site);
1848
2016
  const description = metadata.description || site.description;
1849
2017
  const ogTitle = metadata.ogTitle ?? metadata.title;
@@ -1884,9 +2052,16 @@ function renderDocument(site, metadata, canonical, body, assets, prefetch, trail
1884
2052
  const twitterMeta = renderTwitterMetadata(site.twitter, metadata.twitter);
1885
2053
  const customHead = [...(site.head ?? []), ...(metadata.head ?? [])].map(renderHeadTag).join("");
1886
2054
  const pageClientAssets = resolvePageClientAssets(assets, metadata);
2055
+ // Preload hints go before the stylesheet links so fonts leave the critical
2056
+ // request chain (HTML → CSS → woff2).
2057
+ const fontPreloadLinks = fontPreloads
2058
+ .map((href) => `<link rel="preload" href="${escapeAttribute(href)}" as="font" type="font/woff2" crossorigin>`)
2059
+ .join("");
1887
2060
  const stylesheetLinks = (assets?.stylesheets ?? [])
1888
2061
  .concat(pageClientAssets.stylesheets)
1889
- .map((stylesheet) => `<link rel="stylesheet" href="${escapeAttribute(stylesheetHref(stylesheet))}">`)
2062
+ .map((stylesheet) => isResolvedInlineStylesheet(stylesheet)
2063
+ ? `<style>${escapeInlineStyleContent(stylesheet.inline)}</style>`
2064
+ : `<link rel="stylesheet" href="${escapeAttribute(stylesheetHref(stylesheet))}">`)
1890
2065
  .join("");
1891
2066
  const clientScripts = renderClientScripts([
1892
2067
  ...extraClientScripts,
@@ -1901,7 +2076,7 @@ function renderDocument(site, metadata, canonical, body, assets, prefetch, trail
1901
2076
  const serviceWorkerPrefetch = prefetch?.serviceWorker
1902
2077
  ? renderServiceWorkerPrefetch(prefetchUrls, prefetch)
1903
2078
  : "";
1904
- return `<!doctype html><html lang="${escapeAttribute(site.lang ?? "en")}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title><meta name="description" content="${escapeAttribute(description)}"><link rel="canonical" href="${escapeAttribute(canonical)}">${robots}${favicon}${ogTitleMeta}${ogDescriptionMeta}${ogUrl}${ogSiteName}${ogType}${ogImage}${ogImageWidthMeta}${ogImageHeightMeta}${twitterMeta}${customHead}${stylesheetLinks}${clientScripts}${prefetchLinks}${serviceWorkerPrefetch}<body>${body}`;
2079
+ return `<!doctype html><html lang="${escapeAttribute(site.lang ?? "en")}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title><meta name="description" content="${escapeAttribute(description)}"><link rel="canonical" href="${escapeAttribute(canonical)}">${robots}${favicon}${ogTitleMeta}${ogDescriptionMeta}${ogUrl}${ogSiteName}${ogType}${ogImage}${ogImageWidthMeta}${ogImageHeightMeta}${twitterMeta}${customHead}${fontPreloadLinks}${stylesheetLinks}${clientScripts}${prefetchLinks}${serviceWorkerPrefetch}<body>${body}`;
1905
2080
  }
1906
2081
  function renderTitle(pageTitle, site) {
1907
2082
  return site.titleTemplate
@@ -1953,7 +2128,20 @@ function resolvePageClientAssets(assets, metadata) {
1953
2128
  return resolveClientAssets(assets?.client ?? {}, ids);
1954
2129
  }
1955
2130
  function stylesheetHref(stylesheet) {
1956
- return typeof stylesheet === "string" ? stylesheet : stylesheet.href;
2131
+ if (typeof stylesheet === "string") {
2132
+ return stylesheet;
2133
+ }
2134
+ // Inlined stylesheets are rendered as <style> and never reach this helper.
2135
+ return "href" in stylesheet ? stylesheet.href : "";
2136
+ }
2137
+ // The body of a <style> element is HTML "raw text": a literal `</style` token
2138
+ // (which can validly appear inside a CSS comment or a `content:` string) would
2139
+ // close the element early and corrupt the page. A backslash is a valid CSS
2140
+ // escape, so `<\/style` is byte-for-byte equivalent to `</style` in the only
2141
+ // places it can legitimately occur, while no longer matching the raw-text end
2142
+ // token the HTML parser scans for.
2143
+ function escapeInlineStyleContent(css) {
2144
+ return css.replace(/<\/(style)/gi, "<\\/$1");
1957
2145
  }
1958
2146
  function renderClientScripts(scripts) {
1959
2147
  return scripts.map((script) => `<script${renderScriptAttributes(script)}></script>`).join("");
@@ -2005,7 +2193,7 @@ function renderPrefetchScript(workerPath) {
2005
2193
  function renderPrefetchWorker(cacheName) {
2006
2194
  return `const CACHE_NAME="${escapeJavaScriptString(cacheName)}";self.addEventListener("message",(event)=>{const data=event.data;if(!data||data.type!=="STONEAGE_PREFETCH"||!Array.isArray(data.urls))return;event.waitUntil(caches.open(CACHE_NAME).then((cache)=>Promise.all(data.urls.filter((url)=>typeof url==="string"&&url.startsWith("/")).map((url)=>cache.add(url).catch(()=>undefined)))));});`;
2007
2195
  }
2008
- async function writePublishingManifests(outDir, publishing, baseUrl, trailingSlash, manifest, redirectHtmlFiles, staticAssetHeaders = []) {
2196
+ async function writePublishingManifests(outDir, publishing, baseUrl, trailingSlash, manifest, redirectHtmlFiles, staticAssetHeaders = [], globalHeaders = []) {
2009
2197
  const redirects = normalizePublishingRedirects(publishing.redirects ?? [], trailingSlash);
2010
2198
  if (publishing.robots !== undefined && publishing.robots !== false) {
2011
2199
  await writeText(join(outDir, "robots.txt"), renderRobotsTxt(publishing.robots, baseUrl, trailingSlash));
@@ -2023,12 +2211,23 @@ async function writePublishingManifests(outDir, publishing, baseUrl, trailingSla
2023
2211
  await rm(join(outDir, "headers.json"), { force: true });
2024
2212
  }
2025
2213
  else {
2026
- headers = await writeArtifactHeadersManifest(outDir, manifest, staticAssetHeaders);
2214
+ headers = await writeArtifactHeadersManifest(outDir, manifest, staticAssetHeaders, globalHeaders);
2027
2215
  }
2028
2216
  await writeNativePublishingArtifacts(outDir, publishing, redirects, headers);
2217
+ // SR-4: drop the build-intermediate manifests from the published output once
2218
+ // the host-native artifacts have been written from the in-memory data above.
2219
+ if (publishing.publishManifests === false) {
2220
+ await Promise.all([
2221
+ rm(join(outDir, "headers.json"), { force: true }),
2222
+ rm(join(outDir, "redirects.json"), { force: true }),
2223
+ ]);
2224
+ }
2029
2225
  }
2030
- async function writeArtifactHeadersManifest(outDir, manifest, staticAssetHeaders = []) {
2226
+ async function writeArtifactHeadersManifest(outDir, manifest, staticAssetHeaders = [], globalHeaders = []) {
2031
2227
  const headers = [
2228
+ // Host-wide `/*` rules come first so more specific artifact / asset rules
2229
+ // (e.g. immutable `Cache-Control`) take precedence in `_headers`.
2230
+ ...globalHeaders,
2032
2231
  ...manifest.artifacts
2033
2232
  .map((artifact) => {
2034
2233
  const artifactHeaders = artifactPublishingHeaders(artifact);
package/dist/deploy.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type DeployProvider = "netlify" | "github-pages";
1
+ export type DeployProvider = "netlify" | "github-pages" | "s3";
2
2
  export type DeployArtifactsOptions = {
3
3
  outDir: string;
4
4
  provider: DeployProvider;
@@ -7,6 +7,14 @@ export type DeployArtifactsOptions = {
7
7
  * (protocol and path are stripped). Ignored by other providers.
8
8
  */
9
9
  cname?: string;
10
+ /**
11
+ * URL style the site was built with. Only the "s3" provider uses it, to
12
+ * emit a CloudFront Function that resolves pretty URLs to the matching
13
+ * object layout ("always" -> dir/index.html, "never" -> name.html).
14
+ * Defaults to "always" to match the build default. Ignored by other
15
+ * providers.
16
+ */
17
+ trailingSlash?: "always" | "never";
10
18
  };
11
19
  export type DeployArtifactsResult = {
12
20
  provider: DeployProvider;
package/dist/deploy.js CHANGED
@@ -4,6 +4,9 @@ export async function writeDeployArtifacts(options) {
4
4
  if (options.provider === "github-pages") {
5
5
  return writeGitHubPagesArtifacts(options);
6
6
  }
7
+ if (options.provider === "s3") {
8
+ return writeS3Artifacts(options);
9
+ }
7
10
  if (options.provider !== "netlify") {
8
11
  throw new Error(`Unsupported deploy provider: ${options.provider}`);
9
12
  }
@@ -49,6 +52,109 @@ async function writeGitHubPagesArtifacts(options) {
49
52
  headers: 0,
50
53
  };
51
54
  }
55
+ // Default Cache-Control for files not matched by a header rule (e.g. HTML).
56
+ // Mutable documents get a short revalidation window; hash-named immutable
57
+ // assets carry their own long-lived rule from the headers manifest.
58
+ const s3DefaultCacheControl = "public, max-age=300";
59
+ // Map S3/CloudFront origin errors to the generated 404 page so pretty-URL
60
+ // misses surface the site's own not-found document instead of an XML error.
61
+ const s3ErrorResponses = [
62
+ { errorCode: 403, responseCode: 404, responsePath: "/404.html" },
63
+ { errorCode: 404, responseCode: 404, responsePath: "/404.html" },
64
+ ];
65
+ // Viewer-request CloudFront Function source. It resolves directory-style and
66
+ // extensionless requests to the object the build actually wrote, so pretty
67
+ // URLs work without hand-written routing rules. The mapping depends on the
68
+ // build's trailingSlash mode, so a source is emitted per mode. Both are
69
+ // ES5-safe (no endsWith/slice) to stay within the CloudFront Functions
70
+ // runtime. Written verbatim so callers can attach it to a distribution.
71
+ //
72
+ // "always": pages live at dir/index.html, so /dir/ and /dir both resolve to
73
+ // /dir/index.html and / resolves to /index.html.
74
+ const cloudFrontFunctionSourceAlways = [
75
+ "function handler(event) {",
76
+ " var request = event.request;",
77
+ " var uri = request.uri;",
78
+ ' var lastSegment = uri.substring(uri.lastIndexOf("/") + 1);',
79
+ ' if (lastSegment === "") {',
80
+ ' request.uri = uri + "index.html";',
81
+ ' } else if (lastSegment.indexOf(".") === -1) {',
82
+ ' request.uri = uri + "/index.html";',
83
+ " }",
84
+ " return request;",
85
+ "}",
86
+ "",
87
+ ].join("\n");
88
+ // "never": pages live at name.html, so /about resolves to /about.html, a
89
+ // trailing slash is dropped first, and the root still resolves to /index.html.
90
+ const cloudFrontFunctionSourceNever = [
91
+ "function handler(event) {",
92
+ " var request = event.request;",
93
+ " var uri = request.uri;",
94
+ ' if (uri === "/") {',
95
+ ' request.uri = "/index.html";',
96
+ " return request;",
97
+ " }",
98
+ ' if (uri.charAt(uri.length - 1) === "/") {',
99
+ " uri = uri.substring(0, uri.length - 1);",
100
+ " }",
101
+ ' var lastSegment = uri.substring(uri.lastIndexOf("/") + 1);',
102
+ ' if (lastSegment.indexOf(".") === -1) {',
103
+ ' uri = uri + ".html";',
104
+ " }",
105
+ " request.uri = uri;",
106
+ " return request;",
107
+ "}",
108
+ "",
109
+ ].join("\n");
110
+ function cloudFrontFunctionSource(trailingSlash) {
111
+ return trailingSlash === "never"
112
+ ? cloudFrontFunctionSourceNever
113
+ : cloudFrontFunctionSourceAlways;
114
+ }
115
+ async function writeS3Artifacts(options) {
116
+ const redirects = await readRedirectsManifest(join(options.outDir, "redirects.json"));
117
+ const headers = await readHeadersManifest(join(options.outDir, "headers.json"));
118
+ const trailingSlash = options.trailingSlash ?? "always";
119
+ const rules = headers.headers.map(toS3Rule);
120
+ const descriptor = {
121
+ provider: "s3",
122
+ trailingSlash,
123
+ indexDocument: "index.html",
124
+ errorDocument: "404.html",
125
+ defaults: { cacheControl: s3DefaultCacheControl },
126
+ rules,
127
+ redirects: redirects.redirects,
128
+ errorResponses: s3ErrorResponses.map((entry) => ({ ...entry })),
129
+ cloudFrontFunction: "cloudfront-function.js",
130
+ };
131
+ await writeIfChanged(join(options.outDir, "s3-deploy.json"), `${JSON.stringify(descriptor, null, 2)}\n`);
132
+ await writeIfChanged(join(options.outDir, "cloudfront-function.js"), cloudFrontFunctionSource(trailingSlash));
133
+ return {
134
+ provider: options.provider,
135
+ files: ["s3-deploy.json", "cloudfront-function.js"],
136
+ redirects: redirects.redirects.length,
137
+ headers: rules.length,
138
+ };
139
+ }
140
+ function toS3Rule(entry) {
141
+ const contentType = findHeaderValue(entry.headers, "content-type");
142
+ const cacheControl = findHeaderValue(entry.headers, "cache-control");
143
+ return {
144
+ path: entry.path,
145
+ ...(contentType !== undefined ? { contentType } : {}),
146
+ ...(cacheControl !== undefined ? { cacheControl } : {}),
147
+ };
148
+ }
149
+ function findHeaderValue(headers, name) {
150
+ const target = name.toLowerCase();
151
+ for (const [key, value] of Object.entries(headers)) {
152
+ if (key.toLowerCase() === target) {
153
+ return value;
154
+ }
155
+ }
156
+ return undefined;
157
+ }
52
158
  export function normalizeCname(cname) {
53
159
  if (cname === undefined) {
54
160
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@t09tanaka/stoneage",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "A data-site static site generator for fast plain HTML output.",
6
6
  "keywords": [