Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ svgJar({
// Replace non-none fill/stroke with currentColor globally.
// Default: false
currentColor: false,

// Sprite sheets to inline into the HTML document rather than emit as
// external files. See "Embedded sprites" below.
// true: embed all sprites | string[]: embed named sprites | false (default): external files
embedded: false,
});
```

Expand Down Expand Up @@ -341,6 +346,66 @@ SVGs containing `<use href="other.svg">` or `<image href="photo.png">` have thei

In dev mode, `<use>` SVG references are embedded as local `<symbol>` entries for cross-browser compatibility.

## Embedded sprites

By default, sprite sheets are emitted as separate asset files (e.g. `sprite-abc123.svg`) and referenced via `<use href="/assets/sprite-abc123.svg#symbolId">`. This is the most efficient delivery for static icons — the sprite is cached independently and shared across pages.

However, when the sprite file is loaded as an external document, browsers impose a hard restriction: **CSS and SMIL animations defined inside `<symbol>` elements do not run**. Specifically:

- `@keyframes` rules in `<style>` tags inside a `<symbol>` are not applied to the referencing document
- SMIL animation elements (`<animate>`, `<animateMotion>`, `<animateTransform>`) inside a `<symbol>` do not execute
- `@font-face` rules inside SVG `<style>` tags are not registered in the referencing document

This is a fundamental browser security boundary, not a plugin limitation. The symbol's styles and animations live in a separate SVG document and cannot affect the page that references them via `<use href>`.

The solution is to inline the sprite sheet directly into the HTML document. When the `<svg style="display:none">` containing the symbols is part of the same document as the `<use>` elements, all styles and animations work correctly, since they share the same browsing context.

### Using the `embedded` option (Vite only)

Pass the names of any sprite sheets that should be inlined into the HTML via `transformIndexHtml`:

```ts
// vite.config.ts
import svgJar from '@svg-jar/plugin/vite';

export default {
plugins: [
svgJar({
target: 'dom',
embedded: ['animated'], // inline the 'animated' sprite, keep others external
}),
],
};
```

Then import your animated SVGs into that sprite as normal:

```ts
import Spinner from './icons/spinner.svg?sprite=animated';
import Pulse from './icons/pulse.svg?sprite=animated';
```

At build time, the plugin:

1. Assembles the `animated` sprite sheet as usual
2. Emits it as a file (available for inspection and caching if needed)
3. Injects the sprite's `<svg style="display:none">` inline at the start of `<body>` in each HTML entry point
4. Generates `<use href="#symbolId">` (local fragment references) instead of `<use href="/assets/animated-hash.svg#symbolId">` (external file references)

The result is that all animations, `@keyframes`, and `@font-face` rules inside the symbols work correctly in production builds.

To embed all sprites:

```ts
svgJar({ embedded: true });
```

> [!NOTE]
> The `embedded` option only applies to Vite builds. It has no effect in Rollup or other bundlers, which do not have an HTML entry point for the plugin to transform.

> [!NOTE]
> Embedded sprites are still emitted as external files — they are additionally injected into the HTML. Dev mode is unaffected: symbols are always rendered as self-contained inline SVGs during development.

## What's unsafe about inline SVGs?

The `?unsafe-inline` query is intentionally named to make you think twice. In most cases, sprite mode (the default) is the better choice. Inline SVGs have real costs that aren't immediately obvious:
Expand Down
5 changes: 3 additions & 2 deletions plugin/src/codegen/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function generateCode(ctx: CodegenContext): string {
);
}

// Production sprite mode — embed a placeholder that renderChunk resolves.
return generateProd(ctx.target, ctx.symbolId, ctx.viewBox, ctx.width, ctx.height);
// Production sprite mode — embed a placeholder that renderChunk resolves,
// or a local fragment ref for embedded sprites.
return generateProd(ctx.target, ctx.symbolId, ctx.viewBox, ctx.width, ctx.height, ctx.isEmbedded);
}
10 changes: 8 additions & 2 deletions plugin/src/codegen/prod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,18 @@ export function generateProd(
viewBox: string | null,
width: string | null,
height: string | null,
isEmbedded = false,
): string {
const runtimePath = RUNTIME_PATHS[target];
const placeholder = makePlaceholder(symbolId);

// Embedded sprites are inlined in the HTML document — use a local fragment
// reference that is stable at codegen time (no renderChunk replacement needed).
// Non-embedded sprites use a placeholder that renderChunk replaces with the
// final hashed sprite URL.
const href = isEmbedded ? JSON.stringify(`#${symbolId}`) : `"${makePlaceholder(symbolId)}"`;

