@onedarnleyroad/vite-plugin-svg-sprite 2.0.0-beta.6 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -14
- package/package.json +17 -2
- package/src/index.js +32 -12
package/README.md
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
# @onedarnleyroad/vite-plugin-svg-sprite
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@onedarnleyroad/vite-plugin-svg-sprite)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
|
|
6
|
+
A Vite plugin that builds **content-hashed SVG sprite sheets** at build time and registers them in **Vite's manifest** — built for server-rendered apps like **Craft CMS** (via [nystudio107's Vite plugin](https://nystudio107.com/docs/vite/)), and works with any manifest consumer. Organise icons into folders to get one sprite per folder, and reference them from templates with no JavaScript.
|
|
4
7
|
|
|
5
8
|
## Features
|
|
6
9
|
|
|
7
10
|
- Combines individual SVGs into a single `<svg>` sprite with `<symbol>` elements
|
|
8
|
-
- **Content-hashed output** (e.g. `sprite-
|
|
11
|
+
- **Content-hashed output** (e.g. `sprite-9a3f2c1e.svg`) so browsers never serve stale sprites
|
|
9
12
|
- **Vite manifest integration** — works out of the box with [nystudio107's Craft Vite plugin](https://nystudio107.com/docs/vite/) and any other manifest consumer
|
|
10
13
|
- **Multiple sprites from subfolders** — organise icons into `light/`, `dark/`, etc. and get one sprite per folder
|
|
11
14
|
- Namespaces internal IDs to prevent conflicts across icons
|
|
12
|
-
- **Preserves outer `<svg>` attributes** on the generated `<symbol>` — `fill="currentColor"`, `stroke`, `class`, `role`, `aria-*`, `data-*`, `style` (including CSS custom properties), etc. Only the truly wrapper-specific attributes are stripped: `xmlns
|
|
13
|
-
- Preserves existing `<title
|
|
14
|
-
- Strips comments and empty `<defs>` blocks
|
|
15
|
+
- **Preserves outer `<svg>` attributes** on the generated `<symbol>` — `fill="currentColor"`, `stroke`, `class`, `role`, `aria-*`, `data-*`, `style` (including CSS custom properties), etc. Only the truly wrapper-specific attributes are stripped: `xmlns` (and `xmlns:*` namespace declarations such as `xmlns:xlink`), `version`, `width`, `height`, and `id` (we set our own).
|
|
16
|
+
- Preserves an existing `<title>`, or synthesizes one from the filename (`arrow.svg` → `arrow icon`)
|
|
17
|
+
- Strips comments, XML prolog / DOCTYPE declarations, and empty `<defs>` blocks
|
|
15
18
|
|
|
16
19
|
## Installation
|
|
17
20
|
|
|
@@ -46,7 +49,7 @@ export default defineConfig({
|
|
|
46
49
|
| `outputDir` | `string` | `'assets'` | Output directory, relative to Vite's `build.outDir`. |
|
|
47
50
|
| `prefix` | `string` | `'sprite'` | Filename prefix. The dash between prefix and folder name is always added. |
|
|
48
51
|
|
|
49
|
-
|
|
52
|
+
`inputDir` is resolved relative to the project root; `outputDir` is resolved relative to Vite's `build.outDir`.
|
|
50
53
|
|
|
51
54
|
## Subfolders
|
|
52
55
|
|
|
@@ -74,7 +77,7 @@ Each SVG becomes a `<symbol>` with `id="svg-{filename}"`. For example, `arrow.sv
|
|
|
74
77
|
```html
|
|
75
78
|
<symbol id="svg-arrow" viewBox="0 0 24 24">
|
|
76
79
|
<title>arrow icon</title>
|
|
77
|
-
|
|
80
|
+
<path d="…"/>
|
|
78
81
|
</symbol>
|
|
79
82
|
```
|
|
80
83
|
|
|
@@ -82,29 +85,56 @@ Each SVG becomes a `<symbol>` with `id="svg-{filename}"`. For example, `arrow.sv
|
|
|
82
85
|
|
|
83
86
|
### With Vite manifest (recommended)
|
|
84
87
|
|
|
85
|
-
With `build.manifest: true` enabled, each sprite is registered in `manifest.json`
|
|
88
|
+
With `build.manifest: true` enabled, each sprite is registered in `manifest.json` under a purely logical key — the root sprite is `{prefix}.svg`, and each subfolder sprite is `{prefix}-{folder}.svg`:
|
|
86
89
|
|
|
87
90
|
```json
|
|
88
91
|
{
|
|
89
|
-
"
|
|
90
|
-
"
|
|
92
|
+
"sprite.svg": { "file": "assets/sprite-9a3f2c1e.svg", "src": "sprite.svg", "isEntry": false },
|
|
93
|
+
"sprite-light.svg": { "file": "assets/sprite-light-4b8d70e5.svg", "src": "sprite-light.svg", "isEntry": false }
|
|
91
94
|
}
|
|
92
95
|
```
|
|
93
96
|
|
|
97
|
+
These keys don't correspond to real files on disk — they're the handle you pass to your manifest consumer.
|
|
98
|
+
|
|
94
99
|
With Craft CMS + [nystudio107/craft-vite](https://nystudio107.com/docs/vite/):
|
|
95
100
|
|
|
96
101
|
```twig
|
|
97
102
|
<svg aria-hidden="true">
|
|
98
|
-
<use href="{{ craft.vite.entry('
|
|
103
|
+
<use href="{{ craft.vite.entry('sprite.svg') }}#svg-arrow"></use>
|
|
99
104
|
</svg>
|
|
100
105
|
|
|
101
106
|
<svg aria-hidden="true">
|
|
102
|
-
<use href="{{ craft.vite.entry('
|
|
107
|
+
<use href="{{ craft.vite.entry('sprite-light.svg') }}#svg-sun"></use>
|
|
103
108
|
</svg>
|
|
104
109
|
```
|
|
105
110
|
|
|
106
111
|
`entry()` resolves the hashed URL from the manifest.
|
|
107
112
|
|
|
113
|
+
#### A reusable, accessible macro
|
|
114
|
+
|
|
115
|
+
Rather than hand-writing the `<svg><use>` wrapper each time, wrap it in a Twig macro. Pass `folder:` for a subfolder sprite — it maps to the `sprite-{folder}.svg` manifest key for you, so templates keep the folder mental model. Icons default to decorative (`aria-hidden="true"`), and switch to a labelled `role="img"` when the icon carries meaning on its own:
|
|
116
|
+
|
|
117
|
+
```twig
|
|
118
|
+
{% macro icon(name, folder = null, label = null, prefix = 'sprite', class = 'icon', extra = {}) %}
|
|
119
|
+
{% set key = folder ? "#{prefix}-#{folder}.svg" : "#{prefix}.svg" %}
|
|
120
|
+
{% set a11y = label
|
|
121
|
+
? { role: 'img', 'aria-label': label }
|
|
122
|
+
: { 'aria-hidden': 'true', focusable: 'false' } %}
|
|
123
|
+
<svg{{ attr({ class: class }|merge(a11y)|merge(extra)) }}>
|
|
124
|
+
<use href="{{ craft.vite.entry(key) }}#svg-{{ name }}"></use>
|
|
125
|
+
</svg>
|
|
126
|
+
{% endmacro %}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
```twig
|
|
130
|
+
{{ icon('arrow') }} {# root sprite → entry('sprite.svg') #}
|
|
131
|
+
{{ icon('sun', folder: 'light') }} {# subfolder → entry('sprite-light.svg') #}
|
|
132
|
+
{{ icon('search', label: 'Search') }} {# meaningful → role="img" + aria-label #}
|
|
133
|
+
<button aria-label="Close">{{ icon('x') }}</button> {# icon-only control: label the button #}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Reach for `aria-hidden="true"` only when the icon is decorative — i.e. sitting next to visible text. A standalone, meaning-bearing icon needs an accessible name (via `label`, or on the enclosing control such as a button), or it's invisible to assistive tech. (`attr()` is Craft's built-in attribute renderer.)
|
|
137
|
+
|
|
108
138
|
## Development workflow
|
|
109
139
|
|
|
110
140
|
Sprites are generated at build time only — the plugin does **not** run during `vite dev`. For a live-ish dev loop, run a separate build watcher alongside your usual dev server:
|
|
@@ -113,11 +143,11 @@ Sprites are generated at build time only — the plugin does **not** run during
|
|
|
113
143
|
vite build --watch
|
|
114
144
|
```
|
|
115
145
|
|
|
116
|
-
Every edit under `inputDir` triggers a rebuild (a second or two), updates `manifest.json` with the new content hash, and `craft.vite.entry('
|
|
146
|
+
Every edit under `inputDir` triggers a rebuild (a second or two), updates `manifest.json` with the new content hash, and `craft.vite.entry('sprite.svg')` picks up the new URL on the next request. Not instant HMR, but avoids the CORS / cross-origin complications of trying to serve sprites through a separate dev port.
|
|
117
147
|
|
|
118
148
|
### Without manifest
|
|
119
149
|
|
|
120
|
-
You can reference
|
|
150
|
+
You can reference a sprite file directly without a manifest, but its filename is always content-hashed — there's no option to turn hashing off — so you'd have to discover the emitted name yourself (e.g. by globbing `assets/sprite-*.svg`). The manifest-based flow is strongly recommended.
|
|
121
151
|
|
|
122
152
|
## Migrating from v1
|
|
123
153
|
|
|
@@ -140,6 +170,32 @@ svgSprite({
|
|
|
140
170
|
|
|
141
171
|
Passing `outputFile` in v2 throws an error. Update your templates to read the sprite URL from Vite's manifest instead of hard-coding the path.
|
|
142
172
|
|
|
173
|
+
## How this differs from other SVG sprite plugins
|
|
174
|
+
|
|
175
|
+
Several Vite plugins generate SVG sprites; they mostly split by *how the sprite reaches the page*:
|
|
176
|
+
|
|
177
|
+
- **Runtime injection** (e.g. [`vite-plugin-svg-icons`](https://github.com/vbenjs/vite-plugin-svg-icons)) injects the sprite into the DOM via a virtual import. Great for SPAs; needs JavaScript to run.
|
|
178
|
+
- **Import-based** plugins expose each icon as a JS module or framework component. Great when your icons live in JS/JSX.
|
|
179
|
+
- **This plugin is build-time and manifest-driven.** It emits content-hashed `.svg` files and registers them in Vite's manifest under stable logical keys, so a **server-rendered** template (Craft/Twig, Laravel/Blade, etc.) resolves the hashed URL with no JavaScript — e.g. `craft.vite.entry('sprite.svg')`. It also emits one sprite per subfolder.
|
|
180
|
+
|
|
181
|
+
If you want runtime/HMR injection or a virtual import, one of the others will suit you better. If you render HTML on the server and resolve assets through a Vite manifest, this is built for that.
|
|
182
|
+
|
|
183
|
+
## FAQ
|
|
184
|
+
|
|
185
|
+
**Does it work with Craft CMS?** Yes — it's the primary target. With [nystudio107's Craft Vite plugin](https://nystudio107.com/docs/vite/), reference a sprite with `craft.vite.entry('sprite.svg')` (see [Using the sprite](#using-the-sprite)).
|
|
186
|
+
|
|
187
|
+
**How do I reference an icon in a Twig template?** Use `<use href="{{ craft.vite.entry('sprite.svg') }}#svg-{name}">`, or the reusable [`icon()` macro](#a-reusable-accessible-macro).
|
|
188
|
+
|
|
189
|
+
**Can I have more than one sprite?** Yes — each subfolder of `inputDir` becomes its own sprite, keyed `sprite-{folder}.svg`. See [Subfolders](#subfolders).
|
|
190
|
+
|
|
191
|
+
**Can two icons share a filename across folders?** Yes. `arrow.svg` and `brand/arrow.svg` both become `<symbol id="svg-arrow">`, but in *separate* sprite files — and you reference each by its own sprite URL (`entry('sprite.svg')#svg-arrow` vs `entry('sprite-brand.svg')#svg-arrow`), so the file URL disambiguates them and they render fine on the same page. The ids only repeat *across* files, never within one; the only thing to avoid is inlining multiple sprites into a single document and referencing by bare `#svg-arrow`.
|
|
192
|
+
|
|
193
|
+
**Does it run during `vite dev` / support HMR?** No — it's build-only. Run `vite build --watch` alongside your dev server for a live-ish loop.
|
|
194
|
+
|
|
195
|
+
**Does it optimise SVGs?** No — it does light cleanup (strips the XML prolog / comments / empty `<defs>`, collapses whitespace, namespaces IDs), but it doesn't touch path data, so bring source SVGs that are already production-ready. Each icon needs a `viewBox` — the plugin strips `width`/`height` and scales from it.
|
|
196
|
+
|
|
197
|
+
**Why are the manifest keys `sprite.svg` and `sprite-light.svg`, not paths?** Because nystudio107's `entry()` resolves keys with a substring match, and a path-style `light/sprite.svg` would collide with the root `sprite.svg`. Basename keys avoid that.
|
|
198
|
+
|
|
143
199
|
## License
|
|
144
200
|
|
|
145
201
|
MIT © [One Darnley Road](https://onedarnleyroad.com)
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onedarnleyroad/vite-plugin-svg-sprite",
|
|
3
|
-
"version": "2.0.0
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Vite plugin that builds an SVG sprite sheet from a directory of SVG files",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.js"
|
|
8
8
|
},
|
|
9
|
+
"scripts": {},
|
|
9
10
|
"files": [
|
|
10
11
|
"src"
|
|
11
12
|
],
|
|
@@ -14,10 +15,24 @@
|
|
|
14
15
|
"vite-plugin",
|
|
15
16
|
"svg",
|
|
16
17
|
"sprite",
|
|
17
|
-
"svg-sprite"
|
|
18
|
+
"svg-sprite",
|
|
19
|
+
"svg-symbols",
|
|
20
|
+
"icons",
|
|
21
|
+
"manifest",
|
|
22
|
+
"craft-cms",
|
|
23
|
+
"nystudio107"
|
|
18
24
|
],
|
|
19
25
|
"author": "One Darnley Road <hello@onedarnleyroad.com>",
|
|
20
26
|
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/onedarnleyroad/vite-plugin-svg-sprite.git"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/onedarnleyroad/vite-plugin-svg-sprite#readme",
|
|
32
|
+
"bugs": "https://github.com/onedarnleyroad/vite-plugin-svg-sprite/issues",
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
21
36
|
"peerDependencies": {
|
|
22
37
|
"vite": ">=4.0.0"
|
|
23
38
|
}
|
package/src/index.js
CHANGED
|
@@ -9,13 +9,13 @@ function hashContent(content) {
|
|
|
9
9
|
const STRIP_ATTRS = new Set(['xmlns', 'version', 'width', 'height', 'id'])
|
|
10
10
|
|
|
11
11
|
function extractOuterAttrs(openTag) {
|
|
12
|
-
const attrRe = /\s+([a-zA-Z_][a-zA-Z0-9_:-]*)=
|
|
12
|
+
const attrRe = /\s+([a-zA-Z_][a-zA-Z0-9_:-]*)=(["'])([\s\S]*?)\2/g
|
|
13
13
|
const kept = []
|
|
14
14
|
let m
|
|
15
15
|
while ((m = attrRe.exec(openTag)) !== null) {
|
|
16
|
-
const [, attr, value] = m
|
|
16
|
+
const [, attr, quote, value] = m
|
|
17
17
|
if (STRIP_ATTRS.has(attr) || attr.startsWith('xmlns:')) continue
|
|
18
|
-
kept.push(`${attr}
|
|
18
|
+
kept.push(`${attr}=${quote}${value}${quote}`)
|
|
19
19
|
}
|
|
20
20
|
return kept.join(' ')
|
|
21
21
|
}
|
|
@@ -25,15 +25,16 @@ function buildSymbol(svg, name) {
|
|
|
25
25
|
const title = existingTitle ?? `${name} icon`
|
|
26
26
|
|
|
27
27
|
const cleaned = svg
|
|
28
|
+
.replace(/<\?xml[\s\S]*?\?>/gi, '')
|
|
29
|
+
.replace(/<!DOCTYPE[^>]*>/gi, '')
|
|
28
30
|
.replace(/<!--[\s\S]*?-->/g, '')
|
|
29
31
|
.replace(/<title[^>]*>[\s\S]*?<\/title>/gi, '')
|
|
30
32
|
.replace(/<defs[^>]*>\s*<\/defs>/gi, '')
|
|
31
33
|
|
|
32
34
|
const namespaced = cleaned
|
|
33
|
-
.replace(
|
|
35
|
+
.replace(/(?<![\w-])id=(["'])([^"']+)\1/g, (_, q, v) => `id=${q}${name}-${v}${q}`)
|
|
34
36
|
.replace(/\burl\(#([^)]+)\)/g, `url(#${name}-$1)`)
|
|
35
|
-
.replace(/\
|
|
36
|
-
.replace(/\bxlink:href="#([^"]+)"/g, `xlink:href="#${name}-$1"`)
|
|
37
|
+
.replace(/\b(xlink:href|href)=(["'])#([^"']+)\2/g, (_, attr, q, v) => `${attr}=${q}#${name}-${v}${q}`)
|
|
37
38
|
|
|
38
39
|
const openTag = namespaced.match(/<svg\b[^>]*>/i)?.[0] ?? ''
|
|
39
40
|
const outerAttrs = extractOuterAttrs(openTag)
|
|
@@ -68,7 +69,11 @@ function collectSpriteGroups(inputDir, prefix) {
|
|
|
68
69
|
.filter(e => e.isFile() && e.name.toLowerCase().endsWith('.svg'))
|
|
69
70
|
.map(e => e.name)
|
|
70
71
|
if (rootFiles.length > 0) {
|
|
71
|
-
groups.push({
|
|
72
|
+
groups.push({
|
|
73
|
+
fileBase: prefix,
|
|
74
|
+
dir: absInput,
|
|
75
|
+
files: rootFiles,
|
|
76
|
+
})
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
for (const entry of entries) {
|
|
@@ -78,7 +83,11 @@ function collectSpriteGroups(inputDir, prefix) {
|
|
|
78
83
|
.filter(e => e.isFile() && e.name.toLowerCase().endsWith('.svg'))
|
|
79
84
|
.map(e => e.name)
|
|
80
85
|
if (subFiles.length === 0) continue
|
|
81
|
-
groups.push({
|
|
86
|
+
groups.push({
|
|
87
|
+
fileBase: `${prefix}-${entry.name}`,
|
|
88
|
+
dir: subDir,
|
|
89
|
+
files: subFiles,
|
|
90
|
+
})
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
return groups
|
|
@@ -111,11 +120,22 @@ export default function svgSpritePlugin(options = {}) {
|
|
|
111
120
|
emitted.length = 0
|
|
112
121
|
for (const group of collectSpriteGroups(inputDir, prefix)) {
|
|
113
122
|
const source = buildSpriteSvg(group.dir, group.files)
|
|
114
|
-
const
|
|
115
|
-
const hashedFileName = join(outputDir, `${base}-${hashContent(source)}.svg`)
|
|
116
|
-
const key = join(inputDir, group.logicalName)
|
|
123
|
+
const hashedFileName = join(outputDir, `${group.fileBase}-${hashContent(source)}.svg`)
|
|
117
124
|
this.emitFile({ type: 'asset', fileName: hashedFileName, source })
|
|
118
|
-
emitted.push({ key
|
|
125
|
+
emitted.push({ key: `${group.fileBase}.svg`, fileName: hashedFileName })
|
|
126
|
+
}
|
|
127
|
+
// craft-vite resolves manifest keys with a first-hit substring test: if one key is a
|
|
128
|
+
// substring of another, entry() for the shorter key resolves ambiguously. Basename keys
|
|
129
|
+
// (sprite.svg, sprite-detail.svg) avoid this; warn if a prefix/folder name reintroduces it.
|
|
130
|
+
for (const a of emitted) {
|
|
131
|
+
for (const b of emitted) {
|
|
132
|
+
if (a !== b && b.key.includes(a.key)) {
|
|
133
|
+
this.warn(
|
|
134
|
+
`[vite-plugin-svg-sprite] manifest key "${a.key}" is a substring of "${b.key}"; ` +
|
|
135
|
+
`craft.vite.entry('${a.key}') may resolve ambiguously. Rename the prefix or folder to disambiguate.`
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
119
139
|
}
|
|
120
140
|
},
|
|
121
141
|
|