Skip to content
Open
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
8 changes: 7 additions & 1 deletion plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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-<hash>.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,
});
```

Expand Down
19 changes: 19 additions & 0 deletions plugin/src/core/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-<hash>.svg', import.meta.url).href + '#<id>'`,
* 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;
}

/**
Expand All @@ -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;
}

/**
Expand All @@ -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,
};
}
34 changes: 30 additions & 4 deletions plugin/src/hooks/render-chunk.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;

Expand All @@ -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 === '`';
}
27 changes: 25 additions & 2 deletions plugin/test/integration/rolldown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ 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<RolldownOutput> {
async function build(options = {}, entry = ENTRY, entryFileNames = '[name].js'): Promise<RolldownOutput> {
const bundle = await rolldown({
input: entry,
plugins: [SvgJarPlugin.rolldown(options)],
// The generated components import the runtime from the published
// 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) {
Expand Down Expand Up @@ -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();
});
});
});
10 changes: 10 additions & 0 deletions plugin/test/unit/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Loading