@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.
Files changed (3) hide show
  1. package/README.md +82 -13
  2. package/package.json +1 -1
  3. 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 an SVG sprite sheet from a directory of SVG files.
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
- - Rebuilds automatically in watch mode (HMR-aware)
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 | Type | Description |
39
- |--------|------|-------------|
40
- | `inputDir` | `string` | Directory containing the source `.svg` files |
41
- | `outputFile` | `string` | Path for the generated sprite sheet |
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
- Both paths are resolved relative to the project root (where `vite.config.js` lives).
49
+ All paths are resolved relative to the project root.
44
50
 
45
- ## Output
51
+ ## Subfolders
46
52
 
47
- Each SVG becomes a `<symbol>` with an `id` prefixed with `svg-`. For example, `arrow.svg` becomes:
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
- Use it in your HTML with `<use>`:
81
+ ## Using the sprite
57
82
 
58
- ```html
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="/dist/sprite.svg#svg-arrow" />
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onedarnleyroad/vite-plugin-svg-sprite",
3
- "version": "1.0.0",
3
+ "version": "2.0.0-beta.1",
4
4
  "description": "Vite plugin that builds an SVG sprite sheet from a directory of SVG files",
5
5
  "type": "module",
6
6
  "exports": {
package/src/index.js CHANGED
@@ -1,59 +1,162 @@
1
- import { readdirSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
2
- import { resolve, basename, extname, dirname } from 'path'
3
-
4
- export default function svgSpritePlugin({ inputDir, outputFile }) {
5
- const input = resolve(inputDir)
6
- const output = resolve(outputFile)
7
-
8
- function build() {
9
- const symbols = readdirSync(input)
10
- .filter(f => f.endsWith('.svg'))
11
- .sort()
12
- .map(file => {
13
- const name = basename(file, extname(file))
14
- const svg = readFileSync(resolve(input, file), 'utf-8')
15
-
16
- // Preserve existing <title> if present, otherwise use filename
17
- const existingTitle = svg.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.trim()
18
- const title = existingTitle ?? `${name} icon`
19
-
20
- // Strip comments, existing <title> tags, and empty <defs> blocks
21
- const cleaned = svg
22
- .replace(/<!--[\s\S]*?-->/g, '')
23
- .replace(/<title[^>]*>[\s\S]*?<\/title>/gi, '')
24
- .replace(/<defs[^>]*>\s*<\/defs>/gi, '')
25
-
26
- // Namespace internal IDs to avoid conflicts when sprites are merged
27
- const namespaced = cleaned
28
- .replace(/\bid="([^"]+)"/g, `id="${name}-$1"`)
29
- .replace(/\burl\(#([^)]+)\)/g, `url(#${name}-$1)`)
30
- .replace(/\bhref="#([^"]+)"/g, `href="#${name}-$1"`)
31
- .replace(/\bxlink:href="#([^"]+)"/g, `xlink:href="#${name}-$1"`)
32
-
33
- const viewBox = namespaced.match(/viewBox="([^"]+)"/)?.[1] ?? '0 0 24 24'
34
-
35
- const inner = namespaced
36
- .replace(/<svg[^>]*>/i, '')
37
- .replace(/<\/svg>/i, '')
38
- .replace(/\s*\n\s*/g, ' ')
39
- .replace(/>\s+</g, '><')
40
- .trim()
41
-
42
- return `<symbol id="svg-${name}" viewBox="${viewBox}"><title>${title}</title>${inner}</symbol>`
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
- mkdirSync(dirname(output), { recursive: true })
46
- writeFileSync(output, `<svg xmlns="http://www.w3.org/2000/svg">\n${symbols.join('\n')}\n</svg>`)
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
- buildStart() {
52
- if (this.meta.watchMode) build()
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
- writeBundle: build,
55
- handleHotUpdate({ file }) {
56
- if (file.startsWith(input)) build()
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
  }