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
8 changes: 7 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ test-projects/vite/ One Vite app per supported framework target
├── vanilla/
├── vue/
└── web-component/

test-projects/rolldown/ Rolldown-as-app-bundler projects
└── vanilla/ DOM app, e2e tested like the Vite apps

test-projects/tsdown/ tsdown (Rolldown) projects
└── internal-package/ Internal monorepo package with `base` + declaration output
```

## Tests
Expand All @@ -77,7 +83,7 @@ pnpm --filter @svg-jar/plugin test:unit

### Integration tests

A Rollup integration test builds a minimal bundle and asserts the output.
Rollup and Rolldown integration tests build a minimal bundle and assert the output. A tsdown integration test builds `test-projects/tsdown/internal-package` and asserts on its dist output, including the bundled declaration file.

```sh
pnpm --filter @svg-jar/plugin test:unit # includes integration/
Expand Down
43 changes: 41 additions & 2 deletions plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

</div>

An [unplugin](https://github.com/unjs/unplugin) for importing SVGs as components. Supports sprite sheets, inline SVGs, and raw file exports across Vite and Rollup.
An [unplugin](https://github.com/unjs/unplugin) for importing SVGs as components. Supports sprite sheets, inline SVGs, and raw file exports across Vite, Rollup, and Rolldown (including [tsdown](https://tsdown.dev)).

## Install

Expand Down Expand Up @@ -44,6 +44,38 @@ export default {
};
```

### Rolldown

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

export default {
plugins: [svgJar({ target: 'ember' })],
};
```

### tsdown

[tsdown](https://tsdown.dev) is built on Rolldown, so it uses the Rolldown entry point. Declaration files (`dts: true`) get correctly typed exports for each SVG import.

```ts
// tsdown.config.ts
import { defineConfig } from 'tsdown';
import svgJar from '@svg-jar/plugin/rolldown';

export default defineConfig({
entry: ['src/index.ts'],
dts: true,
plugins: [svgJar({ target: 'dom', base: '/vendor/icons/' })],
});
```

> [!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.

## Usage

### Sprite mode (default)
Expand Down Expand Up @@ -112,6 +144,13 @@ svgJar({
// external files. See "Embedded sprites" below.
// true: embed all sprites | string[]: embed named sprites | false (default): external files
embedded: false,

// Base public path prepended to emitted asset URLs (sprite sheets and
// ?file assets). Defaults to Vite's resolved `base` in Vite, '/' elsewhere.
// Set this when the build output is served from a known non-root location,
// 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/',
});
```

Expand Down Expand Up @@ -422,7 +461,7 @@ For a thorough analysis of this problem, see Cynthia Rey's [The state of SVGs on
## Requirements

- Node.js >= 20
- Vite or Rollup
- Vite, Rollup, or Rolldown (including tsdown)

## Acknowledgements

Expand Down
3 changes: 2 additions & 1 deletion plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
],
"scripts": {
"build": "tsdown",
"build:test-projects": "tsdown && for dir in ../test-projects/vite/*/; do (cd \"$dir\" && pnpm vite build); done",
"build:test-projects": "tsdown && for dir in ../test-projects/vite/*/; do (cd \"$dir\" && pnpm vite build); done && for dir in ../test-projects/rolldown/*/ ../test-projects/tsdown/*/; do (cd \"$dir\" && pnpm build); done",
"dev": "tsdown --watch",
"format": "prettier --write .",
"lint": "eslint .",
Expand All @@ -104,6 +104,7 @@
"devDependencies": {
"@playwright/test": "^1.59.1",
"execa": "catalog:",
"rolldown": "catalog:",
"rollup": "catalog:",
"tsdown": "catalog:",
"vitest": "catalog:"
Expand Down
22 changes: 12 additions & 10 deletions plugin/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ import { defineConfig } from '@playwright/test';
/**
* Test projects and their preview ports.
* Each project is built and served via `vite preview` on a unique port.
* `dir` is the project path relative to `test-projects/`.
*/
const testProjects = {
vanilla: 4200,
react: 4201,
vue: 4202,
preact: 4203,
solid: 4204,
ember: 4205,
'web-component': 4206,
vanilla: { port: 4200, dir: 'vite/vanilla' },
react: { port: 4201, dir: 'vite/react' },
vue: { port: 4202, dir: 'vite/vue' },
preact: { port: 4203, dir: 'vite/preact' },
solid: { port: 4204, dir: 'vite/solid' },
ember: { port: 4205, dir: 'vite/ember' },
'web-component': { port: 4206, dir: 'vite/web-component' },
'rolldown-vanilla': { port: 4207, dir: 'rolldown/vanilla' },
};

export default defineConfig({
Expand All @@ -22,16 +24,16 @@ export default defineConfig({
use: {
headless: true,
},
projects: Object.entries(testProjects).map(([name, port]) => ({
projects: Object.entries(testProjects).map(([name, { port }]) => ({
name,
use: {
baseURL: `http://localhost:${port}`,
},
testMatch: `${name}.spec.ts`,
})),
webServer: Object.entries(testProjects).map(([name, port]) => ({
webServer: Object.entries(testProjects).map(([, { port, dir }]) => ({
command: `pnpm vite preview --port ${port} --strictPort`,
cwd: path.resolve(import.meta.dirname, '..', 'test-projects', 'vite', name),
cwd: path.resolve(import.meta.dirname, '..', 'test-projects', dir),
port,
reuseExistingServer: !process.env.CI,
})),
Expand Down
65 changes: 65 additions & 0 deletions plugin/src/codegen/dts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { SvgJarTarget } from '../core/options.ts';
import type { SvgDtsKind } from '../core/query.ts';

/**
* Declaration code for component-shaped SVG imports, per target.
*
* These mirror the ambient declarations in `client/*.d.ts`, but as real
* module declarations so declaration bundlers (rolldown-plugin-dts, used
* by tsdown) can emit correct types for libraries that re-export SVG
* components.
*/
const COMPONENT_DTS: Record<SvgJarTarget, string> = {
dom: [
`import type { SvgOptions } from '@svg-jar/plugin/runtime/dom';`,
`declare const component: (options?: SvgOptions) => SVGSVGElement;`,
`export default component;`,
].join('\n'),

ember: [
`import type { ComponentLike } from '@glint/template';`,
`declare const Component: ComponentLike<{ Element: SVGSVGElement; Blocks: { default: [] } }>;`,
`export default Component;`,
].join('\n'),

react: [
`import type { FC, SVGAttributes, ReactNode } from 'react';`,
`declare const Component: FC<SVGAttributes<SVGSVGElement> & { children?: ReactNode }>;`,
`export default Component;`,
].join('\n'),

preact: [
`import type { FunctionComponent, JSX } from 'preact';`,
`declare const Component: FunctionComponent<JSX.SVGAttributes<SVGSVGElement>>;`,
`export default Component;`,
].join('\n'),

vue: [
`import type { Component } from 'vue';`,
`declare const component: Component;`,
`export default component;`,
].join('\n'),

solid: [
`import type { Component, JSX, ParentProps } from 'solid-js';`,
`declare const component: Component<ParentProps<JSX.SvgSVGAttributes<SVGSVGElement>>>;`,
`export default component;`,
].join('\n'),

'web-component': [`declare const ElementClass: typeof HTMLElement;`, `export default ElementClass;`].join('\n'),
};

/** Declaration code for `?file` imports - a plain URL string. */
const FILE_DTS = [`declare const url: string;`, `export default url;`].join('\n');

/**
* Generates declaration (`.d.ts`) code for a virtual SVG declaration
* module. Served by the `load` hook when a declaration bundler resolves
* an SVG import from a `.d.ts` module.
*
* @param target Framework target from plugin options.
* @param kind `'component'` for sprite/inline imports, `'file'` for `?file`.
*/
export function generateDts(target: SvgJarTarget, kind: SvgDtsKind): string {
return kind === 'file' ? FILE_DTS : COMPONENT_DTS[target];
}
21 changes: 21 additions & 0 deletions plugin/src/core/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,24 @@ export interface SvgJarOptions {
* svgJar({ embedded: true })
*/
embedded?: boolean | string[];

/**
* Base public path prepended to emitted asset URLs (sprite sheets and
* `?file` assets), e.g. `'/vendor/icons/'`.
*
* Defaults to the bundler's base path in Vite (`base` from the resolved
* config), and `'/'` in other bundlers. Set this when the build output
* is served from a known non-root location - for example an internal
* package built with Rolldown/tsdown whose `dist/assets` the consuming
* app copies to a fixed public path.
*
* A trailing slash is added if missing.
*
* @example
* // Emitted URLs become /vendor/icons/assets/sprite-<hash>.svg#<id>
* svgJar({ base: '/vendor/icons/' })
*/
base?: string;
}

/**
Expand All @@ -57,6 +75,8 @@ export interface ResolvedOptions {
defaultSprite: string;
currentColor: boolean;
embedded: boolean | string[];
/** Normalized base path (trailing slash ensured), or undefined to use the bundler default. */
base: string | undefined;
}

/**
Expand All @@ -72,5 +92,6 @@ export function resolveOptions(options: SvgJarOptions = {}): ResolvedOptions {
defaultSprite: options.defaultSprite ?? 'sprite',
currentColor: options.currentColor ?? false,
embedded: options.embedded ?? false,
base: options.base == null ? undefined : options.base.endsWith('/') ? options.base : `${options.base}/`,
};
}
51 changes: 51 additions & 0 deletions plugin/src/core/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,54 @@ export function isSvgId(id: string): boolean {
const filePath = queryIndex === -1 ? id : id.slice(0, queryIndex);
return filePath.endsWith('.svg');
}

/**
* The shape of a virtual SVG declaration module.
*
* - `'component'` - sprite/inline imports (default export is a component)
* - `'file'` - `?file` imports (default export is a URL string)
*/
export type SvgDtsKind = 'component' | 'file';

/** Suffix for virtual declaration modules of component-shaped SVG imports. */
const SVG_DTS_COMPONENT_SUFFIX = '.svg-jar.d.ts';

/** Suffix for virtual declaration modules of `?file` SVG imports. */
const SVG_DTS_FILE_SUFFIX = '.svg-jar-file.d.ts';

/**
* Returns true if the importer is a declaration module (`.d.ts`, `.d.mts`,
* `.d.cts`). Declaration bundlers like rolldown-plugin-dts (used by tsdown)
* resolve imports from virtual `.d.ts` twins of the source modules; SVG
* imports from those need declaration code, not component code.
*/
export function isDtsImporter(importer: string | undefined): boolean {
return importer != null && /\.d\.[cm]?ts$/.test(importer);
}

/**
* Builds the module ID for a virtual SVG declaration module.
*
* The ID must end in `.d.ts` so declaration bundlers treat the module as
* declaration code. The query string is dropped - all component-shaped
* imports of the same file share one type, so they dedupe naturally.
*
* @example
* makeSvgDtsId('/project/icon.svg', 'component')
* // → '/project/icon.svg.svg-jar.d.ts'
*/
export function makeSvgDtsId(filePath: string, kind: SvgDtsKind): string {
return filePath + (kind === 'file' ? SVG_DTS_FILE_SUFFIX : SVG_DTS_COMPONENT_SUFFIX);
}

/**
* Parses a virtual SVG declaration module ID created by {@link makeSvgDtsId}.
*
* @returns The declaration kind, or `null` if the ID is not a virtual
* SVG declaration module.
*/
export function parseSvgDtsId(id: string): SvgDtsKind | null {
if (id.endsWith(SVG_DTS_FILE_SUFFIX)) return 'file';
if (id.endsWith(SVG_DTS_COMPONENT_SUFFIX)) return 'component';
return null;
}
9 changes: 7 additions & 2 deletions plugin/src/core/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,19 @@ export class PluginState {
/** Whether the current build is a dev server (serve) vs production build. */
isDev = false;

/** Vite base path (e.g. `"/"`). Used to construct final asset URLs. */
base = '/';
/**
* Base public path (e.g. `"/"`). Used to construct final asset URLs.
* Seeded from the `base` plugin option; in Vite the resolved config
* `base` is used when the option is not set.
*/
base: string;

/** Project root directory. Used to compute relative paths. Defaults to cwd. */
root: string = process.cwd();

constructor(options: ResolvedOptions) {
this.options = options;
this.base = options.base ?? '/';
}

/**
Expand Down
8 changes: 7 additions & 1 deletion plugin/src/hooks/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { readFileSync } from 'node:fs';
import path from 'node:path';
import type { UnpluginBuildContext, UnpluginContext } from 'unplugin';
import type { PluginState } from '../core/state.ts';
import { parseSvgId, isSvgId } from '../core/query.ts';
import { parseSvgId, isSvgId, parseSvgDtsId } from '../core/query.ts';
import { generateDts } from '../codegen/dts.ts';
import { parseSvg, hashSvg } from '../svg/parse.ts';
import { optimizeSvg } from '../svg/optimize.ts';
import { applyCurrentColor, stripDimensions, findEmbeddedRefs } from '../svg/transform.ts';
Expand Down Expand Up @@ -46,6 +47,11 @@ export function createLoadHook(
state: PluginState,
): (this: UnpluginBuildContext & UnpluginContext, id: string) => Promise<string | null> {
return async function load(id) {
// Virtual SVG declaration modules (created by resolveId for imports
// from `.d.ts` modules) load as declaration code for the target.
const dtsKind = parseSvgDtsId(id);
if (dtsKind) return generateDts(state.options.target, dtsKind);

if (!isSvgId(id)) return null;

const parsed = parseSvgId(id);
Expand Down
12 changes: 9 additions & 3 deletions plugin/src/hooks/resolve-id.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
import type { UnpluginBuildContext, UnpluginContext } from 'unplugin';
import { isSvgId } from '../core/query.ts';
import { isSvgId, isDtsImporter, makeSvgDtsId, parseSvgId } from '../core/query.ts';

/**
* Creates the `resolveId` hook function.
Expand All @@ -28,18 +28,24 @@ export function createResolveIdHook(): (
const filePath = queryIndex === -1 ? id : id.slice(0, queryIndex);
const query = queryIndex === -1 ? '' : id.slice(queryIndex);

// SVG imports from declaration modules (rolldown-plugin-dts / tsdown
// resolve imports from virtual `.d.ts` twins) resolve to a virtual
// declaration module instead of the component module, so the emitted
// `.d.ts` bundle gets type declarations rather than runtime JS.
const dtsKind = isDtsImporter(importer) ? (parseSvgId(id).mode === 'file' ? 'file' : 'component') : null;

// Relative or absolute paths - resolve and re-append query.
if (filePath.startsWith('.') || filePath.startsWith('/')) {
if (!importer) return null;
const resolved = path.resolve(path.dirname(importer), filePath);
return resolved + query;
return dtsKind ? makeSvgDtsId(resolved, dtsKind) : resolved + query;
}

// Bare specifier - try naive node_modules resolution.
// This bypasses package.json `exports` which often doesn't include .svg files.
if (importer) {
const resolved = resolveFromNodeModules(filePath, path.dirname(importer));
if (resolved) return resolved + query;
if (resolved) return dtsKind ? makeSvgDtsId(resolved, dtsKind) : resolved + query;
}

return null;
Expand Down
Loading
Loading