@onedarnleyroad/vite-plugin-svg-sprite 2.0.0-beta.5 → 2.0.0-beta.8
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 +50 -14
- package/package.json +5 -1
- package/src/index.js +32 -48
package/README.md
CHANGED
|
@@ -5,14 +5,13 @@ A Vite plugin that builds SVG sprite sheets from a directory of SVG files — wi
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- Combines individual SVGs into a single `<svg>` sprite with `<symbol>` elements
|
|
8
|
-
- **Content-hashed output** (e.g. `sprite-
|
|
8
|
+
- **Content-hashed output** (e.g. `sprite-9a3f2c1e.svg`) so browsers never serve stale sprites
|
|
9
9
|
- **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
10
|
- **Multiple sprites from subfolders** — organise icons into `light/`, `dark/`, etc. and get one sprite per folder
|
|
11
11
|
- 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
|
-
- Dev server serves sprites from memory with full-reload on change
|
|
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` (and `xmlns:*` namespace declarations such as `xmlns:xlink`), `version`, `width`, `height`, and `id` (we set our own).
|
|
13
|
+
- Preserves an existing `<title>`, or synthesizes one from the filename (`arrow.svg` → `arrow icon`)
|
|
14
|
+
- Strips comments, XML prolog / DOCTYPE declarations, and empty `<defs>` blocks
|
|
16
15
|
|
|
17
16
|
## Installation
|
|
18
17
|
|
|
@@ -47,7 +46,7 @@ export default defineConfig({
|
|
|
47
46
|
| `outputDir` | `string` | `'assets'` | Output directory, relative to Vite's `build.outDir`. |
|
|
48
47
|
| `prefix` | `string` | `'sprite'` | Filename prefix. The dash between prefix and folder name is always added. |
|
|
49
48
|
|
|
50
|
-
|
|
49
|
+
`inputDir` is resolved relative to the project root; `outputDir` is resolved relative to Vite's `build.outDir`.
|
|
51
50
|
|
|
52
51
|
## Subfolders
|
|
53
52
|
|
|
@@ -75,7 +74,7 @@ Each SVG becomes a `<symbol>` with `id="svg-{filename}"`. For example, `arrow.sv
|
|
|
75
74
|
```html
|
|
76
75
|
<symbol id="svg-arrow" viewBox="0 0 24 24">
|
|
77
76
|
<title>arrow icon</title>
|
|
78
|
-
|
|
77
|
+
<path d="…"/>
|
|
79
78
|
</symbol>
|
|
80
79
|
```
|
|
81
80
|
|
|
@@ -83,28 +82,65 @@ Each SVG becomes a `<symbol>` with `id="svg-{filename}"`. For example, `arrow.sv
|
|
|
83
82
|
|
|
84
83
|
### With Vite manifest (recommended)
|
|
85
84
|
|
|
86
|
-
With `build.manifest: true` enabled, each sprite is registered in `manifest.json`
|
|
85
|
+
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`:
|
|
87
86
|
|
|
88
87
|
```json
|
|
89
88
|
{
|
|
90
|
-
"
|
|
91
|
-
"
|
|
89
|
+
"sprite.svg": { "file": "assets/sprite-9a3f2c1e.svg", "src": "sprite.svg", "isEntry": false },
|
|
90
|
+
"sprite-light.svg": { "file": "assets/sprite-light-4b8d70e5.svg", "src": "sprite-light.svg", "isEntry": false }
|
|
92
91
|
}
|
|
93
92
|
```
|
|
94
93
|
|
|
95
|
-
|
|
94
|
+
These keys don't correspond to real files on disk — they're the handle you pass to your manifest consumer.
|
|
95
|
+
|
|
96
|
+
With Craft CMS + [nystudio107/craft-vite](https://nystudio107.com/docs/vite/):
|
|
96
97
|
|
|
97
98
|
```twig
|
|
98
99
|
<svg aria-hidden="true">
|
|
99
|
-
<use href="{{ craft.vite.
|
|
100
|
+
<use href="{{ craft.vite.entry('sprite.svg') }}#svg-arrow"></use>
|
|
100
101
|
</svg>
|
|
101
102
|
|
|
102
103
|
<svg aria-hidden="true">
|
|
103
|
-
<use href="{{ craft.vite.
|
|
104
|
+
<use href="{{ craft.vite.entry('sprite-light.svg') }}#svg-sun"></use>
|
|
104
105
|
</svg>
|
|
105
106
|
```
|
|
106
107
|
|
|
107
|
-
|
|
108
|
+
`entry()` resolves the hashed URL from the manifest.
|
|
109
|
+
|
|
110
|
+
#### A reusable, accessible macro
|
|
111
|
+
|
|
112
|
+
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:
|
|
113
|
+
|
|
114
|
+
```twig
|
|
115
|
+
{% macro icon(name, folder = null, label = null, prefix = 'sprite', class = 'icon', extra = {}) %}
|
|
116
|
+
{% set key = folder ? "#{prefix}-#{folder}.svg" : "#{prefix}.svg" %}
|
|
117
|
+
{% set a11y = label
|
|
118
|
+
? { role: 'img', 'aria-label': label }
|
|
119
|
+
: { 'aria-hidden': 'true', focusable: 'false' } %}
|
|
120
|
+
<svg{{ attr({ class: class }|merge(a11y)|merge(extra)) }}>
|
|
121
|
+
<use href="{{ craft.vite.entry(key) }}#svg-{{ name }}"></use>
|
|
122
|
+
</svg>
|
|
123
|
+
{% endmacro %}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
```twig
|
|
127
|
+
{{ icon('arrow') }} {# root sprite → entry('sprite.svg') #}
|
|
128
|
+
{{ icon('sun', folder: 'light') }} {# subfolder → entry('sprite-light.svg') #}
|
|
129
|
+
{{ icon('search', label: 'Search') }} {# meaningful → role="img" + aria-label #}
|
|
130
|
+
<button aria-label="Close">{{ icon('x') }}</button> {# icon-only control: label the button #}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
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.)
|
|
134
|
+
|
|
135
|
+
## Development workflow
|
|
136
|
+
|
|
137
|
+
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:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
vite build --watch
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
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.
|
|
108
144
|
|
|
109
145
|
### Without manifest
|
|
110
146
|
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onedarnleyroad/vite-plugin-svg-sprite",
|
|
3
|
-
"version": "2.0.0-beta.
|
|
3
|
+
"version": "2.0.0-beta.8",
|
|
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
|
],
|
|
@@ -18,6 +19,9 @@
|
|
|
18
19
|
],
|
|
19
20
|
"author": "One Darnley Road <hello@onedarnleyroad.com>",
|
|
20
21
|
"license": "MIT",
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
21
25
|
"peerDependencies": {
|
|
22
26
|
"vite": ">=4.0.0"
|
|
23
27
|
}
|
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
|
|
@@ -97,7 +106,6 @@ export default function svgSpritePlugin(options = {}) {
|
|
|
97
106
|
throw new Error('[vite-plugin-svg-sprite] `inputDir` is required.')
|
|
98
107
|
}
|
|
99
108
|
|
|
100
|
-
const absInputDir = resolve(inputDir)
|
|
101
109
|
const emitted = []
|
|
102
110
|
let viteConfig
|
|
103
111
|
|
|
@@ -112,11 +120,22 @@ export default function svgSpritePlugin(options = {}) {
|
|
|
112
120
|
emitted.length = 0
|
|
113
121
|
for (const group of collectSpriteGroups(inputDir, prefix)) {
|
|
114
122
|
const source = buildSpriteSvg(group.dir, group.files)
|
|
115
|
-
const
|
|
116
|
-
const hashedFileName = join(outputDir, `${base}-${hashContent(source)}.svg`)
|
|
117
|
-
const key = join(inputDir, group.logicalName)
|
|
123
|
+
const hashedFileName = join(outputDir, `${group.fileBase}-${hashContent(source)}.svg`)
|
|
118
124
|
this.emitFile({ type: 'asset', fileName: hashedFileName, source })
|
|
119
|
-
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
|
+
}
|
|
120
139
|
}
|
|
121
140
|
},
|
|
122
141
|
|
|
@@ -141,40 +160,5 @@ export default function svgSpritePlugin(options = {}) {
|
|
|
141
160
|
|
|
142
161
|
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
|
|
143
162
|
},
|
|
144
|
-
|
|
145
|
-
configureServer(server) {
|
|
146
|
-
const sprites = new Map()
|
|
147
|
-
|
|
148
|
-
const rebuild = () => {
|
|
149
|
-
sprites.clear()
|
|
150
|
-
for (const group of collectSpriteGroups(inputDir, prefix)) {
|
|
151
|
-
const key = join(inputDir, group.logicalName)
|
|
152
|
-
sprites.set(key, buildSpriteSvg(group.dir, group.files))
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
rebuild()
|
|
156
|
-
|
|
157
|
-
server.middlewares.use((req, res, next) => {
|
|
158
|
-
const path = req.url?.split('?')[0]?.replace(/^\//, '')
|
|
159
|
-
if (!path) return next()
|
|
160
|
-
const match = sprites.get(path)
|
|
161
|
-
if (!match) return next()
|
|
162
|
-
res.setHeader('Content-Type', 'image/svg+xml')
|
|
163
|
-
res.setHeader('Cache-Control', 'no-cache')
|
|
164
|
-
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
165
|
-
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin')
|
|
166
|
-
res.end(match)
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
server.watcher.add(absInputDir)
|
|
170
|
-
const onChange = file => {
|
|
171
|
-
if (!file.startsWith(absInputDir)) return
|
|
172
|
-
rebuild()
|
|
173
|
-
server.ws.send({ type: 'full-reload' })
|
|
174
|
-
}
|
|
175
|
-
server.watcher.on('change', onChange)
|
|
176
|
-
server.watcher.on('add', onChange)
|
|
177
|
-
server.watcher.on('unlink', onChange)
|
|
178
|
-
},
|
|
179
163
|
}
|
|
180
164
|
}
|