Skip to content
5 changes: 5 additions & 0 deletions .changeset/hungry-hounds-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: pass the source file `name` to the `preload` filter for fonts
2 changes: 1 addition & 1 deletion documentation/docs/30-advanced/20-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ You can define multiple `handle` functions and execute them with the [`sequence`

- `transformPageChunk(opts: { html: string, done: boolean }): MaybePromise<string | undefined>` — 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 `<link>` tags added to the `<head>` 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 `<link>` tags added to the `<head>` 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
Expand Down
10 changes: 9 additions & 1 deletion packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1732,9 +1732,17 @@ export interface ResolveOptions {
* `<head>` 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<Config = any> {
Expand Down
4 changes: 2 additions & 2 deletions packages/kit/src/exports/vite/build/build_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export function build_server_nodes(
/** @type {string[]} */
let stylesheets = [];

/** @type {string[]} */
/** @type {import('types').FontDependency[]} */
let fonts = [];

/** @type {Set<string>} */
Expand Down Expand Up @@ -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;
Expand Down
32 changes: 28 additions & 4 deletions packages/kit/src/exports/vite/build/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}
Expand All @@ -116,12 +116,36 @@ export function resolve_symlinks(manifest, file, root) {
return { chunk, file };
}

/** @type {WeakMap<import('vite').Manifest, Map<string, string>>} */
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) };
});
}

/**
Expand Down
14 changes: 8 additions & 6 deletions packages/kit/src/runtime/server/page/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, import('types').FontDependency>} */
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
Expand Down Expand Up @@ -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]) => {
Expand Down Expand Up @@ -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']);
}
Expand Down
13 changes: 10 additions & 3 deletions packages/kit/src/types/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,17 @@ export interface AssetDependencies {
file: string;
imports: string[];
stylesheets: string[];
fonts: string[];
fonts: FontDependency[];
stylesheet_map: Map<string, { css: Set<string>; assets: Set<string> }>;
}

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;
Expand Down Expand Up @@ -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`.
*/
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions packages/kit/test/apps/basics/src/hooks.server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
});
}
);
Expand Down
Binary file not shown.
Binary file not shown.
11 changes: 11 additions & 0 deletions packages/kit/test/apps/basics/src/routes/asset-preload/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
4 changes: 4 additions & 0 deletions packages/kit/test/apps/basics/test/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"/);
});
});

Expand Down
10 changes: 9 additions & 1 deletion packages/kit/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1702,9 +1702,17 @@ declare module '@sveltejs/kit' {
* `<head>` 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<Config = any> {
Expand Down
Loading