diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0baa515..73af8aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 @@ -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/ diff --git a/plugin/README.md b/plugin/README.md index 132758e..1f068be 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -12,7 +12,7 @@ -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 @@ -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) @@ -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/', }); ``` @@ -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 diff --git a/plugin/package.json b/plugin/package.json index d811a51..fd58f8e 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -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 .", @@ -104,6 +104,7 @@ "devDependencies": { "@playwright/test": "^1.59.1", "execa": "catalog:", + "rolldown": "catalog:", "rollup": "catalog:", "tsdown": "catalog:", "vitest": "catalog:" diff --git a/plugin/playwright.config.ts b/plugin/playwright.config.ts index 7a29cee..9fc2d32 100644 --- a/plugin/playwright.config.ts +++ b/plugin/playwright.config.ts @@ -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({ @@ -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, })), diff --git a/plugin/src/codegen/dts.ts b/plugin/src/codegen/dts.ts new file mode 100644 index 0000000..5913729 --- /dev/null +++ b/plugin/src/codegen/dts.ts @@ -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 = { + 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 & { children?: ReactNode }>;`, + `export default Component;`, + ].join('\n'), + + preact: [ + `import type { FunctionComponent, JSX } from 'preact';`, + `declare const Component: FunctionComponent>;`, + `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>>;`, + `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]; +} diff --git a/plugin/src/core/options.ts b/plugin/src/core/options.ts index f16f7c8..3f62180 100644 --- a/plugin/src/core/options.ts +++ b/plugin/src/core/options.ts @@ -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-.svg# + * svgJar({ base: '/vendor/icons/' }) + */ + base?: string; } /** @@ -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; } /** @@ -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}/`, }; } diff --git a/plugin/src/core/query.ts b/plugin/src/core/query.ts index f125448..c41dbbd 100644 --- a/plugin/src/core/query.ts +++ b/plugin/src/core/query.ts @@ -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; +} diff --git a/plugin/src/core/state.ts b/plugin/src/core/state.ts index c152ff3..3cf2e8f 100644 --- a/plugin/src/core/state.ts +++ b/plugin/src/core/state.ts @@ -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 ?? '/'; } /** diff --git a/plugin/src/hooks/load.ts b/plugin/src/hooks/load.ts index b0fe0ff..f0d0778 100644 --- a/plugin/src/hooks/load.ts +++ b/plugin/src/hooks/load.ts @@ -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'; @@ -46,6 +47,11 @@ export function createLoadHook( state: PluginState, ): (this: UnpluginBuildContext & UnpluginContext, id: string) => Promise { 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); diff --git a/plugin/src/hooks/resolve-id.ts b/plugin/src/hooks/resolve-id.ts index eec6ef9..c1dee35 100644 --- a/plugin/src/hooks/resolve-id.ts +++ b/plugin/src/hooks/resolve-id.ts @@ -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. @@ -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; diff --git a/plugin/src/index.ts b/plugin/src/index.ts index 509ff65..65fa940 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -32,7 +32,7 @@ export const SvgJarPlugin: UnpluginInstance = vite: { configResolved(config) { state.isDev = config.command === 'serve'; - state.base = config.base ?? '/'; + state.base = state.options.base ?? config.base ?? '/'; state.root = config.root ?? ''; }, renderChunk: createRenderChunkHook(state) as never, @@ -68,6 +68,11 @@ export const SvgJarPlugin: UnpluginInstance = renderChunk: createRenderChunkHook(state) as never, generateBundle: createGenerateBundleHook(state) as never, }, + + rolldown: { + renderChunk: createRenderChunkHook(state) as never, + generateBundle: createGenerateBundleHook(state) as never, + }, }; }); diff --git a/plugin/test/_fixtures/entry-file-mode.js b/plugin/test/_fixtures/entry-file-mode.js new file mode 100644 index 0000000..254f1d0 --- /dev/null +++ b/plugin/test/_fixtures/entry-file-mode.js @@ -0,0 +1,3 @@ +import simpleUrl from './simple.svg?file'; + +console.log(simpleUrl); diff --git a/plugin/test/e2e/helpers.ts b/plugin/test/e2e/helpers.ts index 101b17f..aa57f0b 100644 --- a/plugin/test/e2e/helpers.ts +++ b/plugin/test/e2e/helpers.ts @@ -19,9 +19,12 @@ export function readAsset(assetsDir: string, fileName: string): string { /** * Returns the dist/assets path for a test project. + * + * @param projectName Project directory name inside the bundler directory. + * @param bundler Bundler directory inside `test-projects/`. Default `'vite'`. */ -export function assetsDir(projectName: string): string { - return path.resolve(import.meta.dirname, '..', '..', '..', 'test-projects', 'vite', projectName, 'dist', 'assets'); +export function assetsDir(projectName: string, bundler = 'vite'): string { + return path.resolve(import.meta.dirname, '..', '..', '..', 'test-projects', bundler, projectName, 'dist', 'assets'); } // -- Page rendering assertions -- diff --git a/plugin/test/e2e/rolldown-vanilla.spec.ts b/plugin/test/e2e/rolldown-vanilla.spec.ts new file mode 100644 index 0000000..9660877 --- /dev/null +++ b/plugin/test/e2e/rolldown-vanilla.spec.ts @@ -0,0 +1,74 @@ +import { test, expect } from '@playwright/test'; +import { + assetsDir, + findFile, + expectSvgsVisible, + expectSpriteRef, + expectInlineSvg, + expectDefaultSprite, + expectNamedSprite, + expectSpriteContainsArrow, + expectSpriteNoInlineSvgs, + expectNamedSpriteContainsCircle, + expectChunkContainsSpriteRefs, + expectChunkContainsInlineMarkup, +} from './helpers.ts'; + +const ASSETS = assetsDir('vanilla', 'rolldown'); + +test.describe('Rolldown app', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + }); + + // -- Page rendering -- + + test('page loads', async ({ page }) => { + await expect(page).toHaveTitle(/SVG Jar/); + }); + + test('renders SVG elements', async ({ page }) => { + await expectSvgsVisible(page); + }); + + test('renders sprite icons with ', async ({ page }) => { + await expectSpriteRef(page); + }); + + test('renders inline SVGs without ', async ({ page }) => { + await expectInlineSvg(page); + }); + + test('factory options create ', async ({ page }) => { + const title = page.locator('svg title'); + await expect(title.first()).toBeAttached(); + await expect(title.first()).toHaveText('Forward arrow'); + }); + + test('renders file mode SVG as an img', async ({ page }) => { + const img = page.locator('img[alt="Star"]'); + await expect(img).toBeAttached(); + const src = await img.getAttribute('src'); + expect(src).toMatch(/\.svg/); + }); + + test('all SVGs render correctly', async ({ page }) => { + const app = page.locator('#app'); + await expect(app).toBeVisible(); + await expect(app).toHaveScreenshot('all-svgs.png', { maxDiffPixelRatio: 0.01 }); + }); + + // -- Build output -- + + test('emits default sprite', () => expectDefaultSprite(ASSETS)); + test('emits named sprite', () => expectNamedSprite(ASSETS)); + test('sprite contains arrow symbol', () => expectSpriteContainsArrow(ASSETS)); + test('sprite excludes inline SVGs', () => expectSpriteNoInlineSvgs(ASSETS)); + test('named sprite contains circle', () => expectNamedSpriteContainsCircle(ASSETS)); + test('chunk references sprites', () => expectChunkContainsSpriteRefs(ASSETS)); + test('chunk embeds inline markup', () => expectChunkContainsInlineMarkup(ASSETS)); + + test('emits file-mode SVG', () => { + expect(findFile(ASSETS, (f) => f.includes('star') && f.endsWith('.svg'))).toBeDefined(); + }); +}); diff --git a/plugin/test/e2e/rolldown-vanilla.spec.ts-snapshots/all-svgs-rolldown-vanilla-linux.png b/plugin/test/e2e/rolldown-vanilla.spec.ts-snapshots/all-svgs-rolldown-vanilla-linux.png new file mode 100644 index 0000000..0ce481c Binary files /dev/null and b/plugin/test/e2e/rolldown-vanilla.spec.ts-snapshots/all-svgs-rolldown-vanilla-linux.png differ diff --git a/plugin/test/integration/rolldown.test.ts b/plugin/test/integration/rolldown.test.ts new file mode 100644 index 0000000..9cb5b03 --- /dev/null +++ b/plugin/test/integration/rolldown.test.ts @@ -0,0 +1,106 @@ +import path from 'node:path'; +import { rolldown, type RolldownOutput } from 'rolldown'; +import { describe, it, expect, beforeAll } from 'vitest'; +import { SvgJarPlugin } from '../../src/index.ts'; +import { PLACEHOLDER_RE } from '../../src/core/constants.ts'; + +const FIXTURES_DIR = path.join(import.meta.dirname, '..', '_fixtures'); +const ENTRY = path.join(FIXTURES_DIR, 'entry.js'); +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> { + 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' }); +} + +function findChunk(output: RolldownOutput) { + const chunk = output.output.find((o) => o.type === 'chunk'); + if (!chunk || chunk.type !== 'chunk') throw new Error('no chunk in output'); + return chunk; +} + +function findAsset(output: RolldownOutput, match: (fileName: string) => boolean) { + return output.output.find((o) => o.type === 'asset' && match(o.fileName)); +} + +describe('Rolldown integration', () => { + let output: RolldownOutput; + + beforeAll(async () => { + output = await build({ target: 'dom' }); + }); + + describe('output chunks', () => { + it('generates a single output chunk', () => { + const chunks = output.output.filter((o) => o.type === 'chunk'); + expect(chunks).toHaveLength(1); + }); + + it('contains createSvg import for sprite mode SVGs', () => { + expect(findChunk(output).code).toContain('createSvg'); + }); + + it('replaces all sprite placeholders with final sprite URLs', () => { + const code = findChunk(output).code; + + PLACEHOLDER_RE.lastIndex = 0; + expect(code).not.toMatch(PLACEHOLDER_RE); + expect(code).toMatch(/\/assets\/sprite-[a-f0-9]+\.svg#[a-f0-9]+/); + expect(code).toMatch(/\/assets\/nav-[a-f0-9]+\.svg#[a-f0-9]+/); + }); + }); + + describe('sprite files', () => { + it('emits a default sprite asset with content hash', () => { + expect(findAsset(output, (f) => f.startsWith('assets/sprite-') && f.endsWith('.svg'))).toBeDefined(); + }); + + it('emits a named sprite asset with content hash', () => { + expect(findAsset(output, (f) => f.startsWith('assets/nav-') && f.endsWith('.svg'))).toBeDefined(); + }); + + it('default sprite contains the simple icon symbol', () => { + const asset = findAsset(output, (f) => f.startsWith('assets/sprite-')); + expect(asset?.type === 'asset' && asset.source).toContain('<symbol'); + }); + }); + + describe('file mode', () => { + let fileOutput: RolldownOutput; + + beforeAll(async () => { + fileOutput = await build({ target: 'dom' }, ENTRY_FILE_MODE); + }); + + it('exports the emitted asset URL', () => { + const code = findChunk(fileOutput).code; + expect(code).toMatch(/\/assets\/simple-[a-f0-9]+\.svg/); + }); + + it('emits the SVG as a standalone file asset', () => { + expect(findAsset(fileOutput, (f) => /^assets\/simple-[a-f0-9]+\.svg$/.test(f))).toBeDefined(); + }); + }); + + describe('base option', () => { + it('prefixes sprite and file asset URLs with the configured base', async () => { + const [spriteOutput, fileOutput] = await Promise.all([ + build({ target: 'dom', base: '/vendor/icons/' }), + build({ target: 'dom', base: '/vendor/icons' }, ENTRY_FILE_MODE), + ]); + + expect(findChunk(spriteOutput).code).toMatch(/\/vendor\/icons\/assets\/sprite-[a-f0-9]+\.svg#[a-f0-9]+/); + // Trailing slash is added when missing + expect(findChunk(fileOutput).code).toMatch(/\/vendor\/icons\/assets\/simple-[a-f0-9]+\.svg/); + }); + }); +}); diff --git a/plugin/test/integration/tsdown.test.ts b/plugin/test/integration/tsdown.test.ts new file mode 100644 index 0000000..eb12096 --- /dev/null +++ b/plugin/test/integration/tsdown.test.ts @@ -0,0 +1,73 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { execa } from 'execa'; +import { describe, it, expect, beforeAll } from 'vitest'; + +const PLUGIN_DIR = path.resolve(import.meta.dirname, '..', '..'); +const PROJECT_DIR = path.resolve(PLUGIN_DIR, '..', 'test-projects', 'tsdown', 'internal-package'); +const DIST = path.join(PROJECT_DIR, 'dist'); + +function readDist(fileName: string): string { + return fs.readFileSync(path.join(DIST, fileName), 'utf-8'); +} + +/** + * Builds the tsdown example project against the freshly built plugin and + * asserts on the actual dist output. This exercises the real declaration + * path (dts importer detection -> virtual `.d.ts` module -> declaration + * codegen -> rolldown-plugin-dts bundling), which unit tests cannot: the + * detection is coupled to rolldown-plugin-dts internals, and tsdown + * exiting 0 alone proved nothing (it also exited 0 while emitting a + * declaration file full of runtime JS). + */ +describe('tsdown integration (internal-package example)', () => { + beforeAll(async () => { + // Build the plugin first: the example resolves @svg-jar/plugin via the + // workspace, whose exports point at dist/. Skipping this would silently + // test a stale build. + await execa('pnpm', ['build'], { cwd: PLUGIN_DIR }); + await execa('pnpm', ['build'], { cwd: PROJECT_DIR }); + }, 120_000); + + describe('JS output', () => { + it('replaces sprite placeholders with base-prefixed URLs', () => { + const code = readDist('index.js'); + + expect(code).not.toContain('__SVG_JAR_SPRITE__'); + expect(code).toMatch(/\/vendor\/icons\/assets\/sprite-[a-f0-9]+\.svg#[a-f0-9]+/); + }); + + it('exports a base-prefixed URL for the ?file import', () => { + expect(readDist('index.js')).toMatch(/\/vendor\/icons\/assets\/star-[a-f0-9]+\.svg/); + }); + + it('emits the sprite and file assets', () => { + const assets = fs.readdirSync(path.join(DIST, 'assets')); + + expect(assets.find((f) => /^sprite-[a-f0-9]+\.svg$/.test(f))).toBeDefined(); + expect(assets.find((f) => /^star-[a-f0-9]+\.svg$/.test(f))).toBeDefined(); + }); + }); + + describe('declaration output', () => { + it('declares the component and URL exports', () => { + const dts = readDist('index.d.ts'); + + expect(dts).toContain('declare const component: (options?: SvgOptions) => SVGSVGElement;'); + expect(dts).toContain('declare const url: string;'); + expect(dts).toContain('declare function createNextButton(label: string): HTMLButtonElement;'); + expect(dts).toMatch(/export \{.*component as Square.*\}/); + }); + + it('contains no runtime code or JS imports', () => { + const dts = readDist('index.d.ts'); + + // createSvg may appear inside inlined doc comments; assert it is + // never imported or called in actual declaration code. + expect(dts).not.toMatch(/import\s*\{[^}]*createSvg/); + expect(dts).not.toMatch(/^\s*(?:declare\s+)?(?:const|var|let)[^=\n]*=\s*.*createSvg/m); + expect(dts).not.toMatch(/from\s+["'][^"']*\.js["']/); + expect(dts).not.toContain('__SVG_JAR_SPRITE__'); + }); + }); +}); diff --git a/plugin/test/unit/codegen.test.ts b/plugin/test/unit/codegen.test.ts index a80f5bc..1c043c7 100644 --- a/plugin/test/unit/codegen.test.ts +++ b/plugin/test/unit/codegen.test.ts @@ -11,6 +11,7 @@ const BASE_CTX: CodegenContext = { height: '24', mode: 'sprite', isDev: false, + isEmbedded: false, svgMarkup: '<svg viewBox="0 0 24 24"><path d="M0 0"/></svg>', symbolMarkup: '<symbol id="abc12345" viewBox="0 0 24 24"><path d="M0 0"/></symbol>', refSymbols: [], diff --git a/plugin/test/unit/dts.test.ts b/plugin/test/unit/dts.test.ts new file mode 100644 index 0000000..fee3604 --- /dev/null +++ b/plugin/test/unit/dts.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { generateDts } from '../../src/codegen/dts.ts'; +import type { SvgJarTarget } from '../../src/core/options.ts'; + +const TARGETS: SvgJarTarget[] = ['dom', 'ember', 'react', 'preact', 'vue', 'solid', 'web-component']; + +describe('generateDts', () => { + it('generates a URL string declaration for file mode regardless of target', () => { + for (const target of TARGETS) { + expect(generateDts(target, 'file')).toMatchInlineSnapshot(` + "declare const url: string; + export default url;" + `); + } + }); + + it('generates a dom component declaration', () => { + expect(generateDts('dom', 'component')).toMatchInlineSnapshot(` + "import type { SvgOptions } from '@svg-jar/plugin/runtime/dom'; + declare const component: (options?: SvgOptions) => SVGSVGElement; + export default component;" + `); + }); + + it('generates an ember component declaration', () => { + expect(generateDts('ember', 'component')).toMatchInlineSnapshot(` + "import type { ComponentLike } from '@glint/template'; + declare const Component: ComponentLike<{ Element: SVGSVGElement; Blocks: { default: [] } }>; + export default Component;" + `); + }); + + it('generates a react component declaration', () => { + expect(generateDts('react', 'component')).toMatchInlineSnapshot(` + "import type { FC, SVGAttributes, ReactNode } from 'react'; + declare const Component: FC<SVGAttributes<SVGSVGElement> & { children?: ReactNode }>; + export default Component;" + `); + }); + + it('generates a preact component declaration', () => { + expect(generateDts('preact', 'component')).toMatchInlineSnapshot(` + "import type { FunctionComponent, JSX } from 'preact'; + declare const Component: FunctionComponent<JSX.SVGAttributes<SVGSVGElement>>; + export default Component;" + `); + }); + + it('generates a vue component declaration', () => { + expect(generateDts('vue', 'component')).toMatchInlineSnapshot(` + "import type { Component } from 'vue'; + declare const component: Component; + export default component;" + `); + }); + + it('generates a solid component declaration', () => { + expect(generateDts('solid', 'component')).toMatchInlineSnapshot(` + "import type { Component, JSX, ParentProps } from 'solid-js'; + declare const component: Component<ParentProps<JSX.SvgSVGAttributes<SVGSVGElement>>>; + export default component;" + `); + }); + + it('generates a web-component declaration', () => { + expect(generateDts('web-component', 'component')).toMatchInlineSnapshot(` + "declare const ElementClass: typeof HTMLElement; + export default ElementClass;" + `); + }); +}); diff --git a/plugin/test/unit/options.test.ts b/plugin/test/unit/options.test.ts index dc74f1b..6b8d098 100644 --- a/plugin/test/unit/options.test.ts +++ b/plugin/test/unit/options.test.ts @@ -31,4 +31,18 @@ describe('resolveOptions', () => { expect(resolved.svgo).toBe(svgoConfig); }); + + describe('base', () => { + it('defaults to undefined (bundler decides)', () => { + expect(resolveOptions({}).base).toBeUndefined(); + }); + + it('preserves a base with a trailing slash', () => { + expect(resolveOptions({ base: '/vendor/icons/' }).base).toBe('/vendor/icons/'); + }); + + it('adds a missing trailing slash', () => { + expect(resolveOptions({ base: '/vendor/icons' }).base).toBe('/vendor/icons/'); + }); + }); }); diff --git a/plugin/test/unit/query.test.ts b/plugin/test/unit/query.test.ts index f33caf3..6fcd2ae 100644 --- a/plugin/test/unit/query.test.ts +++ b/plugin/test/unit/query.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseSvgId, isSvgId } from '../../src/core/query.ts'; +import { parseSvgId, isSvgId, isDtsImporter, makeSvgDtsId, parseSvgDtsId } from '../../src/core/query.ts'; describe('parseSvgId', () => { describe('import mode', () => { @@ -113,3 +113,44 @@ describe('isSvgId', () => { expect(isSvgId('/project/svg-utils.ts')).toBe(false); }); }); + +describe('isDtsImporter', () => { + it('returns true for .d.ts, .d.mts, and .d.cts importers', () => { + expect(isDtsImporter('/project/src/index.d.ts')).toBe(true); + expect(isDtsImporter('/project/src/index.d.mts')).toBe(true); + expect(isDtsImporter('/project/src/index.d.cts')).toBe(true); + }); + + it('returns false for source modules and missing importers', () => { + expect(isDtsImporter('/project/src/index.ts')).toBe(false); + expect(isDtsImporter('/project/src/index.js')).toBe(false); + expect(isDtsImporter(undefined)).toBe(false); + }); +}); + +describe('makeSvgDtsId / parseSvgDtsId', () => { + it('round-trips a component declaration module ID', () => { + const id = makeSvgDtsId('/project/icon.svg', 'component'); + + expect(id).toBe('/project/icon.svg.svg-jar.d.ts'); + expect(parseSvgDtsId(id)).toBe('component'); + }); + + it('round-trips a file declaration module ID', () => { + const id = makeSvgDtsId('/project/icon.svg', 'file'); + + expect(id).toBe('/project/icon.svg.svg-jar-file.d.ts'); + expect(parseSvgDtsId(id)).toBe('file'); + }); + + it('declaration module IDs do not look like SVG imports', () => { + expect(isSvgId(makeSvgDtsId('/project/icon.svg', 'component'))).toBe(false); + expect(isSvgId(makeSvgDtsId('/project/icon.svg', 'file'))).toBe(false); + }); + + it('returns null for regular module IDs', () => { + expect(parseSvgDtsId('/project/icon.svg')).toBeNull(); + expect(parseSvgDtsId('/project/icon.svg?file')).toBeNull(); + expect(parseSvgDtsId('/project/src/index.d.ts')).toBeNull(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1acdaff..40c198f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ catalogs: execa: specifier: ^9.6.0 version: 9.6.1 + rolldown: + specifier: ^1.0.0-rc.15 + version: 1.0.0-rc.15 rollup: specifier: ^4.60.1 version: 4.60.1 @@ -75,6 +78,9 @@ importers: execa: specifier: 'catalog:' version: 9.6.1 + rolldown: + specifier: 'catalog:' + version: 1.0.0-rc.15 rollup: specifier: 'catalog:' version: 4.60.1 @@ -85,6 +91,33 @@ importers: specifier: 'catalog:' version: 4.1.4(@types/node@25.5.0)(@vitest/browser-playwright@4.1.4)(@vitest/ui@4.1.4)(jsdom@25.0.1)(vite@8.0.8(@types/node@25.5.0)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)) + test-projects/rolldown/vanilla: + devDependencies: + '@svg-jar/plugin': + specifier: workspace:* + version: link:../../../plugin + rolldown: + specifier: 'catalog:' + version: 1.0.0-rc.15 + typescript: + specifier: 'catalog:' + version: 6.0.2 + vite: + specifier: 'catalog:' + version: 8.0.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@25.5.0)(esbuild@0.27.7)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0) + + test-projects/tsdown/internal-package: + devDependencies: + '@svg-jar/plugin': + specifier: workspace:* + version: link:../../../plugin + tsdown: + specifier: 'catalog:' + version: 0.21.8(typescript@6.0.2)(vue-tsc@3.2.6(typescript@6.0.2)) + typescript: + specifier: 'catalog:' + version: 6.0.2 + test-projects/vite/ember: dependencies: '@ember/test-waiters': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5ed4f8e..54119ca 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,7 @@ catalog: '@types/node': ^24.12.2 '@vitest/ui': ^4.1.4 execa: ^9.6.0 + rolldown: ^1.0.0-rc.15 rollup: ^4.60.1 tsdown: ^0.21.8 typescript: ~6.0.2 diff --git a/test-projects/rolldown/vanilla/.gitignore b/test-projects/rolldown/vanilla/.gitignore new file mode 100644 index 0000000..1eae0cf --- /dev/null +++ b/test-projects/rolldown/vanilla/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/test-projects/rolldown/vanilla/index.html b/test-projects/rolldown/vanilla/index.html new file mode 100644 index 0000000..a6a99fe --- /dev/null +++ b/test-projects/rolldown/vanilla/index.html @@ -0,0 +1,11 @@ +<!doctype html> +<html> + <head> + <meta charset="utf-8" /> + <title>SVG Jar Rolldown Test + + +
+ + + diff --git a/test-projects/rolldown/vanilla/package.json b/test-projects/rolldown/vanilla/package.json new file mode 100644 index 0000000..cc6df07 --- /dev/null +++ b/test-projects/rolldown/vanilla/package.json @@ -0,0 +1,16 @@ +{ + "name": "@svg-jar/test-rolldown-vanilla", + "private": true, + "type": "module", + "version": "0.0.0", + "scripts": { + "build": "rolldown -c && cp index.html dist/", + "preview": "vite preview" + }, + "devDependencies": { + "@svg-jar/plugin": "workspace:*", + "rolldown": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/test-projects/rolldown/vanilla/rolldown.config.ts b/test-projects/rolldown/vanilla/rolldown.config.ts new file mode 100644 index 0000000..85b4cd8 --- /dev/null +++ b/test-projects/rolldown/vanilla/rolldown.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'rolldown'; +import svgJar from '@svg-jar/plugin/rolldown'; + +// Rolldown as the application bundler: one build owns the whole module +// graph, so sprite assembly works exactly like the Vite apps. +export default defineConfig({ + input: 'src/main.ts', + plugins: [svgJar({ target: 'dom' })], + output: { + dir: 'dist', + // Match the Vite apps' layout: JS chunks live in dist/assets/ alongside + // the emitted sprite and file assets. + entryFileNames: 'assets/[name].js', + chunkFileNames: 'assets/[name]-[hash].js', + }, +}); diff --git a/test-projects/rolldown/vanilla/src/icons/arrow.svg b/test-projects/rolldown/vanilla/src/icons/arrow.svg new file mode 100644 index 0000000..534ae20 --- /dev/null +++ b/test-projects/rolldown/vanilla/src/icons/arrow.svg @@ -0,0 +1 @@ + diff --git a/test-projects/rolldown/vanilla/src/icons/circle.svg b/test-projects/rolldown/vanilla/src/icons/circle.svg new file mode 100644 index 0000000..d9c8483 --- /dev/null +++ b/test-projects/rolldown/vanilla/src/icons/circle.svg @@ -0,0 +1 @@ + diff --git a/test-projects/rolldown/vanilla/src/icons/square.svg b/test-projects/rolldown/vanilla/src/icons/square.svg new file mode 100644 index 0000000..2be60e5 --- /dev/null +++ b/test-projects/rolldown/vanilla/src/icons/square.svg @@ -0,0 +1 @@ + diff --git a/test-projects/rolldown/vanilla/src/icons/star.svg b/test-projects/rolldown/vanilla/src/icons/star.svg new file mode 100644 index 0000000..4547134 --- /dev/null +++ b/test-projects/rolldown/vanilla/src/icons/star.svg @@ -0,0 +1,4 @@ + + + + diff --git a/test-projects/rolldown/vanilla/src/main.ts b/test-projects/rolldown/vanilla/src/main.ts new file mode 100644 index 0000000..00c8827 --- /dev/null +++ b/test-projects/rolldown/vanilla/src/main.ts @@ -0,0 +1,38 @@ +// Default import - sprite mode +import Arrow from './icons/arrow.svg'; + +// Named sprite +import Circle from './icons/circle.svg?sprite=shapes'; + +// Inline mode +import Square from './icons/square.svg?unsafe-inline'; + +// File mode - raw URL +import starUrl from './icons/star.svg?file'; + +const app = document.getElementById('app')!; + +// Sprite mode - call the factory to create a DOM node +app.appendChild(Arrow()); + +// Sprite mode with options - attributes, title, desc +app.appendChild( + Arrow({ + class: 'icon', + 'aria-label': 'Navigate forward', + title: 'Forward arrow', + desc: 'An arrow pointing to the right', + }), +); + +// Named sprite +app.appendChild(Circle()); + +// Inline mode +app.appendChild(Square()); + +// File mode - create an img element with the URL +const img = document.createElement('img'); +img.src = starUrl; +img.alt = 'Star'; +app.appendChild(img); diff --git a/test-projects/rolldown/vanilla/src/types.d.ts b/test-projects/rolldown/vanilla/src/types.d.ts new file mode 100644 index 0000000..b22f559 --- /dev/null +++ b/test-projects/rolldown/vanilla/src/types.d.ts @@ -0,0 +1,6 @@ +declare module '*.svg?sprite=shapes' { + import type { SvgOptions } from '@svg-jar/plugin/runtime/dom'; + + const component: (options?: SvgOptions) => SVGSVGElement; + export default component; +} diff --git a/test-projects/rolldown/vanilla/tsconfig.json b/test-projects/rolldown/vanilla/tsconfig.json new file mode 100644 index 0000000..f4d90fe --- /dev/null +++ b/test-projects/rolldown/vanilla/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "types": ["@svg-jar/plugin/client/dom"] + }, + "include": ["src"] +} diff --git a/test-projects/tsdown/internal-package/.gitignore b/test-projects/tsdown/internal-package/.gitignore new file mode 100644 index 0000000..1eae0cf --- /dev/null +++ b/test-projects/tsdown/internal-package/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/test-projects/tsdown/internal-package/README.md b/test-projects/tsdown/internal-package/README.md new file mode 100644 index 0000000..82a7e31 --- /dev/null +++ b/test-projects/tsdown/internal-package/README.md @@ -0,0 +1,21 @@ +# tsdown internal package example + +Models an **internal monorepo package** built with [tsdown](https://tsdown.dev), consumed by an application the same team owns. + +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 its sprite and `?file` assets to its own `dist/assets/` with baked URLs, which a consuming bundler will not copy or rewrite. That is only viable when you **control the deploy origin** — i.e. an internal package whose consuming app takes on serving the assets: + +1. The plugin is configured with a fixed base path (see `tsdown.config.ts`): + + ```ts + svgJar({ target: 'dom', base: '/vendor/icons/' }); + ``` + +2. The consuming app copies this package's assets to that path as part of its build, e.g.: + + ```sh + cp -r node_modules/@your-org/icons/dist/assets public/vendor/icons/assets + ``` + +For a **publicly distributed library**, don't prebake sprites: ship raw SVG files (and let the consuming app run svg-jar itself), or export inline components (`?unsafe-inline`), which need no asset serving. + +Declaration output (`dts: true`) works for any of these shapes — SVG re-exports get correctly typed declarations per target. diff --git a/test-projects/tsdown/internal-package/package.json b/test-projects/tsdown/internal-package/package.json new file mode 100644 index 0000000..f5e95de --- /dev/null +++ b/test-projects/tsdown/internal-package/package.json @@ -0,0 +1,17 @@ +{ + "name": "@svg-jar/test-tsdown-internal-package", + "private": true, + "type": "module", + "version": "0.0.0", + "exports": { + ".": "./dist/index.js" + }, + "scripts": { + "build": "tsdown" + }, + "devDependencies": { + "@svg-jar/plugin": "workspace:*", + "tsdown": "catalog:", + "typescript": "catalog:" + } +} diff --git a/test-projects/tsdown/internal-package/src/icons/arrow.svg b/test-projects/tsdown/internal-package/src/icons/arrow.svg new file mode 100644 index 0000000..534ae20 --- /dev/null +++ b/test-projects/tsdown/internal-package/src/icons/arrow.svg @@ -0,0 +1 @@ + diff --git a/test-projects/tsdown/internal-package/src/icons/square.svg b/test-projects/tsdown/internal-package/src/icons/square.svg new file mode 100644 index 0000000..2be60e5 --- /dev/null +++ b/test-projects/tsdown/internal-package/src/icons/square.svg @@ -0,0 +1 @@ + diff --git a/test-projects/tsdown/internal-package/src/icons/star.svg b/test-projects/tsdown/internal-package/src/icons/star.svg new file mode 100644 index 0000000..4547134 --- /dev/null +++ b/test-projects/tsdown/internal-package/src/icons/star.svg @@ -0,0 +1,4 @@ + + + + diff --git a/test-projects/tsdown/internal-package/src/index.ts b/test-projects/tsdown/internal-package/src/index.ts new file mode 100644 index 0000000..ed3f63f --- /dev/null +++ b/test-projects/tsdown/internal-package/src/index.ts @@ -0,0 +1,24 @@ +// An internal package in a monorepo, built with tsdown and consumed by an +// app the same team owns. The app copies dist/assets/ to a fixed public +// path matching the `base` plugin option (see tsdown.config.ts and README). + +import Arrow from './icons/arrow.svg'; + +/** + * A button that renders a sprite icon internally. The sprite sheet is + * emitted to dist/assets/ and served by the consuming app from the + * configured base path. + */ +export function createNextButton(label: string): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.append(label, Arrow({ 'aria-hidden': 'true' })); + return button; +} + +// Inline components are safe to re-export from any package - the markup is +// embedded in the JS, no asset serving required. +export { default as Square } from './icons/square.svg?unsafe-inline'; + +// File mode works here because the app serves dist/assets/ from the base path. +export { default as starUrl } from './icons/star.svg?file'; diff --git a/test-projects/tsdown/internal-package/tsconfig.json b/test-projects/tsdown/internal-package/tsconfig.json new file mode 100644 index 0000000..f4d90fe --- /dev/null +++ b/test-projects/tsdown/internal-package/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "types": ["@svg-jar/plugin/client/dom"] + }, + "include": ["src"] +} diff --git a/test-projects/tsdown/internal-package/tsdown.config.ts b/test-projects/tsdown/internal-package/tsdown.config.ts new file mode 100644 index 0000000..adccd7f --- /dev/null +++ b/test-projects/tsdown/internal-package/tsdown.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'tsdown'; +import svgJar from '@svg-jar/plugin/rolldown'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: 'esm', + platform: 'browser', + dts: true, + plugins: [ + svgJar({ + target: 'dom', + // The consuming app copies this package's dist/assets/ directory to + // public/vendor/icons/assets/, so emitted asset URLs resolve at runtime. + base: '/vendor/icons/', + }), + ], +});