diff --git a/.changeset/hungry-hounds-argue.md b/.changeset/hungry-hounds-argue.md new file mode 100644 index 000000000000..81fe65d98f35 --- /dev/null +++ b/.changeset/hungry-hounds-argue.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: pass the source file `name` to the `preload` filter for fonts diff --git a/documentation/docs/30-advanced/20-hooks.md b/documentation/docs/30-advanced/20-hooks.md index 6d1275d8665d..4213ad7b7ca2 100644 --- a/documentation/docs/30-advanced/20-hooks.md +++ b/documentation/docs/30-advanced/20-hooks.md @@ -45,7 +45,7 @@ You can define multiple `handle` functions and execute them with the [`sequence` - `transformPageChunk(opts: { html: string, done: boolean }): MaybePromise` — applies custom transforms to HTML. If `done` is true, it's the final chunk. Chunks are not guaranteed to be well-formed HTML (they could include an element's opening tag but not its closing tag, for example) but they will always be split at sensible boundaries such as `%sveltekit.head%` or layout/page components. - `filterSerializedResponseHeaders(name: string, value: string): boolean` — determines which headers should be included in serialized responses when a `load` function loads a resource with `fetch`. By default, none will be included. -- `preload(input: { type: 'js' | 'css' | 'font' | 'asset', path: string }): boolean` — determines which files should be preloaded. Files are preloaded via `` tags added to the `` tag; if [`output.linkHeaderPreload`](configuration#output) is enabled, dynamically rendered pages use the [`Link` response header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link) instead. The method is called with each file that was found at build time while constructing the code chunks — so if you for example have `import './styles.css` in your `+page.svelte`, `preload` will be called with the resolved path to that CSS file when visiting that page. Note that in dev mode `preload` is _not_ called, since it depends on analysis that happens at build time. Preloading can improve performance by downloading assets sooner, but it can also hurt if too much is downloaded unnecessarily. By default, `js` and `css` files will be preloaded. `asset` files are not preloaded at all currently, but we may add this later after evaluating feedback. +- `preload(input: { type: 'js' | 'css' | 'asset', path: string } | { type: 'font', path: string, name: string }): boolean` — determines which files should be preloaded. Files are preloaded via `` tags added to the `` tag; if [`output.linkHeaderPreload`](configuration#output) is enabled, dynamically rendered pages use the [`Link` response header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link) instead. The method is called with each file that was found at build time while constructing the code chunks — so if you for example have `import './styles.css` in your `+page.svelte`, `preload` will be called with the resolved path to that CSS file when visiting that page. Note that in dev mode `preload` is _not_ called, since it depends on analysis that happens at build time. Preloading can improve performance by downloading assets sooner, but it can also hurt if too much is downloaded unnecessarily. By default, `js` and `css` files will be preloaded. `asset` files are not preloaded at all currently, but we may add this later after evaluating feedback. For `font` files, `input` also has a `name` property, the source file's name, so that a filter can match on it instead of the hashed path. ```js /// file: src/hooks.server.js diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 8b493da6337a..c413d6e13c82 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -1732,9 +1732,17 @@ export interface ResolveOptions { * `` tag; if `output.linkHeaderPreload` is enabled, dynamically rendered pages use the * [`Link` response header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link) instead. * By default, `js` and `css` files will be preloaded. + * + * For `font` files, `input` also has a `name` property, the source file's name, so that a + * filter can match on it instead of the hashed path. `js` and `css` files are bundled and + * have no single source file name. * @param input the type of the file and its path */ - preload?: (input: { type: 'font' | 'css' | 'js' | 'asset'; path: string }) => boolean; + preload?: ( + input: + | { type: 'css' | 'js' | 'asset'; path: string } + | { type: 'font'; path: string; name: string } + ) => boolean; } export interface RouteDefinition { diff --git a/packages/kit/src/exports/vite/build/build_server.js b/packages/kit/src/exports/vite/build/build_server.js index de55700d5cb5..225904c6eba3 100644 --- a/packages/kit/src/exports/vite/build/build_server.js +++ b/packages/kit/src/exports/vite/build/build_server.js @@ -129,7 +129,7 @@ export function build_server_nodes( /** @type {string[]} */ let stylesheets = []; - /** @type {string[]} */ + /** @type {import('types').FontDependency[]} */ let fonts = []; /** @type {Set} */ @@ -213,7 +213,7 @@ export function build_server_nodes( imported = entry.imports; stylesheets = Array.from(eager_css); - fonts = filter_fonts(Array.from(eager_assets)); + fonts = filter_fonts(Array.from(eager_assets), client_manifest); } else { for (const entry of [component, universal]) { if (!entry) continue; diff --git a/packages/kit/src/exports/vite/build/utils.js b/packages/kit/src/exports/vite/build/utils.js index 503c959f4b0a..b7aa684b353e 100644 --- a/packages/kit/src/exports/vite/build/utils.js +++ b/packages/kit/src/exports/vite/build/utils.js @@ -94,7 +94,7 @@ export function find_deps(manifest, entry, add_dynamic_css, root) { imports: Array.from(imports), stylesheets: Array.from(stylesheets), // TODO do we need this separately? - fonts: filter_fonts(assets), + fonts: filter_fonts(assets, manifest), stylesheet_map }; } @@ -116,12 +116,36 @@ export function resolve_symlinks(manifest, file, root) { return { chunk, file }; } +/** @type {WeakMap>} */ +const source_maps = new WeakMap(); + /** * @param {string[]} assets - * @returns {string[]} + * @param {import('vite').Manifest} manifest + * @returns {import('types').FontDependency[]} */ -export function filter_fonts(assets) { - return assets.filter((asset) => /\.(woff2?|ttf|otf)$/.test(asset)); +export function filter_fonts(assets, manifest) { + let sources = source_maps.get(manifest); + + if (!sources) { + sources = new Map(); + source_maps.set(manifest, sources); + + // identical files are emitted once but can have several sources — keep the first + for (const key of Object.keys(manifest).sort()) { + const { file, src } = manifest[key]; + if (src && !sources.has(file)) { + sources.set(file, src); + } + } + } + + return assets + .filter((asset) => /\.(woff2?|ttf|otf)$/.test(asset)) + .map((file) => { + const src = sources.get(file) ?? file; + return { file, name: path.basename(src) }; + }); } /** diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 676afaf8f56d..f3e8fab36660 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -72,7 +72,9 @@ export async function render_response({ const modulepreloads = new Set(client?.imports); const stylesheets = new Set(client?.stylesheets); - const fonts = new Set(client?.fonts); + + /** @type {Map} */ + const fonts = new Map(client?.fonts.map((font) => [font.file, font])); /** * The value of the Link header that is added to the response when not prerendering @@ -262,7 +264,7 @@ export async function render_response({ for (const { node } of branch) { for (const url of node.imports) modulepreloads.add(url); for (const url of node.stylesheets) stylesheets.add(url); - for (const url of node.fonts) fonts.add(url); + for (const font of node.fonts) fonts.set(font.file, font); if (node.inline_styles && !client?.inline) { Object.entries(await node.inline_styles()).forEach(([filename, css]) => { @@ -335,11 +337,11 @@ export async function render_response({ head.add_stylesheet(path, attributes); } - for (const dep of fonts) { - const path = prefixed(dep); + for (const { file, name } of fonts.values()) { + const path = prefixed(file); - if (resolve_opts.preload({ type: 'font', path })) { - const ext = dep.slice(dep.lastIndexOf('.') + 1); + if (resolve_opts.preload({ type: 'font', path, name })) { + const ext = file.slice(file.lastIndexOf('.') + 1); add_preload(path, ['rel="preload"', 'as="font"', `type="font/${ext}"`, 'crossorigin']); } diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 547a3f103758..197f7bdf542d 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -62,10 +62,17 @@ export interface AssetDependencies { file: string; imports: string[]; stylesheets: string[]; - fonts: string[]; + fonts: FontDependency[]; stylesheet_map: Map; assets: Set }>; } +export interface FontDependency { + /** emitted file path, relative to the client output directory */ + file: string; + /** the source file's basename, before hashing and character sanitization */ + name: string; +} + export interface BuildData { app_dir: string; app_path: string; @@ -98,7 +105,7 @@ export interface BuildData { */ routes?: SSRClientRoute[]; stylesheets: string[]; - fonts: string[]; + fonts: FontDependency[]; /** * Whether the client uses public dynamic env vars — `$env/dynamic/public` or `$app/env/public`. */ @@ -449,7 +456,7 @@ export interface SSRNode { /** external CSS files that are loaded on the client */ stylesheets: string[]; /** external font files that are loaded on the client */ - fonts: string[]; + fonts: FontDependency[]; universal_id?: string; server_id?: string; diff --git a/packages/kit/test/apps/basics/src/hooks.server.js b/packages/kit/test/apps/basics/src/hooks.server.js index dc20c948692c..a78eb04955cd 100644 --- a/packages/kit/test/apps/basics/src/hooks.server.js +++ b/packages/kit/test/apps/basics/src/hooks.server.js @@ -193,8 +193,10 @@ export const handle = sequence( } return resolve(event, { - // needed for asset-preload tests - preload: () => true + // needed for asset-preload tests, which assert `name` is the unhashed file name + preload: (input) => + input.type !== 'font' || + ['shlop.woff2', 'shlop.var.woff2', 'shlop+bold.woff2'].includes(input.name) }); } ); diff --git a/packages/kit/test/apps/basics/src/routes/asset-preload/shlop+bold.woff2 b/packages/kit/test/apps/basics/src/routes/asset-preload/shlop+bold.woff2 new file mode 100644 index 000000000000..c213444215c1 Binary files /dev/null and b/packages/kit/test/apps/basics/src/routes/asset-preload/shlop+bold.woff2 differ diff --git a/packages/kit/test/apps/basics/src/routes/asset-preload/shlop.var.woff2 b/packages/kit/test/apps/basics/src/routes/asset-preload/shlop.var.woff2 new file mode 100644 index 000000000000..95964213afa7 Binary files /dev/null and b/packages/kit/test/apps/basics/src/routes/asset-preload/shlop.var.woff2 differ diff --git a/packages/kit/test/apps/basics/src/routes/asset-preload/styles.css b/packages/kit/test/apps/basics/src/routes/asset-preload/styles.css index fef546dd088d..51cd6e05f708 100644 --- a/packages/kit/test/apps/basics/src/routes/asset-preload/styles.css +++ b/packages/kit/test/apps/basics/src/routes/asset-preload/styles.css @@ -3,3 +3,14 @@ font-family: 'Shlop'; src: url(./shlop.woff2); } + +@font-face { + /* the dot in the file name must survive hash stripping */ + font-family: 'Shlop Var'; + src: url(./shlop.var.woff2); +} + +@font-face { + font-family: 'shlopbold'; + src: url('./shlop+bold.woff2') format('woff2'); +} diff --git a/packages/kit/test/apps/basics/test/server.test.js b/packages/kit/test/apps/basics/test/server.test.js index b83589978f95..f5b2b0c4217f 100644 --- a/packages/kit/test/apps/basics/test/server.test.js +++ b/packages/kit/test/apps/basics/test/server.test.js @@ -1508,6 +1508,10 @@ test.describe('asset preload', () => { expect(body).toContain('rel="modulepreload"'); expect(body).toContain('as="font"'); + expect(body).toMatch(/href="[^"]+\/shlop\.[^".]+\.woff2"/); + expect(body).toMatch(/href="[^"]+\/shlop\.var\.[^".]+\.woff2"/); + // the emitted file name is sanitized, but the filter matched on the original `shlop+bold.woff2` + expect(body).toMatch(/href="[^"]+\/shlop_bold\.[^".]+\.woff2"/); }); }); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index b35569cdfc29..16cd6356f5e8 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1702,9 +1702,17 @@ declare module '@sveltejs/kit' { * `` tag; if `output.linkHeaderPreload` is enabled, dynamically rendered pages use the * [`Link` response header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link) instead. * By default, `js` and `css` files will be preloaded. + * + * For `font` files, `input` also has a `name` property, the source file's name, so that a + * filter can match on it instead of the hashed path. `js` and `css` files are bundled and + * have no single source file name. * @param input the type of the file and its path */ - preload?: (input: { type: 'font' | 'css' | 'js' | 'asset'; path: string }) => boolean; + preload?: ( + input: + | { type: 'css' | 'js' | 'asset'; path: string } + | { type: 'font'; path: string; name: string } + ) => boolean; } export interface RouteDefinition {