From 32f6e1d450aa4388742e65bad8f0ccc7e876d429 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:07:54 -0400 Subject: [PATCH] Add relativeUrls option for sprite URLs in library builds With relativeUrls, renderChunk writes each sprite reference as new URL('./assets/sprite-.svg', import.meta.url).href + '#'. The path is relative to the chunk that holds it. The app that consumes the library then resolves the sprite with its own bundler, under its own base. Without the option, output is unchanged. --- plugin/README.md | 8 +++++- plugin/src/core/options.ts | 19 +++++++++++++ plugin/src/hooks/render-chunk.ts | 34 +++++++++++++++++++++--- plugin/test/integration/rolldown.test.ts | 27 +++++++++++++++++-- plugin/test/unit/options.test.ts | 10 +++++++ 5 files changed, 91 insertions(+), 7 deletions(-) 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); + }); + }); });