@t09tanaka/stoneage 0.1.0 → 0.3.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 +31 -0
- package/README.md +73 -1
- package/dist/agent-skill.d.ts +1 -1
- package/dist/agent-skill.js +1 -1
- package/dist/assets.d.ts +41 -2
- package/dist/assets.js +32 -21
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +34 -12
- package/dist/core.d.ts +73 -0
- package/dist/core.js +287 -21
- package/dist/deploy.d.ts +9 -1
- package/dist/deploy.js +106 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 - 2026-06-30
|
|
4
|
+
|
|
5
|
+
- Added global stylesheet auto-inlining via `assets.inlineStylesheets` (inline
|
|
6
|
+
every generated stylesheet) and `assets.inlineStylesheetMaxBytes` (inline only
|
|
7
|
+
bundles whose built CSS is within the byte threshold). A per-stylesheet
|
|
8
|
+
`inline` flag still wins; when both options are set the byte threshold gates
|
|
9
|
+
inlining. Google Fonts WOFF2 stay vendored and preload hints are unchanged.
|
|
10
|
+
|
|
11
|
+
## 0.2.0 - 2026-06-30
|
|
12
|
+
|
|
13
|
+
- Added immutable `Cache-Control: public, max-age=31536000, immutable` coverage
|
|
14
|
+
for content-hashed, `/_assets/`, and `/_stoneage/` artifacts that previously
|
|
15
|
+
lacked it, including the deferred fragment client.
|
|
16
|
+
- Added opt-in `publishing.htmlCacheControl` to emit a host-wide `/*` HTML
|
|
17
|
+
revalidate default, overridden by more specific immutable asset rules.
|
|
18
|
+
- Added opt-in `publishing.securityHeaders` (HSTS, `X-Content-Type-Options`,
|
|
19
|
+
`X-Frame-Options`, `Referrer-Policy`, and an `extra` map) emitted as a `/*`
|
|
20
|
+
rule.
|
|
21
|
+
- Added `publishing.publishManifests` (default `true`) to drop the intermediate
|
|
22
|
+
`headers.json` / `redirects.json` from published output once host-native
|
|
23
|
+
`_headers` / `_redirects` are written.
|
|
24
|
+
- Added an `s3` deploy provider that emits a host-neutral `s3-deploy.json`
|
|
25
|
+
descriptor (with per-rule content-type and Cache-Control) and a
|
|
26
|
+
`cloudfront-function.js` for pretty-URL and 403/404 -> `/404.html` routing on
|
|
27
|
+
S3 + CloudFront, without an AWS SDK dependency.
|
|
28
|
+
- Added opt-in critical CSS inlining via stylesheet `inline`, emitting a
|
|
29
|
+
`<style>` element instead of a render-blocking `<link rel="stylesheet">`.
|
|
30
|
+
- Added self-hosted subset font preloading via `preload` (all or the first N
|
|
31
|
+
fonts) and ensured `font-display: swap`.
|
|
32
|
+
- Changed the published package to exclude `docs/`.
|
|
33
|
+
|
|
3
34
|
## 0.1.0 - 2026-06-29
|
|
4
35
|
|
|
5
36
|
- Added the initial StoneAge data-site SSG release.
|
package/README.md
CHANGED
|
@@ -254,7 +254,61 @@ 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 inline without flagging each stylesheet, set a global policy on `assets`.
|
|
274
|
+
`assets.inlineStylesheets: true` inlines every generated stylesheet (top-level
|
|
275
|
+
and per-page client ones), and `assets.inlineStylesheetMaxBytes: <n>` inlines
|
|
276
|
+
only bundles whose built CSS is at most `n` bytes (UTF-8) — handy for inlining a
|
|
277
|
+
small critical bundle while larger ones stay external. A per-stylesheet `inline`
|
|
278
|
+
flag always wins over the global policy: `inline: false` opts a sheet out and
|
|
279
|
+
`inline: true` opts one in regardless of the threshold. When both options are
|
|
280
|
+
set, `inlineStylesheetMaxBytes` acts as a gate — a bundle over the limit stays
|
|
281
|
+
external even with `inlineStylesheets: true` (a non-positive or non-finite limit
|
|
282
|
+
inlines nothing). The hyogo critical-path setup combines this with font preload
|
|
283
|
+
— inline the small site and fonts CSS while still preloading the primary WOFF2:
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
assets: {
|
|
287
|
+
inlineStylesheetMaxBytes: 16_384, // inline bundles ≤ 16 KB
|
|
288
|
+
stylesheets: [
|
|
289
|
+
{ href: "/assets/site.css", source: "dist/site.css" },
|
|
290
|
+
googleFontsStylesheet("/assets/fonts.css", {
|
|
291
|
+
families: ["Zen Maru Gothic:wght@700;900", "Outfit:wght@700;900"],
|
|
292
|
+
text: subsetText,
|
|
293
|
+
preload: 2, // keep the primary WOFF2 off the critical chain
|
|
294
|
+
}),
|
|
295
|
+
],
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Both stylesheets inline as `<style>` (when within the threshold), the WOFF2
|
|
300
|
+
files are still vendored as immutable assets, and the preload hints render ahead
|
|
301
|
+
of the inlined CSS so fonts are discovered without waiting on CSS.
|
|
302
|
+
|
|
303
|
+
To pull self-hosted fonts off the critical request chain (HTML → CSS → WOFF2),
|
|
304
|
+
emit `<link rel="preload" as="font" type="font/woff2" crossorigin>` hints with
|
|
305
|
+
`googleFontsStylesheet("/assets/fonts.css", { families, text, preload: true })`.
|
|
306
|
+
`preload: true` preloads every WOFF2 the stylesheet resolves; pass a number
|
|
307
|
+
(`preload: 1`) to preload only the first N fonts in CSS source order and avoid
|
|
308
|
+
over-preloading subset ranges the page may not use. Preload hints render before
|
|
309
|
+
the stylesheet and are only honored on top-level `assets.stylesheets`; fonts
|
|
310
|
+
declared in per-page client assets are not preloaded so a hint is never forced
|
|
311
|
+
onto every page.
|
|
258
312
|
|
|
259
313
|
For Vite builds, `loadViteBuildAssetsFromManifest("dist/.vite/manifest.json", {
|
|
260
314
|
sourceDir: "dist", base: "/", entriesOnly: true })` reads a Vite manifest and
|
|
@@ -744,6 +798,24 @@ never overwritten), and non-internal sources are left to the manifest. Like
|
|
|
744
798
|
`redirects.json` itself, these stubs are not swept on incremental builds, so
|
|
745
799
|
clear `dist/` for a clean rebuild after removing redirect rules.
|
|
746
800
|
|
|
801
|
+
Run `deploy --provider s3` to target AWS S3 + CloudFront. Instead of Netlify
|
|
802
|
+
files it writes two host-agnostic artifacts next to the output: `s3-deploy.json`
|
|
803
|
+
and `cloudfront-function.js`. `s3-deploy.json` is a provider-neutral deploy
|
|
804
|
+
descriptor derived from the same `redirects.json`/`headers.json` manifests: each
|
|
805
|
+
header rule carries the `contentType` and `cacheControl` to apply when uploading
|
|
806
|
+
the matching objects (so content-hashed assets keep
|
|
807
|
+
`public, max-age=31536000, immutable` and re-syncs do not drop it), a
|
|
808
|
+
`defaults.cacheControl` short-revalidation value covers files no rule matches,
|
|
809
|
+
`redirects` preserves the abstract redirect list, and `errorResponses` maps
|
|
810
|
+
CloudFront 403/404 origin errors to `/404.html`. `cloudfront-function.js` is a
|
|
811
|
+
viewer-request CloudFront Function that resolves pretty URLs to the object the
|
|
812
|
+
build actually wrote. Pass `--trailing-slash always` (default) for sites whose
|
|
813
|
+
pages live at `dir/index.html` (resolves `/dir/` and `/dir`), or
|
|
814
|
+
`--trailing-slash never` for `trailing-slash never` builds whose pages live at
|
|
815
|
+
`name.html` (resolves `/about` to `/about.html`); the chosen style is recorded
|
|
816
|
+
in `s3-deploy.json`. The command does not upload to AWS; it emits the metadata
|
|
817
|
+
and routing logic an upload step or infrastructure template consumes.
|
|
818
|
+
|
|
747
819
|
`benchmark` measures generated output sizes without precompressing public files;
|
|
748
820
|
runtime Brotli/Gzip compression is expected to be handled by the serving layer.
|
|
749
821
|
The benchmark command accepts `--trailing-slash always` or
|
package/dist/agent-skill.d.ts
CHANGED
|
@@ -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
|
|
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";
|
package/dist/agent-skill.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
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 &&
|
|
764
|
-
|
|
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,
|
|
1660
|
-
//
|
|
1661
|
-
|
|
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
|
|
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>
|
|
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>
|
|
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;
|
|
@@ -259,6 +303,29 @@ export type BuildConfig = {
|
|
|
259
303
|
assets?: {
|
|
260
304
|
stylesheets?: ClientStylesheetAsset[];
|
|
261
305
|
client?: ClientAssetRegistry;
|
|
306
|
+
/**
|
|
307
|
+
* Inline every generated stylesheet's CSS into each document `<head>` as a
|
|
308
|
+
* `<style>` element instead of emitting render-blocking
|
|
309
|
+
* `<link rel="stylesheet">`s. Applies to both `assets.stylesheets` and the
|
|
310
|
+
* stylesheets resolved from per-page `assets.client` registries (inlining a
|
|
311
|
+
* client stylesheet turns it into a `<style>` on the pages that use it). A
|
|
312
|
+
* per-stylesheet `inline` flag always wins: `inline: false` opts a sheet out
|
|
313
|
+
* even when this is true, and `inline: true` opts one in. When
|
|
314
|
+
* {@link inlineStylesheetMaxBytes} is also set, the byte threshold gates this:
|
|
315
|
+
* a bundle larger than the threshold stays external even with this enabled.
|
|
316
|
+
* Defaults to the existing external-link behavior. Inlined CSS is subject to
|
|
317
|
+
* the page's CSP `style-src`; see {@link InlineStylesheetAsset} for the caveat.
|
|
318
|
+
*/
|
|
319
|
+
inlineStylesheets?: boolean;
|
|
320
|
+
/**
|
|
321
|
+
* Auto-inline a generated stylesheet only when its built CSS is at most this
|
|
322
|
+
* many bytes (UTF-8), measured after Google Fonts CSS resolution. Lets small,
|
|
323
|
+
* critical bundles inline while larger ones stay external. A per-stylesheet
|
|
324
|
+
* `inline` flag still wins over this gate. When {@link inlineStylesheets} is
|
|
325
|
+
* also true, this acts as the gate: bundles over the threshold are not
|
|
326
|
+
* inlined. A non-positive or non-finite value inlines nothing.
|
|
327
|
+
*/
|
|
328
|
+
inlineStylesheetMaxBytes?: number;
|
|
262
329
|
public?: PublicAssetInput[];
|
|
263
330
|
/**
|
|
264
331
|
* Directory whose tree is copied verbatim onto the output root. Each file
|
|
@@ -371,6 +438,12 @@ export type BuildReport = {
|
|
|
371
438
|
bytes: number;
|
|
372
439
|
}>;
|
|
373
440
|
validation: BuildValidationReport;
|
|
441
|
+
/**
|
|
442
|
+
* Host-wide `/*` header rules (HTML `Cache-Control` / security headers) that
|
|
443
|
+
* are emitted to `headers.json` / `_headers` but are not tied to a file
|
|
444
|
+
* artifact. Persisted so `validate` can treat them as expected entries.
|
|
445
|
+
*/
|
|
446
|
+
publishingHeaders?: PublishingHeaderEntry[];
|
|
374
447
|
};
|
|
375
448
|
export type DataFlowFamilySummary = {
|
|
376
449
|
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,9 +617,18 @@ async function resolveStaticAssetReferences(assets) {
|
|
|
525
617
|
}
|
|
526
618
|
return hashedPath;
|
|
527
619
|
};
|
|
528
|
-
|
|
620
|
+
// Global auto-inline policy, applied to any stylesheet that does not set its
|
|
621
|
+
// own `inline` flag. Threaded through both top-level and per-page client
|
|
622
|
+
// stylesheet resolution so the behavior is consistent everywhere.
|
|
623
|
+
const inlinePolicy = {
|
|
624
|
+
inlineStylesheets: assets.inlineStylesheets,
|
|
625
|
+
inlineStylesheetMaxBytes: assets.inlineStylesheetMaxBytes,
|
|
626
|
+
};
|
|
627
|
+
const stylesheets = await Promise.all((assets.stylesheets ?? []).map(async (stylesheet) => resolveStylesheetAsset(stylesheet, resolveImmutablePublicAsset, collectFontPreloads, inlinePolicy)));
|
|
628
|
+
// Per-page client stylesheets are resolved without a preload collector: a
|
|
629
|
+
// page-scoped font must not become a global preload hint on every page.
|
|
529
630
|
const client = assets.client
|
|
530
|
-
? await resolveClientAssetRegistry(assets.client, resolveImmutablePublicAsset)
|
|
631
|
+
? await resolveClientAssetRegistry(assets.client, resolveImmutablePublicAsset, inlinePolicy)
|
|
531
632
|
: undefined;
|
|
532
633
|
return {
|
|
533
634
|
assets: {
|
|
@@ -537,24 +638,125 @@ async function resolveStaticAssetReferences(assets) {
|
|
|
537
638
|
},
|
|
538
639
|
publicAssets: [...publicAssetsByPath.values()],
|
|
539
640
|
headers: [...headersByPath.values()],
|
|
641
|
+
fontPreloads,
|
|
540
642
|
};
|
|
541
643
|
}
|
|
542
|
-
|
|
543
|
-
|
|
644
|
+
// Decide whether a stylesheet's built CSS should be inlined. A per-stylesheet
|
|
645
|
+
// `inline` flag always wins; otherwise the global policy applies, with
|
|
646
|
+
// `inlineStylesheetMaxBytes` acting as a byte-size gate over `inlineStylesheets`.
|
|
647
|
+
function shouldInlineStylesheet(explicitInline, css, policy) {
|
|
648
|
+
if (explicitInline !== undefined) {
|
|
649
|
+
return explicitInline;
|
|
650
|
+
}
|
|
651
|
+
if (!policy) {
|
|
652
|
+
return false;
|
|
653
|
+
}
|
|
654
|
+
const { inlineStylesheets, inlineStylesheetMaxBytes } = policy;
|
|
655
|
+
if (inlineStylesheetMaxBytes !== undefined) {
|
|
656
|
+
// The threshold gates inlining (even when `inlineStylesheets` is true): a
|
|
657
|
+
// non-positive/non-finite limit inlines nothing, otherwise the built CSS
|
|
658
|
+
// must fit within it.
|
|
659
|
+
if (!Number.isFinite(inlineStylesheetMaxBytes) || inlineStylesheetMaxBytes <= 0) {
|
|
660
|
+
return false;
|
|
661
|
+
}
|
|
662
|
+
return Buffer.byteLength(css, "utf8") <= inlineStylesheetMaxBytes;
|
|
663
|
+
}
|
|
664
|
+
return inlineStylesheets === true;
|
|
665
|
+
}
|
|
666
|
+
// Whether a global policy could ever inline an `inline`-unspecified stylesheet.
|
|
667
|
+
// Lets the resolver skip reading a source file (and keep the byte-identical
|
|
668
|
+
// external path) when inlining is impossible regardless of CSS size.
|
|
669
|
+
function policyMightInline(policy) {
|
|
670
|
+
if (!policy) {
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
const { inlineStylesheets, inlineStylesheetMaxBytes } = policy;
|
|
674
|
+
if (inlineStylesheetMaxBytes !== undefined) {
|
|
675
|
+
return Number.isFinite(inlineStylesheetMaxBytes) && inlineStylesheetMaxBytes > 0;
|
|
676
|
+
}
|
|
677
|
+
return inlineStylesheets === true;
|
|
678
|
+
}
|
|
679
|
+
async function resolveStylesheetAsset(stylesheet, resolveImmutablePublicAsset, onFontPreload, inlinePolicy) {
|
|
680
|
+
// Plain href strings and already-inlined CSS pass through untouched.
|
|
681
|
+
if (typeof stylesheet === "string" || isResolvedInlineStylesheet(stylesheet)) {
|
|
544
682
|
return stylesheet;
|
|
545
683
|
}
|
|
684
|
+
const explicitInline = stylesheet.inline;
|
|
546
685
|
if ("source" in stylesheet) {
|
|
547
|
-
|
|
686
|
+
// `immutable: false` is a deliberate "serve this href as-is" opt-out, so the
|
|
687
|
+
// global inline policy does not touch it; only an explicit `inline: true`
|
|
688
|
+
// can still inline such a sheet.
|
|
689
|
+
if (explicitInline !== true && stylesheet.immutable === false) {
|
|
690
|
+
return stripStylesheetBuildHints(stylesheet);
|
|
691
|
+
}
|
|
692
|
+
// When inlining is impossible (explicit opt-out, or no policy that could
|
|
693
|
+
// inline an unspecified sheet), keep the original source-backed external
|
|
694
|
+
// path so its hashed output stays byte-identical to before.
|
|
695
|
+
if (explicitInline === false || (explicitInline === undefined && !policyMightInline(inlinePolicy))) {
|
|
696
|
+
return resolveImmutablePublicAsset({ source: stylesheet.source }, stylesheet.href);
|
|
697
|
+
}
|
|
698
|
+
// Read the CSS once and reuse it for both the size check and inlining so the
|
|
699
|
+
// file is never read twice.
|
|
700
|
+
const css = await readFile(stylesheet.source, "utf8");
|
|
701
|
+
if (shouldInlineStylesheet(explicitInline, css, inlinePolicy)) {
|
|
702
|
+
// Embed the CSS directly: no hashed public file and no `_headers` entry.
|
|
703
|
+
return { inline: css };
|
|
704
|
+
}
|
|
705
|
+
return resolveImmutablePublicAsset({ content: css }, stylesheet.href);
|
|
706
|
+
}
|
|
707
|
+
// Google Fonts: an `immutable: false` reference is not vendored (legacy
|
|
708
|
+
// behavior), so there are no woff2 to preload or CSS to inline. As above, the
|
|
709
|
+
// global policy leaves this opt-out alone unless `inline: true` is explicit.
|
|
710
|
+
if (explicitInline !== true && stylesheet.immutable === false) {
|
|
711
|
+
return stripStylesheetBuildHints(stylesheet);
|
|
712
|
+
}
|
|
713
|
+
const { css, fontPaths } = await resolveGoogleFontsStylesheet(stylesheet.href, stylesheet.googleFonts, resolveImmutablePublicAsset);
|
|
714
|
+
if (onFontPreload) {
|
|
715
|
+
const selected = selectFontPreloads(fontPaths, stylesheet.googleFonts.preload);
|
|
716
|
+
if (selected.length > 0) {
|
|
717
|
+
onFontPreload(selected);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
// The woff2 files are always vendored above; only the CSS is inlined.
|
|
721
|
+
return shouldInlineStylesheet(explicitInline, css, inlinePolicy)
|
|
722
|
+
? { inline: css }
|
|
723
|
+
: resolveImmutablePublicAsset({ content: css }, stylesheet.href);
|
|
724
|
+
}
|
|
725
|
+
function isResolvedInlineStylesheet(stylesheet) {
|
|
726
|
+
// The resolved inline form carries the CSS text as a string; the input forms
|
|
727
|
+
// only ever carry a boolean `inline` flag, so the string check disambiguates.
|
|
728
|
+
return typeof stylesheet === "object" && typeof stylesheet.inline === "string";
|
|
729
|
+
}
|
|
730
|
+
// Drop the build-time `inline` flag from a non-vendored stylesheet so the
|
|
731
|
+
// rendered form is a clean external reference and never carries a stray boolean
|
|
732
|
+
// `inline` that the renderer might mistake for inlined CSS.
|
|
733
|
+
function stripStylesheetBuildHints(stylesheet) {
|
|
734
|
+
const { inline: _inline, ...rest } = stylesheet;
|
|
735
|
+
return rest;
|
|
736
|
+
}
|
|
737
|
+
function selectFontPreloads(fontPaths, preload) {
|
|
738
|
+
if (preload === undefined || preload === false) {
|
|
739
|
+
return [];
|
|
740
|
+
}
|
|
741
|
+
if (preload === true) {
|
|
742
|
+
return fontPaths;
|
|
548
743
|
}
|
|
549
|
-
|
|
744
|
+
if (!Number.isFinite(preload) || preload <= 0) {
|
|
745
|
+
return [];
|
|
746
|
+
}
|
|
747
|
+
return fontPaths.slice(0, Math.floor(preload));
|
|
550
748
|
}
|
|
551
|
-
async function resolveClientAssetRegistry(registry, resolveImmutablePublicAsset) {
|
|
749
|
+
async function resolveClientAssetRegistry(registry, resolveImmutablePublicAsset, inlinePolicy) {
|
|
552
750
|
const entries = await Promise.all(Object.entries(registry).map(async ([id, declarations]) => [
|
|
553
751
|
id,
|
|
554
752
|
await Promise.all((Array.isArray(declarations) ? declarations : [declarations]).map(async (declaration) => ({
|
|
555
753
|
...(declaration.stylesheets
|
|
556
754
|
? {
|
|
557
|
-
stylesheets: await Promise.all(declaration.stylesheets.map((stylesheet) =>
|
|
755
|
+
stylesheets: await Promise.all(declaration.stylesheets.map((stylesheet) =>
|
|
756
|
+
// No preload collector here: a page-scoped client font must
|
|
757
|
+
// not become a global hint. The global inline policy still
|
|
758
|
+
// applies so client CSS inlines consistently.
|
|
759
|
+
resolveStylesheetAsset(stylesheet, resolveImmutablePublicAsset, undefined, inlinePolicy))),
|
|
558
760
|
}
|
|
559
761
|
: {}),
|
|
560
762
|
...(declaration.scripts
|
|
@@ -584,6 +786,12 @@ async function immutableAssetBytes(asset) {
|
|
|
584
786
|
const defaultGoogleFontsCssEndpoint = "https://fonts.googleapis.com/css2";
|
|
585
787
|
const defaultGoogleFontsUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
|
|
586
788
|
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
|
789
|
+
/**
|
|
790
|
+
* Vendor the Google Fonts subset (CSS + woff2) and return the rewritten CSS
|
|
791
|
+
* together with the hashed woff2 public paths in CSS source order. The caller
|
|
792
|
+
* decides whether to write the CSS as a hashed file or inline it, and which
|
|
793
|
+
* fonts to preload.
|
|
794
|
+
*/
|
|
587
795
|
async function resolveGoogleFontsStylesheet(href, options, resolveImmutablePublicAsset) {
|
|
588
796
|
const fetchAsset = options.fetch ?? defaultAssetFetch;
|
|
589
797
|
const requestHeaders = {
|
|
@@ -610,7 +818,7 @@ async function resolveGoogleFontsStylesheet(href, options, resolveImmutablePubli
|
|
|
610
818
|
fontPathsByUrl.set(font.url, await resolveImmutablePublicAsset({ content: bytes }, fontPublicPath));
|
|
611
819
|
}
|
|
612
820
|
const rewrittenCss = rewriteCssUrls(css, fontPathsByUrl);
|
|
613
|
-
return
|
|
821
|
+
return { css: rewrittenCss, fontPaths: [...fontPathsByUrl.values()] };
|
|
614
822
|
}
|
|
615
823
|
const defaultAssetFetch = async (url, init) => {
|
|
616
824
|
return fetch(url, init);
|
|
@@ -836,9 +1044,17 @@ export async function buildSite(config) {
|
|
|
836
1044
|
};
|
|
837
1045
|
});
|
|
838
1046
|
validateOutputPlan(plannedPages, plannedArtifacts, config.notFound !== undefined);
|
|
1047
|
+
const fontPreloads = staticAssets.fontPreloads;
|
|
839
1048
|
const sharedPageFingerprintHash = hashValue({
|
|
840
1049
|
site: config.site,
|
|
841
1050
|
stylesheets: assets?.stylesheets ?? [],
|
|
1051
|
+
// The global inline policy can flip a stylesheet between external and
|
|
1052
|
+
// inlined; including the options invalidates every page when the policy
|
|
1053
|
+
// changes, even for pages whose CSS lives only in `assets.client` (whose
|
|
1054
|
+
// resolved forms are not part of this shared hash).
|
|
1055
|
+
inlineStylesheets: assets?.inlineStylesheets ?? false,
|
|
1056
|
+
inlineStylesheetMaxBytes: assets?.inlineStylesheetMaxBytes ?? null,
|
|
1057
|
+
fontPreloads,
|
|
842
1058
|
prefetch: config.prefetch ?? {},
|
|
843
1059
|
trailingSlash,
|
|
844
1060
|
renderFingerprint: config.renderFingerprint,
|
|
@@ -866,7 +1082,7 @@ export async function buildSite(config) {
|
|
|
866
1082
|
};
|
|
867
1083
|
}
|
|
868
1084
|
const body = await withDeferredFragmentDefaults({ defaultFallbackText: fragmentDefaultFallbackText }, () => task.entry.render());
|
|
869
|
-
const html = renderDocument(config.site, task.metadata, canonical, body, assets, config.prefetch, trailingSlash, fragmentClientScripts);
|
|
1085
|
+
const html = renderDocument(config.site, task.metadata, canonical, body, assets, config.prefetch, trailingSlash, fragmentClientScripts, fontPreloads);
|
|
870
1086
|
await writeText(absoluteOutputPath, html, {
|
|
871
1087
|
compareExisting: compareExistingGeneratedOutput,
|
|
872
1088
|
});
|
|
@@ -936,7 +1152,7 @@ export async function buildSite(config) {
|
|
|
936
1152
|
});
|
|
937
1153
|
const notFoundResults = config.notFound
|
|
938
1154
|
? [
|
|
939
|
-
await buildNotFoundPage(config, config.notFound, trailingSlash, previousPages, sharedPageFingerprintHash, compareExistingGeneratedOutput, fragmentDefaultFallbackText, fragmentClientScripts, assets),
|
|
1155
|
+
await buildNotFoundPage(config, config.notFound, trailingSlash, previousPages, sharedPageFingerprintHash, compareExistingGeneratedOutput, fragmentDefaultFallbackText, fragmentClientScripts, assets, fontPreloads),
|
|
940
1156
|
]
|
|
941
1157
|
: [];
|
|
942
1158
|
const allPageResults = [...pageResults, ...notFoundResults];
|
|
@@ -1043,6 +1259,16 @@ export async function buildSite(config) {
|
|
|
1043
1259
|
pages,
|
|
1044
1260
|
artifacts,
|
|
1045
1261
|
};
|
|
1262
|
+
// Bake the immutable `Cache-Control` into content-hashed / `_assets` /
|
|
1263
|
+
// `_stoneage` artifacts before the manifest is compared, persisted, and
|
|
1264
|
+
// published, so `headers.json`, `_headers`, and `validate` all stay in sync.
|
|
1265
|
+
const publishesHeaders = config.publishing !== undefined && config.publishing.headers !== false;
|
|
1266
|
+
if (publishesHeaders) {
|
|
1267
|
+
applyImmutableAssetHeaders(manifest.artifacts);
|
|
1268
|
+
}
|
|
1269
|
+
const globalPublishingHeaders = config.publishing !== undefined
|
|
1270
|
+
? resolveGlobalPublishingHeaders(config.publishing)
|
|
1271
|
+
: [];
|
|
1046
1272
|
const staleOutputsRemoved = config.partial
|
|
1047
1273
|
? 0
|
|
1048
1274
|
: (await removeStaleGeneratedOutputs(config.outDir, previousManifest, manifest)) +
|
|
@@ -1072,6 +1298,14 @@ export async function buildSite(config) {
|
|
|
1072
1298
|
artifactWrites,
|
|
1073
1299
|
})
|
|
1074
1300
|
: 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);
|
|
1301
|
+
// Persist the host-wide `/*` header rules so `validate` can reconstruct them
|
|
1302
|
+
// (they are not tied to a file artifact). Always set the fresh value, even on
|
|
1303
|
+
// the report-reuse path, and force a report rewrite when it changes so the
|
|
1304
|
+
// persisted report never lags the regenerated `headers.json`.
|
|
1305
|
+
report.publishingHeaders =
|
|
1306
|
+
globalPublishingHeaders.length > 0 ? globalPublishingHeaders : undefined;
|
|
1307
|
+
const publishingHeadersChanged = stableJson(previousReport?.publishingHeaders ?? []) !==
|
|
1308
|
+
stableJson(globalPublishingHeaders);
|
|
1075
1309
|
const missingPreviousManifest = previousManifest.pages.length === 0 && previousManifest.artifacts.length === 0;
|
|
1076
1310
|
const missingBuildReport = previousReport === undefined;
|
|
1077
1311
|
const dataFlowSummaryReportPath = join(config.outDir, ".stoneage/data-flow-summary.json");
|
|
@@ -1087,6 +1321,7 @@ export async function buildSite(config) {
|
|
|
1087
1321
|
staleOutputsRemoved > 0 ||
|
|
1088
1322
|
missingPreviousManifest ||
|
|
1089
1323
|
missingBuildReport ||
|
|
1324
|
+
publishingHeadersChanged ||
|
|
1090
1325
|
shouldWriteSitemaps;
|
|
1091
1326
|
const validationReportChanged = !shouldWriteBuildReportsWithoutComparisons &&
|
|
1092
1327
|
!canReusePreviousReport &&
|
|
@@ -1131,7 +1366,7 @@ export async function buildSite(config) {
|
|
|
1131
1366
|
await rm(legacyDataFlowReportPath, { force: true });
|
|
1132
1367
|
}
|
|
1133
1368
|
if (config.publishing) {
|
|
1134
|
-
await writePublishingManifests(config.outDir, config.publishing, config.site.baseUrl, trailingSlash, manifest, redirectHtmlFiles, staticAssets.headers);
|
|
1369
|
+
await writePublishingManifests(config.outDir, config.publishing, config.site.baseUrl, trailingSlash, manifest, redirectHtmlFiles, staticAssets.headers, globalPublishingHeaders);
|
|
1135
1370
|
}
|
|
1136
1371
|
return {
|
|
1137
1372
|
pagesWritten,
|
|
@@ -1142,7 +1377,7 @@ export async function buildSite(config) {
|
|
|
1142
1377
|
report,
|
|
1143
1378
|
};
|
|
1144
1379
|
}
|
|
1145
|
-
async function buildNotFoundPage(config, notFound, trailingSlash, previousPages, sharedPageFingerprintHash, compareExistingGeneratedOutput, fragmentDefaultFallbackText, fragmentClientScripts, assets) {
|
|
1380
|
+
async function buildNotFoundPage(config, notFound, trailingSlash, previousPages, sharedPageFingerprintHash, compareExistingGeneratedOutput, fragmentDefaultFallbackText, fragmentClientScripts, assets, fontPreloads) {
|
|
1146
1381
|
const metadata = {
|
|
1147
1382
|
...resolvePageMetadata(undefined, undefined, notFound.metadata),
|
|
1148
1383
|
// A 404 page must never be indexed or advertised in the sitemap.
|
|
@@ -1168,7 +1403,7 @@ async function buildNotFoundPage(config, notFound, trailingSlash, previousPages,
|
|
|
1168
1403
|
};
|
|
1169
1404
|
}
|
|
1170
1405
|
const body = await withDeferredFragmentDefaults({ defaultFallbackText: fragmentDefaultFallbackText }, () => notFound.render());
|
|
1171
|
-
const html = renderDocument(config.site, metadata, canonical, body, assets, config.prefetch, trailingSlash, fragmentClientScripts);
|
|
1406
|
+
const html = renderDocument(config.site, metadata, canonical, body, assets, config.prefetch, trailingSlash, fragmentClientScripts, fontPreloads);
|
|
1172
1407
|
await writeText(absoluteOutputPath, html, {
|
|
1173
1408
|
compareExisting: compareExistingGeneratedOutput,
|
|
1174
1409
|
});
|
|
@@ -1843,7 +2078,7 @@ function sameDependencies(left, right) {
|
|
|
1843
2078
|
}
|
|
1844
2079
|
return leftCount === rightCount;
|
|
1845
2080
|
}
|
|
1846
|
-
function renderDocument(site, metadata, canonical, body, assets, prefetch, trailingSlash = "always", extraClientScripts = []) {
|
|
2081
|
+
function renderDocument(site, metadata, canonical, body, assets, prefetch, trailingSlash = "always", extraClientScripts = [], fontPreloads = []) {
|
|
1847
2082
|
const title = renderTitle(metadata.title, site);
|
|
1848
2083
|
const description = metadata.description || site.description;
|
|
1849
2084
|
const ogTitle = metadata.ogTitle ?? metadata.title;
|
|
@@ -1884,9 +2119,16 @@ function renderDocument(site, metadata, canonical, body, assets, prefetch, trail
|
|
|
1884
2119
|
const twitterMeta = renderTwitterMetadata(site.twitter, metadata.twitter);
|
|
1885
2120
|
const customHead = [...(site.head ?? []), ...(metadata.head ?? [])].map(renderHeadTag).join("");
|
|
1886
2121
|
const pageClientAssets = resolvePageClientAssets(assets, metadata);
|
|
2122
|
+
// Preload hints go before the stylesheet links so fonts leave the critical
|
|
2123
|
+
// request chain (HTML → CSS → woff2).
|
|
2124
|
+
const fontPreloadLinks = fontPreloads
|
|
2125
|
+
.map((href) => `<link rel="preload" href="${escapeAttribute(href)}" as="font" type="font/woff2" crossorigin>`)
|
|
2126
|
+
.join("");
|
|
1887
2127
|
const stylesheetLinks = (assets?.stylesheets ?? [])
|
|
1888
2128
|
.concat(pageClientAssets.stylesheets)
|
|
1889
|
-
.map((stylesheet) =>
|
|
2129
|
+
.map((stylesheet) => isResolvedInlineStylesheet(stylesheet)
|
|
2130
|
+
? `<style>${escapeInlineStyleContent(stylesheet.inline)}</style>`
|
|
2131
|
+
: `<link rel="stylesheet" href="${escapeAttribute(stylesheetHref(stylesheet))}">`)
|
|
1890
2132
|
.join("");
|
|
1891
2133
|
const clientScripts = renderClientScripts([
|
|
1892
2134
|
...extraClientScripts,
|
|
@@ -1901,7 +2143,7 @@ function renderDocument(site, metadata, canonical, body, assets, prefetch, trail
|
|
|
1901
2143
|
const serviceWorkerPrefetch = prefetch?.serviceWorker
|
|
1902
2144
|
? renderServiceWorkerPrefetch(prefetchUrls, prefetch)
|
|
1903
2145
|
: "";
|
|
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}`;
|
|
2146
|
+
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
2147
|
}
|
|
1906
2148
|
function renderTitle(pageTitle, site) {
|
|
1907
2149
|
return site.titleTemplate
|
|
@@ -1953,7 +2195,20 @@ function resolvePageClientAssets(assets, metadata) {
|
|
|
1953
2195
|
return resolveClientAssets(assets?.client ?? {}, ids);
|
|
1954
2196
|
}
|
|
1955
2197
|
function stylesheetHref(stylesheet) {
|
|
1956
|
-
|
|
2198
|
+
if (typeof stylesheet === "string") {
|
|
2199
|
+
return stylesheet;
|
|
2200
|
+
}
|
|
2201
|
+
// Inlined stylesheets are rendered as <style> and never reach this helper.
|
|
2202
|
+
return "href" in stylesheet ? stylesheet.href : "";
|
|
2203
|
+
}
|
|
2204
|
+
// The body of a <style> element is HTML "raw text": a literal `</style` token
|
|
2205
|
+
// (which can validly appear inside a CSS comment or a `content:` string) would
|
|
2206
|
+
// close the element early and corrupt the page. A backslash is a valid CSS
|
|
2207
|
+
// escape, so `<\/style` is byte-for-byte equivalent to `</style` in the only
|
|
2208
|
+
// places it can legitimately occur, while no longer matching the raw-text end
|
|
2209
|
+
// token the HTML parser scans for.
|
|
2210
|
+
function escapeInlineStyleContent(css) {
|
|
2211
|
+
return css.replace(/<\/(style)/gi, "<\\/$1");
|
|
1957
2212
|
}
|
|
1958
2213
|
function renderClientScripts(scripts) {
|
|
1959
2214
|
return scripts.map((script) => `<script${renderScriptAttributes(script)}></script>`).join("");
|
|
@@ -2005,7 +2260,7 @@ function renderPrefetchScript(workerPath) {
|
|
|
2005
2260
|
function renderPrefetchWorker(cacheName) {
|
|
2006
2261
|
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
2262
|
}
|
|
2008
|
-
async function writePublishingManifests(outDir, publishing, baseUrl, trailingSlash, manifest, redirectHtmlFiles, staticAssetHeaders = []) {
|
|
2263
|
+
async function writePublishingManifests(outDir, publishing, baseUrl, trailingSlash, manifest, redirectHtmlFiles, staticAssetHeaders = [], globalHeaders = []) {
|
|
2009
2264
|
const redirects = normalizePublishingRedirects(publishing.redirects ?? [], trailingSlash);
|
|
2010
2265
|
if (publishing.robots !== undefined && publishing.robots !== false) {
|
|
2011
2266
|
await writeText(join(outDir, "robots.txt"), renderRobotsTxt(publishing.robots, baseUrl, trailingSlash));
|
|
@@ -2023,12 +2278,23 @@ async function writePublishingManifests(outDir, publishing, baseUrl, trailingSla
|
|
|
2023
2278
|
await rm(join(outDir, "headers.json"), { force: true });
|
|
2024
2279
|
}
|
|
2025
2280
|
else {
|
|
2026
|
-
headers = await writeArtifactHeadersManifest(outDir, manifest, staticAssetHeaders);
|
|
2281
|
+
headers = await writeArtifactHeadersManifest(outDir, manifest, staticAssetHeaders, globalHeaders);
|
|
2027
2282
|
}
|
|
2028
2283
|
await writeNativePublishingArtifacts(outDir, publishing, redirects, headers);
|
|
2284
|
+
// SR-4: drop the build-intermediate manifests from the published output once
|
|
2285
|
+
// the host-native artifacts have been written from the in-memory data above.
|
|
2286
|
+
if (publishing.publishManifests === false) {
|
|
2287
|
+
await Promise.all([
|
|
2288
|
+
rm(join(outDir, "headers.json"), { force: true }),
|
|
2289
|
+
rm(join(outDir, "redirects.json"), { force: true }),
|
|
2290
|
+
]);
|
|
2291
|
+
}
|
|
2029
2292
|
}
|
|
2030
|
-
async function writeArtifactHeadersManifest(outDir, manifest, staticAssetHeaders = []) {
|
|
2293
|
+
async function writeArtifactHeadersManifest(outDir, manifest, staticAssetHeaders = [], globalHeaders = []) {
|
|
2031
2294
|
const headers = [
|
|
2295
|
+
// Host-wide `/*` rules come first so more specific artifact / asset rules
|
|
2296
|
+
// (e.g. immutable `Cache-Control`) take precedence in `_headers`.
|
|
2297
|
+
...globalHeaders,
|
|
2032
2298
|
...manifest.artifacts
|
|
2033
2299
|
.map((artifact) => {
|
|
2034
2300
|
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;
|