diff --git a/plugin/README.md b/plugin/README.md index 82e22d6..dc500cf 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -74,7 +74,7 @@ export default defineConfig({ > [!IMPORTANT] > Sprite sheets are an **application-level** optimization — their benefits (app-wide symbol dedupe, per-symbol tree-shaking, a single sheet on a known origin) only exist when one build owns the whole module graph. A package built with tsdown emits sprite and `?file` assets into its own `dist/assets` with baked URLs that a consuming bundler will not copy or rewrite. > -> Use sprite or `?file` output from tsdown only for **internal packages where the consuming app controls the deploy origin**: set the `base` option to a fixed public path, and have the app copy the package's `dist/assets` there as part of its build (see `test-projects/tsdown/internal-package`). For publicly distributed libraries, ship raw SVG files (and let the consuming app run svg-jar itself) or export inline components (`?unsafe-inline`), which need no asset serving. +> Use sprite or `?file` output from tsdown only for **internal packages where the consuming app controls the deploy origin**: set the `base` option to a fixed public path, and have the app copy the package's `dist/assets` there as part of its build (see `test-projects/tsdown/internal-package`). If the consuming app's bundler handles `new URL(..., import.meta.url)` (Vite does), set `relativeUrls: true` instead, so sprite URLs do not depend on where the app is served. For publicly distributed libraries, ship raw SVG files (and let the consuming app run svg-jar itself) or export inline components (`?unsafe-inline`), which need no asset serving. ## Usage @@ -171,6 +171,12 @@ svgJar({ // e.g. an internal package whose dist/assets the consuming app copies to a // fixed public path. A trailing slash is added if missing. base: '/vendor/icons/', + + // Emit sprite URLs as `new URL('./assets/sprite-.svg', import.meta.url)`, + // relative to the chunk, instead of prefixing them with `base`. The + // consuming app's bundler then resolves the sprite and serves it under the + // app's own base. Applies to sprite references in JS only. Default: false. + relativeUrls: false, }); ``` diff --git a/plugin/src/core/options.ts b/plugin/src/core/options.ts index 0752275..2de4e2b 100644 --- a/plugin/src/core/options.ts +++ b/plugin/src/core/options.ts @@ -81,6 +81,23 @@ export interface SvgJarOptions { * svgJar({ base: '/vendor/icons/' }) */ base?: string; + + /** + * Emit sprite URLs relative to the chunk that uses them, as + * `new URL('./assets/sprite-.svg', import.meta.url).href + '#'`, + * instead of prefixing them with `base`. + * + * Use this when building a library. The app that consumes it does not + * need to serve the sprite at a fixed path: its own bundler resolves the + * `new URL(..., import.meta.url)` reference, and serves and emits the + * sprite under the app's base. + * + * Applies to sprite references in JS only. `?file` imports and refs + * inside sprite sheets still use `base`. + * + * Default: `false`. + */ + relativeUrls?: boolean; } /** @@ -95,6 +112,7 @@ export interface ResolvedOptions { embedded: boolean | string[]; /** Normalized base path (trailing slash ensured), or undefined to use the bundler default. */ base: string | undefined; + relativeUrls: boolean; } /** @@ -112,5 +130,6 @@ export function resolveOptions(options: SvgJarOptions = {}): ResolvedOptions { rootAttributes: options.rootAttributes ?? [...DEFAULT_ROOT_ATTRIBUTES], embedded: options.embedded ?? false, base: options.base == null ? undefined : options.base.endsWith('/') ? options.base : `${options.base}/`, + relativeUrls: options.relativeUrls ?? false, }; } diff --git a/plugin/src/hooks/render-chunk.ts b/plugin/src/hooks/render-chunk.ts index 961f3b0..7e66b8d 100644 --- a/plugin/src/hooks/render-chunk.ts +++ b/plugin/src/hooks/render-chunk.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import MagicString from 'magic-string'; import type { PluginState } from '../core/state.ts'; import { PLACEHOLDER_RE } from '../core/constants.ts'; @@ -12,11 +13,17 @@ import { PLACEHOLDER_RE } from '../core/constants.ts'; * Also marks each found symbol as used in the `SpriteRegistry` so that * `generateBundle` can tree-shake unused symbols from the sprite files. * + * With the `relativeUrls` option, the whole string literal around each + * placeholder becomes a `new URL(..., import.meta.url)` expression that + * points from the chunk to the sprite file. + * * Note: This hook is Rollup/Vite-specific. For webpack/esbuild it is * currently a no-op (tree-shaking deferred). */ -export function createRenderChunkHook(state: PluginState): (code: string) => { code: string; map?: unknown } | null { - return function renderChunk(code) { +export function createRenderChunkHook( + state: PluginState, +): (code: string, chunk?: { fileName: string }) => { code: string; map?: unknown } | null { + return function renderChunk(code, chunk) { // Reset the regex lastIndex since we reuse the global regex PLACEHOLDER_RE.lastIndex = 0; @@ -32,11 +39,30 @@ export function createRenderChunkHook(state: PluginState): (code: string) => { c const spriteName = state.sprites.getSpriteName(symbolId) ?? 'sprite'; const spriteFileName = state.sprites.getSpriteFileName(spriteName); - const spriteUrl = `${state.base}assets/${spriteFileName}#${symbolId}`; + const start = match.index; + const end = start + match[0].length; + const quote = code[start - 1]; + + if (state.options.relativeUrls && isQuote(quote) && code[end] === quote) { + const chunkDir = path.posix.dirname(chunk?.fileName ?? ''); + const relative = path.posix.relative(chunkDir, `assets/${spriteFileName}`); + const spriteUrl = relative.startsWith('.') ? relative : `./${relative}`; + + ms.overwrite( + start - 1, + end + 1, + `new URL(${JSON.stringify(spriteUrl)}, import.meta.url).href + ${JSON.stringify(`#${symbolId}`)}`, + ); + continue; + } - ms.overwrite(match.index, match.index + match[0].length, spriteUrl); + ms.overwrite(start, end, `${state.base}assets/${spriteFileName}#${symbolId}`); } return { code: ms.toString(), map: ms.generateMap({ hires: true }) }; }; } + +function isQuote(char: string | undefined): boolean { + return char === '"' || char === "'" || char === '`'; +} diff --git a/plugin/test/integration/rolldown.test.ts b/plugin/test/integration/rolldown.test.ts index 9cb5b03..6f15a79 100644 --- a/plugin/test/integration/rolldown.test.ts +++ b/plugin/test/integration/rolldown.test.ts @@ -11,7 +11,7 @@ const ENTRY_FILE_MODE = path.join(FIXTURES_DIR, 'entry-file-mode.js'); /** * Builds the test fixture with Rolldown + svg-jar plugin and returns the output. */ -async function build(options = {}, entry = ENTRY): Promise { +async function build(options = {}, entry = ENTRY, entryFileNames = '[name].js'): Promise { const bundle = await rolldown({ input: entry, plugins: [SvgJarPlugin.rolldown(options)], @@ -19,7 +19,7 @@ async function build(options = {}, entry = ENTRY): Promise { // package name, which is not resolvable from inside the package itself. external: [/^@svg-jar\/plugin\/runtime\//], }); - return bundle.generate({ format: 'esm' }); + return bundle.generate({ format: 'esm', entryFileNames }); } function findChunk(output: RolldownOutput) { @@ -103,4 +103,27 @@ describe('Rolldown integration', () => { expect(findChunk(fileOutput).code).toMatch(/\/vendor\/icons\/assets\/simple-[a-f0-9]+\.svg/); }); }); + + describe('relativeUrls option', () => { + it('points sprite URLs at the sprite file relative to the chunk', async () => { + const code = findChunk(await build({ target: 'dom', relativeUrls: true, base: '/ignored/' })).code; + + PLACEHOLDER_RE.lastIndex = 0; + expect(code).not.toMatch(PLACEHOLDER_RE); + expect(code).not.toContain('/ignored/'); + expect(code).toMatch(/new URL\("\.\/assets\/sprite-[a-f0-9]+\.svg", import\.meta\.url\)\.href \+ "#[a-f0-9]+"/); + }); + + it('walks up from a chunk in a subdirectory', async () => { + const code = findChunk(await build({ target: 'dom', relativeUrls: true }, ENTRY, 'nested/[name].js')).code; + + expect(code).toMatch(/new URL\("\.\.\/assets\/sprite-[a-f0-9]+\.svg", import\.meta\.url\)/); + }); + + it('still emits the sprite into assets/', async () => { + const output = await build({ target: 'dom', relativeUrls: true }); + + expect(findAsset(output, (f) => /^assets\/sprite-[a-f0-9]+\.svg$/.test(f))).toBeDefined(); + }); + }); }); diff --git a/plugin/test/unit/options.test.ts b/plugin/test/unit/options.test.ts index 3a3f0b1..04692de 100644 --- a/plugin/test/unit/options.test.ts +++ b/plugin/test/unit/options.test.ts @@ -77,4 +77,14 @@ describe('resolveOptions', () => { expect(resolveOptions({ base: '/vendor/icons' }).base).toBe('/vendor/icons/'); }); }); + + describe('relativeUrls', () => { + it('defaults to false', () => { + expect(resolveOptions({}).relativeUrls).toBe(false); + }); + + it('preserves true', () => { + expect(resolveOptions({ relativeUrls: true }).relativeUrls).toBe(true); + }); + }); });