diff --git a/plugin/README.md b/plugin/README.md index ff8151f..132758e 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -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, }); ``` @@ -341,6 +346,66 @@ SVGs containing `` or `` have thei In dev mode, `` SVG references are embedded as local `` 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 ``. 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 `` elements do not run**. Specifically: + +- `@keyframes` rules in ` + + Hello + diff --git a/plugin/test/e2e/helpers.ts b/plugin/test/e2e/helpers.ts index 7483080..101b17f 100644 --- a/plugin/test/e2e/helpers.ts +++ b/plugin/test/e2e/helpers.ts @@ -27,7 +27,7 @@ export function assetsDir(projectName: string): string { // -- Page rendering assertions -- export async function expectSvgsVisible(page: Page): Promise { - const svgs = page.locator('svg'); + const svgs = page.locator('svg:visible'); await expect(svgs.first()).toBeVisible(); } @@ -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'); } diff --git a/plugin/test/e2e/vanilla.spec.ts b/plugin/test/e2e/vanilla.spec.ts index 5689c5f..7540686 100644 --- a/plugin/test/e2e/vanilla.spec.ts +++ b/plugin/test/e2e/vanilla.spec.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs'; +import path from 'node:path'; import { test, expect } from '@playwright/test'; import { assetsDir, @@ -15,6 +17,21 @@ import { expectChunkContainsInlineMarkup, } from './helpers.ts'; +function readDistFile(projectName: string, fileName: string): string { + const filePath = path.resolve( + import.meta.dirname, + '..', + '..', + '..', + 'test-projects', + 'vite', + projectName, + 'dist', + fileName, + ); + return fs.readFileSync(filePath, 'utf-8'); +} + const ASSETS = assetsDir('vanilla'); test.describe('Vanilla DOM', () => { @@ -116,4 +133,82 @@ test.describe('Vanilla DOM', () => { const sprite = readAsset(ASSETS, findFile(ASSETS, (f) => f.startsWith('sprite-') && f.endsWith('.svg'))!); expect(sprite).toMatch(/href="\/assets\/placeholder-[^"]+\.png"/); }); + + test('absolute image URL passes through unchanged', () => { + const sprite = readAsset(ASSETS, findFile(ASSETS, (f) => f.startsWith('sprite-') && f.endsWith('.svg'))!); + expect(sprite).toContain('href="https://placehold.co/600x400/png"'); + }); + + test('absolute font URL in @font-face passes through unchanged', () => { + // font-ref.svg is imported into the 'fonts' named sprite + const sprite = readAsset(ASSETS, findFile(ASSETS, (f) => f.startsWith('fonts-') && f.endsWith('.svg'))!); + expect(sprite).toContain('https://fonts.gstatic.com'); + expect(sprite).toContain('@font-face'); + }); +}); + +test.describe('Vanilla DOM — embedded sprite (animations page)', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/animations.html'); + }); + + // -- Build output -- + + test('animated sprite is emitted as a file', () => { + expect(findFile(ASSETS, (f) => f.startsWith('animated-') && f.endsWith('.svg'))).toBeDefined(); + }); + + test('animated sprite contains all 5 animation symbols', () => { + const sprite = readAsset(ASSETS, findFile(ASSETS, (f) => f.startsWith('animated-') && f.endsWith('.svg'))!); + const symbolCount = (sprite.match(/ { + const sprite = readAsset(ASSETS, findFile(ASSETS, (f) => f.startsWith('animated-') && f.endsWith('.svg'))!); + expect(sprite).toContain('@keyframes'); + }); + + test('animated sprite preserves SMIL animate elements', () => { + const sprite = readAsset(ASSETS, findFile(ASSETS, (f) => f.startsWith('animated-') && f.endsWith('.svg'))!); + expect(sprite).toContain(' { + const sprite = readAsset(ASSETS, findFile(ASSETS, (f) => f.startsWith('animated-') && f.endsWith('.svg'))!); + expect(sprite).toContain(' { + const html = readDistFile('vanilla', 'animations.html'); + expect(html).toContain(' { + const html = readDistFile('vanilla', 'animations.html'); + const symbolCount = (html.match(/ { + const html = readDistFile('vanilla', 'animations.html'); + // The elements reference local fragment IDs, not external files + expect(html).not.toMatch(/href="\/assets\/animated-[^"]+\.svg#/); + }); + + // -- Page rendering -- + + test('animations page loads', async ({ page }) => { + await expect(page).toHaveTitle(/Animated/); + }); + + test('renders 5 SVG elements on the animations page', async ({ page }) => { + const svgs = page.locator('#app svg'); + await expect(svgs).toHaveCount(5); + }); }); diff --git a/plugin/test/e2e/vanilla.spec.ts-snapshots/all-svgs-vanilla-darwin.png b/plugin/test/e2e/vanilla.spec.ts-snapshots/all-svgs-vanilla-darwin.png index ee88037..c7ebe13 100644 Binary files a/plugin/test/e2e/vanilla.spec.ts-snapshots/all-svgs-vanilla-darwin.png and b/plugin/test/e2e/vanilla.spec.ts-snapshots/all-svgs-vanilla-darwin.png differ diff --git a/plugin/test/integration/rollup.test.ts b/plugin/test/integration/rollup.test.ts index 318ac8a..50d43d1 100644 --- a/plugin/test/integration/rollup.test.ts +++ b/plugin/test/integration/rollup.test.ts @@ -183,6 +183,37 @@ describe('Rollup integration', () => { }); }); + describe('external asset references', () => { + it('leaves absolute URL in unchanged', async () => { + const entry = path.join(FIXTURES_DIR, 'entry-external-refs.js'); + const result = await build({ target: 'dom' }, entry); + const sprite = result.output.find( + (o) => o.type === 'asset' && o.fileName.startsWith('assets/sprite-') && o.fileName.endsWith('.svg'), + ); + expect(sprite).toBeDefined(); + if (sprite?.type === 'asset' && typeof sprite.source === 'string') { + expect(sprite.source).toContain('href="https://example.com/photo.jpg"'); + // Must not be rewritten to a local asset path + expect(sprite.source).not.toMatch(/href="\/assets\//); + } + }); + + it('leaves @font-face URL in + + +

Animated SVG manual test

+

All five animations should be playing. Each is imported in sprite mode and rendered via <use href> from a shared sprite sheet.

+
+ + + diff --git a/test-projects/vite/vanilla/src/animations.ts b/test-projects/vite/vanilla/src/animations.ts new file mode 100644 index 0000000..b2329d5 --- /dev/null +++ b/test-projects/vite/vanilla/src/animations.ts @@ -0,0 +1,40 @@ +// CSS animation - pulsing circle +import Pulse from './icons/animated/pulse.svg?sprite=animated'; + +// CSS animation - spinning arc (spinner) +import Spinner from './icons/animated/spinner.svg?sprite=animated'; + +// SMIL - cycling fill color + size +import ColorShift from './icons/animated/color-shift.svg?sprite=animated'; + +// SMIL - orbiting dot +import Orbit from './icons/animated/orbit.svg?sprite=animated'; + +// CSS animation - stroke-dashoffset draw-on effect +import Dash from './icons/animated/dash.svg?sprite=animated'; + +const app = document.getElementById('app')!; + +const animations = [ + { label: 'CSS pulse', icon: Pulse }, + { label: 'CSS spinner', icon: Spinner }, + { label: 'SMIL color shift', icon: ColorShift }, + { label: 'SMIL orbit', icon: Orbit }, + { label: 'CSS dash draw', icon: Dash }, +]; + +for (const { label, icon } of animations) { + const wrapper = document.createElement('div'); + wrapper.style.cssText = 'display:inline-flex;flex-direction:column;align-items:center;gap:8px;margin:24px;'; + + const svg = icon(); + svg.style.cssText = 'width:100px;height:100px;'; + wrapper.appendChild(svg); + + const caption = document.createElement('span'); + caption.textContent = label; + caption.style.cssText = 'font:14px/1 sans-serif;color:#555;'; + wrapper.appendChild(caption); + + app.appendChild(wrapper); +} diff --git a/test-projects/vite/vanilla/src/icons/animated/color-shift.svg b/test-projects/vite/vanilla/src/icons/animated/color-shift.svg new file mode 100644 index 0000000..a64d5a6 --- /dev/null +++ b/test-projects/vite/vanilla/src/icons/animated/color-shift.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test-projects/vite/vanilla/src/icons/animated/dash.svg b/test-projects/vite/vanilla/src/icons/animated/dash.svg new file mode 100644 index 0000000..be0547b --- /dev/null +++ b/test-projects/vite/vanilla/src/icons/animated/dash.svg @@ -0,0 +1,14 @@ + + + + + diff --git a/test-projects/vite/vanilla/src/icons/animated/orbit.svg b/test-projects/vite/vanilla/src/icons/animated/orbit.svg new file mode 100644 index 0000000..1d853f2 --- /dev/null +++ b/test-projects/vite/vanilla/src/icons/animated/orbit.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/test-projects/vite/vanilla/src/icons/animated/pulse.svg b/test-projects/vite/vanilla/src/icons/animated/pulse.svg new file mode 100644 index 0000000..643518f --- /dev/null +++ b/test-projects/vite/vanilla/src/icons/animated/pulse.svg @@ -0,0 +1,10 @@ + + + + diff --git a/test-projects/vite/vanilla/src/icons/animated/spinner.svg b/test-projects/vite/vanilla/src/icons/animated/spinner.svg new file mode 100644 index 0000000..5c9353e --- /dev/null +++ b/test-projects/vite/vanilla/src/icons/animated/spinner.svg @@ -0,0 +1,9 @@ + + + + + diff --git a/test-projects/vite/vanilla/src/icons/with-external-image.svg b/test-projects/vite/vanilla/src/icons/with-external-image.svg new file mode 100644 index 0000000..ed6f32c --- /dev/null +++ b/test-projects/vite/vanilla/src/icons/with-external-image.svg @@ -0,0 +1 @@ + diff --git a/test-projects/vite/vanilla/src/icons/with-font-ref.svg b/test-projects/vite/vanilla/src/icons/with-font-ref.svg new file mode 100644 index 0000000..0144963 --- /dev/null +++ b/test-projects/vite/vanilla/src/icons/with-font-ref.svg @@ -0,0 +1,11 @@ + + + + Hello + diff --git a/test-projects/vite/vanilla/src/main.ts b/test-projects/vite/vanilla/src/main.ts index 5024cfe..2bf90bf 100644 --- a/test-projects/vite/vanilla/src/main.ts +++ b/test-projects/vite/vanilla/src/main.ts @@ -16,6 +16,10 @@ import UseRef from './icons/with-use-ref.svg'; // Embedded refs - SVG with referencing a PNG import ImageRef from './icons/with-image-ref.svg'; +// External asset refs - absolute URLs that must pass through unchanged +import ExternalImage from './icons/with-external-image.svg'; +import FontRef from './icons/with-font-ref.svg?sprite=fonts'; + // Source order - complex SVG with layered shapes, gradients, clip-paths, text import SourceOrder from './icons/source-order.svg'; @@ -49,10 +53,14 @@ img.src = starUrl; img.alt = 'Star'; app.appendChild(img); -// Embedded refs +// Embedded refs (local) app.appendChild(UseRef()); app.appendChild(ImageRef()); +// External asset refs (absolute URLs - should remain unchanged in output) +app.appendChild(ExternalImage()); +app.appendChild(FontRef()); + // Source order - render at larger size to see detail const sourceOrderEl = SourceOrder(); sourceOrderEl.style.width = '100px'; diff --git a/test-projects/vite/vanilla/tsconfig.json b/test-projects/vite/vanilla/tsconfig.json index 462b0a9..378ca1c 100644 --- a/test-projects/vite/vanilla/tsconfig.json +++ b/test-projects/vite/vanilla/tsconfig.json @@ -5,7 +5,7 @@ "moduleResolution": "bundler", "strict": true, "noEmit": true, - "types": ["@svg-jar/plugin/client", "node"] + "types": ["@svg-jar/plugin/client/dom", "node"] }, "include": ["src", "test"] } diff --git a/test-projects/vite/vanilla/vite.config.ts b/test-projects/vite/vanilla/vite.config.ts index edf1e60..9cc9edd 100644 --- a/test-projects/vite/vanilla/vite.config.ts +++ b/test-projects/vite/vanilla/vite.config.ts @@ -2,5 +2,13 @@ import { defineConfig } from 'vite'; import svgJar from '@svg-jar/plugin/vite'; export default defineConfig({ - plugins: [svgJar({ target: 'dom' })], + plugins: [svgJar({ target: 'dom', embedded: ['animated', 'fonts'] })], + build: { + rollupOptions: { + input: { + main: 'index.html', + animations: 'animations.html', + }, + }, + }, });