return [
`import { createSvg } from '${runtimePath}';`,
`export default /*#__PURE__*/ createSvg(${JSON.stringify(viewBox)}, ${JSON.stringify(width)}, ${JSON.stringify(height)}, "${placeholder}");`,
`export default /*#__PURE__*/ createSvg(${JSON.stringify(viewBox)}, ${JSON.stringify(width)}, ${JSON.stringify(height)}, ${href});`,
].join('\n');
}
6 changes: 6 additions & 0 deletions plugin/src/codegen/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ export interface CodegenContext {
mode: SvgImportMode;
/** Whether this is a dev build. */
isDev: boolean;
/**
* When `true`, the sprite is inlined in the HTML document and the
* `<use href>` should reference a local fragment (`#symbolId`) rather
* than an external asset path.
*/
isEmbedded: boolean;
/** Full SVG markup string (for inline mode). */
svgMarkup: string;
/** The SVG content as a `<symbol>` element string (for dev sprite mode). */
Expand Down
25 changes: 25 additions & 0 deletions plugin/src/core/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,29 @@ export interface SvgJarOptions {

/** Replace non-`none` fill/stroke with `currentColor`. Default: `false`. */
currentColor?: boolean;

/**
* Sprite sheets to inline directly into the HTML document rather than
* emit as external asset files (Vite only, via `transformIndexHtml`).
*
* Inlining keeps symbols in the same document as their `<use>` elements,
* which is required for CSS animations (`@keyframes`), SMIL animations
* (`<animate>`, `<animateMotion>`), and `@font-face` rules inside SVG
* `<style>` tags to work correctly.
*
* - `true`: embed all sprite sheets
* - `false` (default): emit all sprites as external files
* - `string[]`: embed only the named sprites in the array
*
* @example
* // Embed only the 'animated' sprite sheet
* svgJar({ embedded: ['animated'] })
*
* @example
* // Embed all sprite sheets
* svgJar({ embedded: true })
*/
embedded?: boolean | string[];
}

/**
Expand All @@ -33,6 +56,7 @@ export interface ResolvedOptions {
svgo: boolean | SvgoConfig;
defaultSprite: string;
currentColor: boolean;
embedded: boolean | string[];
}

/**
Expand All @@ -47,5 +71,6 @@ export function resolveOptions(options: SvgJarOptions = {}): ResolvedOptions {
svgo: options.svgo ?? true,
defaultSprite: options.defaultSprite ?? 'sprite',
currentColor: options.currentColor ?? false,
embedded: options.embedded ?? false,
};
}
18 changes: 17 additions & 1 deletion plugin/src/core/sprite-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,20 +34,35 @@ export class SpriteRegistry {
/** Reverse mapping: symbolId → spriteName. */
private symbolToSprite = new Map<string, string>();

/** Sprite names that should be embedded inline in the HTML document. */
private embeddedSprites = new Set<string>();

/**
* Registers a symbol in a named sprite.
*
* @param spriteName The sprite to add to (e.g. `"sprite"`, `"nav"`).
* @param symbol The symbol to register.
* @param embedded When `true`, marks this sprite as inline-embedded.
*/
addSymbol(spriteName: string, symbol: SpriteSymbol): void {
addSymbol(spriteName: string, symbol: SpriteSymbol, embedded = false): void {
let symbols = this.sprites.get(spriteName);
if (!symbols) {
symbols = [];
this.sprites.set(spriteName, symbols);
}
symbols.push(symbol);
this.symbolToSprite.set(symbol.symbolId, spriteName);
if (embedded) {
this.embeddedSprites.add(spriteName);
}
}

/**
* Returns `true` if the named sprite should be inlined into the HTML
* document rather than emitted as an external asset file.
*/
isEmbedded(spriteName: string): boolean {
return this.embeddedSprites.has(spriteName);
}

/**
Expand Down Expand Up @@ -118,5 +133,6 @@ export class SpriteRegistry {
this.sprites.clear();
this.usedSymbolIds.clear();
this.symbolToSprite.clear();
this.embeddedSprites.clear();
}
}
5 changes: 5 additions & 0 deletions plugin/src/hooks/generate-bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ function resolveRefsToFinalUrls(

for (const ref of refs) {
const href = ref.href;

// Skip absolute URLs — they were left untouched by the load hook and
// should remain as-is in the final sprite output.
if (/^[a-z][a-z\d+\-.]*:/i.test(href)) continue;

const moduleId = hrefToModuleId(href, state.base, state.root);

// <use> refs: the referenced SVG is a sprite symbol.
Expand Down
14 changes: 9 additions & 5 deletions plugin/src/hooks/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ export function createLoadHook(
const refs = findEmbeddedRefs(data.entries);

for (const ref of refs) {
// Skip absolute URLs (http/https/data/etc.) — leave them untouched.
if (/^[a-z][a-z\d+\-.]*:/i.test(ref.href)) continue;

const resolved = await this.resolve(ref.href, parsed.filePath);
if (!resolved?.id) continue;

Expand Down Expand Up @@ -119,14 +122,15 @@ export function createLoadHook(
// Resolve sprite name: per-SVG query > global default
const spriteName = parsed.spriteName ?? state.options.defaultSprite;

// Determine whether this sprite should be embedded in the HTML document.
// Resolved entirely from plugin options — no per-import override.
const { embedded } = state.options;
const isEmbedded = embedded === true || (Array.isArray(embedded) && embedded.includes(spriteName));

// For sprite mode, create a <symbol> and register in the registry
if (parsed.mode === 'sprite') {
const symbolEntry = createSymbol(symbolId, data);
state.sprites.addSymbol(spriteName, {
symbolId,
entries: [symbolEntry],
metadata: data,
});
state.sprites.addSymbol(spriteName, { symbolId, entries: [symbolEntry], metadata: data }, isEmbedded);
}

// Serialize inner content (no root <svg>) for codegen, and full SVG for file emission.
Expand Down
44 changes: 44 additions & 0 deletions plugin/src/hooks/transform-html.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { PluginState } from '../core/state.ts';

/**
* Creates the Vite `transformIndexHtml` hook function.
*
* For each sprite marked as embedded, reads the assembled sprite XML from
* the Rollup output bundle (which is available as `ctx.bundle` during build)
* and injects it inline at the start of `<body>`. This keeps the symbols in
* the same document as the referencing `<use>` elements, which is required for:
*
* - CSS animations (`@keyframes` defined in `<style>` inside the symbol)
* - SMIL animations (`<animate>`, `<animateMotion>`, `<animateTransform>`)
* - `@font-face` rules inside SVG `<style>` tags
*
* The sprite XML is read directly from the bundle object — no disk I/O needed,
* and no ordering dependency beyond generateBundle running first (which Vite
* guarantees).
*/
export function createTransformHtmlHook(
state: PluginState,
): (html: string, ctx: { bundle?: Record<string, { type: string; source?: string | Uint8Array }> }) => string {
return function transformIndexHtml(html, ctx) {
if (!ctx.bundle) return html;

const snippets: string[] = [];

for (const spriteName of state.sprites.getSpriteNames()) {
if (!state.sprites.isEmbedded(spriteName)) continue;
if (state.sprites.getUsedSymbols(spriteName).length === 0) continue;

const fileName = `assets/${state.sprites.getSpriteFileName(spriteName)}`;
const asset = ctx.bundle[fileName];

if (asset?.type === 'asset' && typeof asset.source === 'string') {
snippets.push(asset.source);
}
}

if (snippets.length === 0) return html;

const injection = snippets.join('\n');
return html.replace('<body>', `<body>\n${injection}`);
};
}
9 changes: 9 additions & 0 deletions plugin/src/hooks/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ export function createTransformHook(
}
}

// Embedded sprites use a local fragment href (#symbolId) instead of a
// placeholder, so renderChunk never encounters them and cannot mark them
// used. Mark them used here instead.
const isEmbedded = state.sprites.isEmbedded(svgModule.spriteName);
if (svgModule.mode === 'sprite' && isEmbedded) {
state.sprites.markUsed(svgModule.symbolId);
}

const generated = generateCode({
target: state.options.target,
symbolId: svgModule.symbolId,
Expand All @@ -81,6 +89,7 @@ export function createTransformHook(
height: svgModule.data.height,
mode: svgModule.mode,
isDev: state.isDev,
isEmbedded,
svgMarkup: svgModule.svgMarkup,
symbolMarkup,
refSymbols,
Expand Down
2 changes: 2 additions & 0 deletions plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createLoadHook } from './hooks/load.ts';
import { createTransformHook } from './hooks/transform.ts';
import { createRenderChunkHook } from './hooks/render-chunk.ts';
import { createGenerateBundleHook } from './hooks/generate-bundle.ts';
import { createTransformHtmlHook } from './hooks/transform-html.ts';

/**
* The main unplugin instance. Use the bundler-specific entry points
Expand Down Expand Up @@ -36,6 +37,7 @@ export const SvgJarPlugin: UnpluginInstance<SvgJarOptions | undefined, false> =
},
renderChunk: createRenderChunkHook(state) as never,
generateBundle: createGenerateBundleHook(state) as never,
transformIndexHtml: createTransformHtmlHook(state),
handleHotUpdate({ file, server }) {
if (!file.endsWith('.svg')) return;

Expand Down
4 changes: 4 additions & 0 deletions plugin/test/_fixtures/entry-external-refs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import ExternalImage from './with-external-image.svg';
import FontRef from './with-font-ref.svg';

console.log(ExternalImage, FontRef);
1 change: 1 addition & 0 deletions plugin/test/_fixtures/with-external-image.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions plugin/test/_fixtures/with-font-ref.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 7 additions & 3 deletions plugin/test/e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function assetsDir(projectName: string): string {
// -- Page rendering assertions --

export async function expectSvgsVisible(page: Page): Promise<void> {
const svgs = page.locator('svg');
const svgs = page.locator('svg:visible');
await expect(svgs.first()).toBeVisible();
}

Expand Down Expand Up @@ -83,13 +83,17 @@ export function expectNamedSpriteContainsCircle(assets: string): void {
}

export function expectChunkContainsSpriteRefs(assets: string): void {
const code = readAsset(assets, findFile(assets, (f) => f.endsWith('.js'))!);
// Concatenate all JS chunks — sprite refs may be split across entry and shared chunks
const files = fs.readdirSync(assets).filter((f) => f.endsWith('.js'));
const code = files.map((f) => readAsset(assets, f)).join('\n');
expect(code).toMatch(/sprite-[a-f0-9]+\.svg/);
expect(code).toMatch(/shapes-[a-f0-9]+\.svg/);
}

export function expectChunkContainsInlineMarkup(assets: string): void {
const code = readAsset(assets, findFile(assets, (f) => f.endsWith('.js'))!);
// Concatenate all JS chunks — inline markup may live in a shared chunk
const files = fs.readdirSync(assets).filter((f) => f.endsWith('.js'));
const code = files.map((f) => readAsset(assets, f)).join('\n');
expect(code).toContain('M4 4H20V20H4z');
}

Expand Down
Loading
Loading