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
44 changes: 43 additions & 1 deletion plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,26 @@ svgJar({
// Default: false
currentColor: false,

// Attributes copied from the root <svg> of each source file to the <svg>
// that the runtime renders. See "Root attributes" below.
// Default: the list below. A list given here replaces the default list.
rootAttributes: [
'fill',
'fill-opacity',
'fill-rule',
'stroke',
'stroke-width',
'stroke-linecap',
'stroke-linejoin',
'stroke-miterlimit',
'stroke-dasharray',
'stroke-dashoffset',
'stroke-opacity',
'clip-rule',
'opacity',
'color',
],

// Sprite sheets to inline into the HTML document rather than emit as
// external files. See "Embedded sprites" below.
// true: embed all sprites | string[]: embed named sprites | false (default): external files
Expand Down Expand Up @@ -168,6 +188,28 @@ These can be combined with any import mode:
import Icon from './icons/icon.svg?current-color';
```

### Root attributes

Many icon sets put presentation attributes on the root `<svg>` and leave them off the children. Heroicons outline icons look like this:

```svg
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 15.75 7.5-7.5 7.5 7.5"/>
</svg>
```

svg-jar does not keep the root `<svg>` of the source file. The runtime renders its own `<svg>`, and in sprite mode the children move into a `<symbol>`. So svg-jar copies the attributes in `rootAttributes` from the source root to the rendered `<svg>`, and the children inherit them as before.

Attributes you pass to the component, and CSS rules on the rendered `<svg>`, override the copied values. For example, `<ChevronUp stroke-width="2" />` or a Tailwind class like `stroke-2` gives a thicker stroke.

The default list is:

`fill`, `fill-opacity`, `fill-rule`, `stroke`, `stroke-width`, `stroke-linecap`, `stroke-linejoin`, `stroke-miterlimit`, `stroke-dasharray`, `stroke-dashoffset`, `stroke-opacity`, `clip-rule`, `opacity`, `color`

A list in `rootAttributes` replaces the default list. It can name any attribute, such as `aria-hidden`. An empty list copies nothing.

When `currentColor` is on, the root `fill` and `stroke` are replaced in the same way as on the children.

## Targets

The `target` option controls what each SVG import exports. All targets support sprite, inline, file, and named sprite modes.
Expand Down Expand Up @@ -351,7 +393,7 @@ The `?file` query always exports a `string` URL regardless of target, so no cust
### Build mode

1. **`resolveId`** <br> Intercepts `.svg` imports and parses query strings
2. **`load`** <br> Reads the SVG, optimizes with SVGO, parses via [@eksml/xml](https://github.com/evoactivity/eksml), applies transforms (currentColor, strip dimensions), resolves embedded `<use>` and `<image>` references, registers symbols in the sprite registry
2. **`load`** <br> Reads the SVG, optimizes with SVGO, parses via [@eksml/xml](https://github.com/evoactivity/eksml), applies transforms (currentColor, strip dimensions), reads the root attributes, resolves embedded `<use>` and `<image>` references, registers symbols in the sprite registry
3. **`transform`** <br> Generates framework-specific component code with a sprite placeholder URL
4. **`renderChunk`** <br> Replaces placeholders with final sprite URLs, tracks which symbols are actually used (tree-shaking)
5. **`generateBundle`** <br> Assembles sprite sheets from used symbols only, resolves embedded refs to final asset URLs, emits sprite and file assets
Expand Down
17 changes: 17 additions & 0 deletions plugin/runtime/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@ function applyOptions(svg: SVGSVGElement, options: SvgOptions): void {
}
}

/**
* Sets the root attributes of the source file on the rendered `<svg>`.
* Called before `applyOptions` so the caller's attributes win.
*/
function applyRootAttrs(svg: SVGSVGElement, rootAttrs: Record<string, string> | null): void {
if (!rootAttrs) return;
for (const [key, value] of Object.entries(rootAttrs)) {
svg.setAttribute(key, value);
}
}

/**
* Returns a factory function that creates an SVG element referencing a
* sprite symbol via `<use href>`. Each call to the factory produces a
Expand All @@ -62,6 +73,7 @@ function applyOptions(svg: SVGSVGElement, options: SvgOptions): void {
* @param width The `width` attribute, or `null` to omit.
* @param height The `height` attribute, or `null` to omit.
* @param href The sprite symbol reference (e.g. `/assets/sprite.svg#abc123`).
* @param rootAttrs Attributes from the root of the source `<svg>`, or `null` if none.
* @returns A factory function that creates a new SVG DOM element on each call.
*
* @example
Expand All @@ -74,13 +86,15 @@ export function createSvg(
width: string | null,
height: string | null,
href: string,
rootAttrs: Record<string, string> | null = null,
): (options?: SvgOptions) => SVGSVGElement {
return (options?: SvgOptions) => {
const svg = document.createElementNS(SVG_NS, 'svg');

if (viewBox) svg.setAttribute('viewBox', viewBox);
if (width) svg.setAttribute('width', width);
if (height) svg.setAttribute('height', height);
applyRootAttrs(svg, rootAttrs);

const use = document.createElementNS(SVG_NS, 'use');
use.setAttributeNS(XLINK_NS, 'xlink:href', href);
Expand All @@ -105,6 +119,7 @@ export function createSvg(
* @param width The `width` attribute.
* @param height The `height` attribute.
* @param svgMarkup The inner SVG markup string.
* @param rootAttrs Attributes from the root of the source `<svg>`, or `null` if none.
* @returns A factory function that creates a new SVG DOM element on each call.
*
* @example
Expand All @@ -117,13 +132,15 @@ export function createSvgInline(
width: string | null,
height: string | null,
svgMarkup: string,
rootAttrs: Record<string, string> | null = null,
): (options?: SvgOptions) => SVGSVGElement {
return (options?: SvgOptions) => {
const svg = document.createElementNS(SVG_NS, 'svg');

if (viewBox) svg.setAttribute('viewBox', viewBox);
if (width) svg.setAttribute('width', width);
if (height) svg.setAttribute('height', height);
applyRootAttrs(svg, rootAttrs);

svg.innerHTML = svgMarkup;

Expand Down
52 changes: 45 additions & 7 deletions plugin/runtime/ember.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,63 @@
// @ts-expect-error @ember/template-compiler is a peer dependency provided by the consuming Ember app
import { template } from '@ember/template-compiler';
// @ts-expect-error @ember/modifier is provided by the consuming Ember app
import { capabilities, setModifierManager } from '@ember/modifier';

/**
* Modifier that sets the root attributes of the source file on the
* rendered `<svg>`. Used as `{{rootAttrsModifier rootAttrs}}`.
*
* Modifiers run after `...attributes` is applied, so an attribute the
* element already has came from the caller and is left alone. This lets
* the caller's attributes win.
*
* A modifier is used because the Ember build precompiles `template()`
* calls and rejects template strings built at runtime. Ember has no
* default manager for plain function modifiers, so this sets one.
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
const rootAttrsModifier: object = /*#__PURE__*/ setModifierManager(
() => ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
capabilities: capabilities('3.22'),
createModifier: () => ({}),
installModifier(_state: unknown, el: Element, args: { positional: [Record<string, string> | null] }) {
for (const [name, value] of Object.entries(args.positional[0] ?? {})) {
if (!el.hasAttribute(name)) el.setAttribute(name, value);
}
},
updateModifier: () => {},
destroyModifier: () => {},
}),
{},
);

/**
* Creates an Ember component that renders an SVG sprite symbol via `<use href>`.
* Used in production builds where SVGs are collected into sprite sheets.
*
* The template is compiled once with scoped bindings rather than string
* interpolation, so each SVG reuses the same compiled template.
* interpolation, so each SVG reuses the same compiled template. The root
* attributes are set by a modifier that leaves the caller's attributes alone.
*
* @param viewBox The `viewBox` attribute for the `<svg>` element.
* @param width The `width` attribute, or `null` to omit.
* @param height The `height` attribute, or `null` to omit.
* @param symbol The sprite symbol reference (e.g. `/assets/sprite.svg#abc123`).
* @param rootAttrs Attributes from the root of the source `<svg>`, or `null` if none.
* @returns An Ember template component.
*/
export const createSvg = /*#__NO_SIDE_EFFECTS__*/ (
viewBox: string | null,
width: string | null,
height: string | null,
symbol: string,
rootAttrs: Record<string, string> | null = null,
): unknown => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
return template(
`<svg viewBox={{viewBox}} width={{width}} height={{height}} ...attributes>{{yield}}<use href={{symbol}} /></svg>`,
{ scope: () => ({ viewBox, width, height, symbol }) },
`<svg viewBox={{viewBox}} width={{width}} height={{height}} ...attributes {{rootAttrsModifier rootAttrs}}>{{yield}}<use href={{symbol}} /></svg>`,
{ scope: () => ({ viewBox, width, height, symbol, rootAttrs, rootAttrsModifier }) },
);
};

Expand All @@ -33,22 +67,26 @@ export const createSvg = /*#__NO_SIDE_EFFECTS__*/ (
*
* The SVG markup is injected as raw HTML via triple-curlies `{{{xml}}}`,
* wrapped in an `<svg>` element that receives `...attributes` for
* splattributes support.
* splattributes support. The root attributes are set by a modifier that
* leaves the caller's attributes alone.
*
* @param viewBox The `viewBox` attribute for the wrapper `<svg>`.
* @param width The `width` attribute, or `null` to omit.
* @param height The `height` attribute, or `null` to omit.
* @param xml The inner SVG markup string (children of the root `<svg>`).
* @param rootAttrs Attributes from the root of the source `<svg>`, or `null` if none.
* @returns An Ember template component.
*/
export const createSvgInline = /*#__NO_SIDE_EFFECTS__*/ (
viewBox: string | null,
width: string | null,
height: string | null,
xml: string,
rootAttrs: Record<string, string> | null = null,
): unknown => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
return template(`<svg viewBox={{viewBox}} width={{width}} height={{height}} ...attributes>{{yield}}{{{xml}}}</svg>`, {
scope: () => ({ viewBox, width, height, xml }),
});
return template(
`<svg viewBox={{viewBox}} width={{width}} height={{height}} ...attributes {{rootAttrsModifier rootAttrs}}>{{yield}}{{{xml}}}</svg>`,
{ scope: () => ({ viewBox, width, height, xml, rootAttrs, rootAttrsModifier }) },
);
};
12 changes: 8 additions & 4 deletions plugin/runtime/preact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,23 @@ type SvgProps = JSX.SVGAttributes<SVGSVGElement> & {
* @param width The `width` attribute, or `null` to omit.
* @param height The `height` attribute, or `null` to omit.
* @param href The sprite symbol reference (e.g. `/assets/sprite.svg#abc123`).
* @param rootAttrs Attributes from the root of the source `<svg>`, or `null` if none.
* @returns A Preact function component.
*/
export const createSvg: (
viewBox: string | null,
width: string | null,
height: string | null,
href: string,
) => unknown = /*#__NO_SIDE_EFFECTS__*/ (viewBox, width, height, href) => {
rootAttrs?: Record<string, string> | null,
) => unknown = /*#__NO_SIDE_EFFECTS__*/ (viewBox, width, height, href, rootAttrs) => {
const useEl = h('use', { href });

return (props: SvgProps) => {
const { children, ...rest } = props;
return h(
'svg',
{ viewBox: viewBox ?? undefined, width: width ?? undefined, height: height ?? undefined, ...rest },
{ viewBox: viewBox ?? undefined, width: width ?? undefined, height: height ?? undefined, ...rootAttrs, ...rest },
children,
useEl,
);
Expand All @@ -49,14 +51,16 @@ export const createSvg: (
* @param width The `width` attribute, or `null` to omit.
* @param height The `height` attribute, or `null` to omit.
* @param xml The inner SVG markup string.
* @param rootAttrs Attributes from the root of the source `<svg>`, or `null` if none.
* @returns A Preact function component.
*/
export const createSvgInline: (
viewBox: string | null,
width: string | null,
height: string | null,
xml: string,
) => unknown = /*#__NO_SIDE_EFFECTS__*/ (viewBox, width, height, xml) => {
rootAttrs?: Record<string, string> | null,
) => unknown = /*#__NO_SIDE_EFFECTS__*/ (viewBox, width, height, xml, rootAttrs) => {
return (props: SvgProps) => {
const { children, ...rest } = props;
const setMarkup = useCallback((node: SVGGElement | null) => {
Expand All @@ -65,7 +69,7 @@ export const createSvgInline: (

return h(
'svg',
{ viewBox: viewBox ?? undefined, width: width ?? undefined, height: height ?? undefined, ...rest },
{ viewBox: viewBox ?? undefined, width: width ?? undefined, height: height ?? undefined, ...rootAttrs, ...rest },
children,
h('g', { ref: setMarkup }),
);
Expand Down
41 changes: 37 additions & 4 deletions plugin/runtime/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@
// @ts-expect-error react is a peer dependency provided by the consuming app
import { createElement, useCallback } from 'react';

/**
* Converts attribute names to the camelCase prop names React expects,
* e.g. `stroke-width` to `strokeWidth`. React keeps `aria-*` and `data-*`
* hyphenated.
*/
function toReactProps(rootAttrs: Record<string, string> | null | undefined): Record<string, string> {
const props: Record<string, string> = {};
for (const [key, value] of Object.entries(rootAttrs ?? {})) {
const name = /^(aria|data)-/.test(key) ? key : key.replace(/[-:]([a-z])/g, (_, c: string) => c.toUpperCase());
props[name] = value;
}
return props;
}

/**
* Creates a React component that renders an SVG sprite symbol via `<use href>`.
* Used in production builds where SVGs are collected into sprite sheets.
Expand All @@ -13,21 +27,30 @@ import { createElement, useCallback } from 'react';
* @param width The `width` attribute, or `null` to omit.
* @param height The `height` attribute, or `null` to omit.
* @param href The sprite symbol reference (e.g. `/assets/sprite.svg#abc123`).
* @param rootAttrs Attributes from the root of the source `<svg>`, or `null` if none.
* @returns A React function component.
*/
export const createSvg: (
viewBox: string | null,
width: string | null,
height: string | null,
href: string,
) => (props: unknown) => Record<string, unknown> = /*#__NO_SIDE_EFFECTS__*/ (viewBox, width, height, href) => {
rootAttrs?: Record<string, string> | null,
) => (props: unknown) => Record<string, unknown> = /*#__NO_SIDE_EFFECTS__*/ (
viewBox,
width,
height,
href,
rootAttrs,
) => {
const useEl = createElement('use', { href });
const rootProps = toReactProps(rootAttrs);

return (props: unknown) => {
const { children, ...rest } = props as Record<string, unknown>;
return createElement(
'svg',
{ viewBox: viewBox ?? undefined, width: width ?? undefined, height: height ?? undefined, ...rest },
{ viewBox: viewBox ?? undefined, width: width ?? undefined, height: height ?? undefined, ...rootProps, ...rest },
children,
useEl,
);
Expand All @@ -46,14 +69,24 @@ export const createSvg: (
* @param width The `width` attribute, or `null` to omit.
* @param height The `height` attribute, or `null` to omit.
* @param xml The inner SVG markup string.
* @param rootAttrs Attributes from the root of the source `<svg>`, or `null` if none.
* @returns A React function component.
*/
export const createSvgInline: (
viewBox: string | null,
width: string | null,
height: string | null,
xml: string,
) => (props: unknown) => Record<string, unknown> = /*#__NO_SIDE_EFFECTS__*/ (viewBox, width, height, xml) => {
rootAttrs?: Record<string, string> | null,
) => (props: unknown) => Record<string, unknown> = /*#__NO_SIDE_EFFECTS__*/ (
viewBox,
width,
height,
xml,
rootAttrs,
) => {
const rootProps = toReactProps(rootAttrs);

return (props: unknown) => {
const { children, ...rest } = props as Record<string, unknown>;
const setMarkup = useCallback((node: SVGGElement | null) => {
Expand All @@ -62,7 +95,7 @@ export const createSvgInline: (

return createElement(
'svg',
{ viewBox: viewBox ?? undefined, width: width ?? undefined, height: height ?? undefined, ...rest },
{ viewBox: viewBox ?? undefined, width: width ?? undefined, height: height ?? undefined, ...rootProps, ...rest },
children,
createElement('g', { ref: setMarkup }),
);
Expand Down
Loading
Loading