@onedarnleyroad/vite-plugin-svg-sprite 1.0.0 → 2.0.0-beta.1
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 +82 -13
- package/package.json +1 -1
- package/src/index.js +153 -50
package/README.md
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
# @onedarnleyroad/vite-plugin-svg-sprite
|
|
2
2
|
|
|
3
|
-
A Vite plugin that builds
|
|
3
|
+
A Vite plugin that builds SVG sprite sheets from a directory of SVG files — with content-hashed filenames and full Vite manifest integration.
|
|
4
4
|
|
|
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-DAskUDYW.svg`) so browsers never serve stale sprites
|
|
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
|
+
- **Multiple sprites from subfolders** — organise icons into `light/`, `dark/`, etc. and get one sprite per folder
|
|
8
11
|
- Namespaces internal IDs to prevent conflicts across icons
|
|
9
12
|
- Preserves existing `<title>` elements, or falls back to the filename
|
|
10
13
|
- Strips comments and empty `<defs>` blocks
|
|
11
|
-
-
|
|
14
|
+
- Dev server serves sprites from memory with full-reload on change
|
|
12
15
|
|
|
13
16
|
## Installation
|
|
14
17
|
|
|
@@ -24,10 +27,12 @@ import { defineConfig } from 'vite'
|
|
|
24
27
|
import svgSprite from '@onedarnleyroad/vite-plugin-svg-sprite'
|
|
25
28
|
|
|
26
29
|
export default defineConfig({
|
|
30
|
+
build: {
|
|
31
|
+
manifest: true,
|
|
32
|
+
},
|
|
27
33
|
plugins: [
|
|
28
34
|
svgSprite({
|
|
29
35
|
inputDir: 'src/icons',
|
|
30
|
-
outputFile: 'web/dist/sprite.svg',
|
|
31
36
|
}),
|
|
32
37
|
],
|
|
33
38
|
})
|
|
@@ -35,16 +40,36 @@ export default defineConfig({
|
|
|
35
40
|
|
|
36
41
|
### Options
|
|
37
42
|
|
|
38
|
-
| Option
|
|
39
|
-
|
|
40
|
-
| `inputDir`
|
|
41
|
-
| `
|
|
43
|
+
| Option | Type | Default | Description |
|
|
44
|
+
|-------------|----------|------------|-----------------------------------------------------------------------------|
|
|
45
|
+
| `inputDir` | `string` | _required_ | Directory containing the source `.svg` files. |
|
|
46
|
+
| `outputDir` | `string` | `'assets'` | Output directory, relative to Vite's `build.outDir`. |
|
|
47
|
+
| `prefix` | `string` | `'sprite'` | Filename prefix. The dash between prefix and folder name is always added. |
|
|
42
48
|
|
|
43
|
-
|
|
49
|
+
All paths are resolved relative to the project root.
|
|
44
50
|
|
|
45
|
-
##
|
|
51
|
+
## Subfolders
|
|
46
52
|
|
|
47
|
-
|
|
53
|
+
Organise icons into subfolders to produce one sprite per folder. Loose `.svg` files at the root of `inputDir` go into the default sprite.
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
src/icons/
|
|
57
|
+
├── arrow.svg ─┐
|
|
58
|
+
├── close.svg ├─→ sprite.svg → dist/assets/sprite-{HASH}.svg
|
|
59
|
+
├── menu.svg ─┘
|
|
60
|
+
├── light/
|
|
61
|
+
│ ├── sun.svg ─┐
|
|
62
|
+
│ └── star.svg ─┴─→ sprite-light.svg → dist/assets/sprite-light-{HASH}.svg
|
|
63
|
+
└── dark/
|
|
64
|
+
├── moon.svg ─┐
|
|
65
|
+
└── cloud.svg ─┴─→ sprite-dark.svg → dist/assets/sprite-dark-{HASH}.svg
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Only the first level of subfolders is scanned. Empty subfolders are skipped.
|
|
69
|
+
|
|
70
|
+
## Output symbol IDs
|
|
71
|
+
|
|
72
|
+
Each SVG becomes a `<symbol>` with `id="svg-{filename}"`. For example, `arrow.svg` becomes:
|
|
48
73
|
|
|
49
74
|
```html
|
|
50
75
|
<symbol id="svg-arrow" viewBox="0 0 24 24">
|
|
@@ -53,14 +78,58 @@ Each SVG becomes a `<symbol>` with an `id` prefixed with `svg-`. For example, `a
|
|
|
53
78
|
</symbol>
|
|
54
79
|
```
|
|
55
80
|
|
|
56
|
-
|
|
81
|
+
## Using the sprite
|
|
57
82
|
|
|
58
|
-
|
|
83
|
+
### With Vite manifest (recommended)
|
|
84
|
+
|
|
85
|
+
With `build.manifest: true` enabled, each sprite is registered in `manifest.json` keyed by its `inputDir`-relative path:
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"src/icons/sprite.svg": { "file": "assets/sprite-DAskUDYW.svg", "src": "src/icons/sprite.svg", "isEntry": false },
|
|
90
|
+
"src/icons/sprite-light.svg": { "file": "assets/sprite-light-xV7q1sYy.svg", "src": "src/icons/sprite-light.svg", "isEntry": false }
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
With Craft CMS + [nystudio107/craft-vite](https://nystudio107.com/docs/vite/), use `craft.vite.asset()` — **not `entry()`**. The `asset()` helper is dev-server-aware; `entry()` always reads the manifest, so in dev it would serve stale built files:
|
|
95
|
+
|
|
96
|
+
```twig
|
|
59
97
|
<svg aria-hidden="true">
|
|
60
|
-
<use href="/
|
|
98
|
+
<use href="{{ craft.vite.asset('src/icons/sprite.svg') }}#svg-arrow"></use>
|
|
99
|
+
</svg>
|
|
100
|
+
|
|
101
|
+
<svg aria-hidden="true">
|
|
102
|
+
<use href="{{ craft.vite.asset('src/icons/sprite-light.svg') }}#svg-sun"></use>
|
|
61
103
|
</svg>
|
|
62
104
|
```
|
|
63
105
|
|
|
106
|
+
In dev, `asset()` returns the Vite dev-server URL (e.g. `https://…:3000/src/icons/sprite.svg`), which the plugin's in-memory middleware serves. In production it returns the hashed URL from the manifest.
|
|
107
|
+
|
|
108
|
+
### Without manifest
|
|
109
|
+
|
|
110
|
+
You can reference the sprite directly if you don't use a manifest — but the filename will be hashed, so you'd need to read the hash yourself or disable hashing manually. The manifest-based flow is strongly recommended.
|
|
111
|
+
|
|
112
|
+
## Migrating from v1
|
|
113
|
+
|
|
114
|
+
v2 is a breaking change. The API moved from "write a single file to an exact path" to "emit hashed assets that Vite owns".
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
// v1
|
|
118
|
+
svgSprite({
|
|
119
|
+
inputDir: 'src/icons',
|
|
120
|
+
outputFile: 'web/dist/sprite.svg',
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// v2
|
|
124
|
+
svgSprite({
|
|
125
|
+
inputDir: 'src/icons',
|
|
126
|
+
// outputDir defaults to 'assets' (inside Vite's build.outDir)
|
|
127
|
+
// prefix defaults to 'sprite'
|
|
128
|
+
})
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
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.
|
|
132
|
+
|
|
64
133
|
## License
|
|
65
134
|
|
|
66
135
|
MIT © [One Darnley Road](https://onedarnleyroad.com)
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -1,59 +1,162 @@
|
|
|
1
|
-
import { readdirSync, readFileSync, writeFileSync,
|
|
2
|
-
import { resolve, basename, extname,
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
1
|
+
import { readdirSync, readFileSync, writeFileSync, existsSync } from 'fs'
|
|
2
|
+
import { resolve, basename, extname, join } from 'path'
|
|
3
|
+
import { createHash } from 'crypto'
|
|
4
|
+
|
|
5
|
+
function hashContent(content) {
|
|
6
|
+
return createHash('sha256').update(content).digest('base64url').slice(0, 8)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function buildSymbol(svg, name) {
|
|
10
|
+
const existingTitle = svg.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.trim()
|
|
11
|
+
const title = existingTitle ?? `${name} icon`
|
|
12
|
+
|
|
13
|
+
const cleaned = svg
|
|
14
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
15
|
+
.replace(/<title[^>]*>[\s\S]*?<\/title>/gi, '')
|
|
16
|
+
.replace(/<defs[^>]*>\s*<\/defs>/gi, '')
|
|
17
|
+
|
|
18
|
+
const namespaced = cleaned
|
|
19
|
+
.replace(/\bid="([^"]+)"/g, `id="${name}-$1"`)
|
|
20
|
+
.replace(/\burl\(#([^)]+)\)/g, `url(#${name}-$1)`)
|
|
21
|
+
.replace(/\bhref="#([^"]+)"/g, `href="#${name}-$1"`)
|
|
22
|
+
.replace(/\bxlink:href="#([^"]+)"/g, `xlink:href="#${name}-$1"`)
|
|
23
|
+
|
|
24
|
+
const viewBox = namespaced.match(/viewBox="([^"]+)"/)?.[1] ?? '0 0 24 24'
|
|
25
|
+
|
|
26
|
+
const inner = namespaced
|
|
27
|
+
.replace(/<svg[^>]*>/i, '')
|
|
28
|
+
.replace(/<\/svg>/i, '')
|
|
29
|
+
.replace(/\s*\n\s*/g, ' ')
|
|
30
|
+
.replace(/>\s+</g, '><')
|
|
31
|
+
.trim()
|
|
32
|
+
|
|
33
|
+
return `<symbol id="svg-${name}" viewBox="${viewBox}"><title>${title}</title>${inner}</symbol>`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildSpriteSvg(dir, files) {
|
|
37
|
+
const symbols = files.sort().map(file => {
|
|
38
|
+
const name = basename(file, extname(file))
|
|
39
|
+
const svg = readFileSync(join(dir, file), 'utf-8')
|
|
40
|
+
return buildSymbol(svg, name)
|
|
41
|
+
})
|
|
42
|
+
return `<svg xmlns="http://www.w3.org/2000/svg">\n${symbols.join('\n')}\n</svg>`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function collectSpriteGroups(inputDir, prefix) {
|
|
46
|
+
const absInput = resolve(inputDir)
|
|
47
|
+
const entries = readdirSync(absInput, { withFileTypes: true })
|
|
48
|
+
|
|
49
|
+
const groups = []
|
|
50
|
+
|
|
51
|
+
const rootFiles = entries
|
|
52
|
+
.filter(e => e.isFile() && e.name.toLowerCase().endsWith('.svg'))
|
|
53
|
+
.map(e => e.name)
|
|
54
|
+
if (rootFiles.length > 0) {
|
|
55
|
+
groups.push({ logicalName: `${prefix}.svg`, dir: absInput, files: rootFiles })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
if (!entry.isDirectory()) continue
|
|
60
|
+
const subDir = join(absInput, entry.name)
|
|
61
|
+
const subFiles = readdirSync(subDir, { withFileTypes: true })
|
|
62
|
+
.filter(e => e.isFile() && e.name.toLowerCase().endsWith('.svg'))
|
|
63
|
+
.map(e => e.name)
|
|
64
|
+
if (subFiles.length === 0) continue
|
|
65
|
+
groups.push({ logicalName: `${prefix}-${entry.name}.svg`, dir: subDir, files: subFiles })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return groups
|
|
69
|
+
}
|
|
44
70
|
|
|
45
|
-
|
|
46
|
-
|
|
71
|
+
export default function svgSpritePlugin(options = {}) {
|
|
72
|
+
if ('outputFile' in options) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
'[vite-plugin-svg-sprite] `outputFile` was removed in v2. Use `outputDir` (relative to Vite\'s build.outDir) and `prefix` instead. See README for migration.'
|
|
75
|
+
)
|
|
47
76
|
}
|
|
48
77
|
|
|
78
|
+
const { inputDir, outputDir = 'assets', prefix = 'sprite' } = options
|
|
79
|
+
|
|
80
|
+
if (!inputDir) {
|
|
81
|
+
throw new Error('[vite-plugin-svg-sprite] `inputDir` is required.')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const absInputDir = resolve(inputDir)
|
|
85
|
+
const emitted = []
|
|
86
|
+
let viteConfig
|
|
87
|
+
|
|
49
88
|
return {
|
|
50
89
|
name: 'svg-sprite',
|
|
51
|
-
|
|
52
|
-
|
|
90
|
+
|
|
91
|
+
configResolved(config) {
|
|
92
|
+
viteConfig = config
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
generateBundle() {
|
|
96
|
+
emitted.length = 0
|
|
97
|
+
for (const group of collectSpriteGroups(inputDir, prefix)) {
|
|
98
|
+
const source = buildSpriteSvg(group.dir, group.files)
|
|
99
|
+
const base = group.logicalName.replace(/\.svg$/, '')
|
|
100
|
+
const hashedFileName = join(outputDir, `${base}-${hashContent(source)}.svg`)
|
|
101
|
+
const key = join(inputDir, group.logicalName)
|
|
102
|
+
this.emitFile({ type: 'asset', fileName: hashedFileName, source })
|
|
103
|
+
emitted.push({ key, fileName: hashedFileName })
|
|
104
|
+
}
|
|
53
105
|
},
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (
|
|
106
|
+
|
|
107
|
+
writeBundle(outputOptions) {
|
|
108
|
+
if (!viteConfig?.build?.manifest) return
|
|
109
|
+
|
|
110
|
+
const manifestName = typeof viteConfig.build.manifest === 'string'
|
|
111
|
+
? viteConfig.build.manifest
|
|
112
|
+
: '.vite/manifest.json'
|
|
113
|
+
const manifestPath = resolve(outputOptions.dir, manifestName)
|
|
114
|
+
if (!existsSync(manifestPath)) return
|
|
115
|
+
|
|
116
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'))
|
|
117
|
+
|
|
118
|
+
for (const { key, fileName } of emitted) {
|
|
119
|
+
manifest[key] = {
|
|
120
|
+
file: fileName,
|
|
121
|
+
src: key,
|
|
122
|
+
isEntry: false,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
configureServer(server) {
|
|
130
|
+
const sprites = new Map()
|
|
131
|
+
|
|
132
|
+
const rebuild = () => {
|
|
133
|
+
sprites.clear()
|
|
134
|
+
for (const group of collectSpriteGroups(inputDir, prefix)) {
|
|
135
|
+
const key = join(inputDir, group.logicalName)
|
|
136
|
+
sprites.set(key, buildSpriteSvg(group.dir, group.files))
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
rebuild()
|
|
140
|
+
|
|
141
|
+
server.middlewares.use((req, res, next) => {
|
|
142
|
+
const path = req.url?.split('?')[0]?.replace(/^\//, '')
|
|
143
|
+
if (!path) return next()
|
|
144
|
+
const match = sprites.get(path)
|
|
145
|
+
if (!match) return next()
|
|
146
|
+
res.setHeader('Content-Type', 'image/svg+xml')
|
|
147
|
+
res.setHeader('Cache-Control', 'no-cache')
|
|
148
|
+
res.end(match)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
server.watcher.add(absInputDir)
|
|
152
|
+
const onChange = file => {
|
|
153
|
+
if (!file.startsWith(absInputDir)) return
|
|
154
|
+
rebuild()
|
|
155
|
+
server.ws.send({ type: 'full-reload' })
|
|
156
|
+
}
|
|
157
|
+
server.watcher.on('change', onChange)
|
|
158
|
+
server.watcher.on('add', onChange)
|
|
159
|
+
server.watcher.on('unlink', onChange)
|
|
57
160
|
},
|
|
58
161
|
}
|
|
59
162
|
}
|