@onedarnleyroad/vite-plugin-svg-sprite 2.0.0-beta.6 → 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 +39 -12
- package/package.json +5 -1
- package/src/index.js +32 -12
package/README.md
CHANGED
|
@@ -5,13 +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
|
|
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
|
|
15
15
|
|
|
16
16
|
## Installation
|
|
17
17
|
|
|
@@ -46,7 +46,7 @@ export default defineConfig({
|
|
|
46
46
|
| `outputDir` | `string` | `'assets'` | Output directory, relative to Vite's `build.outDir`. |
|
|
47
47
|
| `prefix` | `string` | `'sprite'` | Filename prefix. The dash between prefix and folder name is always added. |
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
`inputDir` is resolved relative to the project root; `outputDir` is resolved relative to Vite's `build.outDir`.
|
|
50
50
|
|
|
51
51
|
## Subfolders
|
|
52
52
|
|
|
@@ -74,7 +74,7 @@ Each SVG becomes a `<symbol>` with `id="svg-{filename}"`. For example, `arrow.sv
|
|
|
74
74
|
```html
|
|
75
75
|
<symbol id="svg-arrow" viewBox="0 0 24 24">
|
|
76
76
|
<title>arrow icon</title>
|
|
77
|
-
|
|
77
|
+
<path d="…"/>
|
|
78
78
|
</symbol>
|
|
79
79
|
```
|
|
80
80
|
|
|
@@ -82,29 +82,56 @@ Each SVG becomes a `<symbol>` with `id="svg-{filename}"`. For example, `arrow.sv
|
|
|
82
82
|
|
|
83
83
|
### With Vite manifest (recommended)
|
|
84
84
|
|
|
85
|
-
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`:
|
|
86
86
|
|
|
87
87
|
```json
|
|
88
88
|
{
|
|
89
|
-
"
|
|
90
|
-
"
|
|
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 }
|
|
91
91
|
}
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
+
These keys don't correspond to real files on disk — they're the handle you pass to your manifest consumer.
|
|
95
|
+
|
|
94
96
|
With Craft CMS + [nystudio107/craft-vite](https://nystudio107.com/docs/vite/):
|
|
95
97
|
|
|
96
98
|
```twig
|
|
97
99
|
<svg aria-hidden="true">
|
|
98
|
-
<use href="{{ craft.vite.entry('
|
|
100
|
+
<use href="{{ craft.vite.entry('sprite.svg') }}#svg-arrow"></use>
|
|
99
101
|
</svg>
|
|
100
102
|
|
|
101
103
|
<svg aria-hidden="true">
|
|
102
|
-
<use href="{{ craft.vite.entry('
|
|
104
|
+
<use href="{{ craft.vite.entry('sprite-light.svg') }}#svg-sun"></use>
|
|
103
105
|
</svg>
|
|
104
106
|
```
|
|
105
107
|
|
|
106
108
|
`entry()` resolves the hashed URL from the manifest.
|
|
107
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
|
+
|
|
108
135
|
## Development workflow
|
|
109
136
|
|
|
110
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:
|
|
@@ -113,7 +140,7 @@ Sprites are generated at build time only — the plugin does **not** run during
|
|
|
113
140
|
vite build --watch
|
|
114
141
|
```
|
|
115
142
|
|
|
116
|
-
Every edit under `inputDir` triggers a rebuild (a second or two), updates `manifest.json` with the new content hash, and `craft.vite.entry('
|
|
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.
|
|
117
144
|
|
|
118
145
|
### Without manifest
|
|
119
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
|
|
@@ -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
|
|