@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.
Files changed (3) hide show
  1. package/README.md +50 -14
  2. package/package.json +5 -1
  3. 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-DAskUDYW.svg`) so browsers never serve stale sprites
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`, `version`, `width`, `height`, and `id` (we set our own).
13
- - Preserves existing `<title>` elements, or falls back to the filename
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
- All paths are resolved relative to the project root.
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` keyed by its `inputDir`-relative path:
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
- "src/icons/sprite.svg": { "file": "assets/sprite-DAskUDYW.svg", "src": "src/icons/sprite.svg", "isEntry": false },
91
- "src/icons/sprite-light.svg": { "file": "assets/sprite-light-xV7q1sYy.svg", "src": "src/icons/sprite-light.svg", "isEntry": false }
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
- 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:
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.asset('src/icons/sprite.svg') }}#svg-arrow"></use>
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.asset('src/icons/sprite-light.svg') }}#svg-sun"></use>
104
+ <use href="{{ craft.vite.entry('sprite-light.svg') }}#svg-sun"></use>
104
105
  </svg>
105
106
  ```
106
107
 
107
- 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.
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.5",
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_:-]*)="([^"]*)"/g
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}="${value}"`)
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(/\bid="([^"]+)"/g, `id="${name}-$1"`)
35
+ .replace(/(?<![\w-])id=(["'])([^"']+)\1/g, (_, q, v) => `id=${q}${name}-${v}${q}`)
34
36
  .replace(/\burl\(#([^)]+)\)/g, `url(#${name}-$1)`)
35
- .replace(/\bhref="#([^"]+)"/g, `href="#${name}-$1"`)
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({ logicalName: `${prefix}.svg`, dir: absInput, files: rootFiles })
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({ logicalName: `${prefix}-${entry.name}.svg`, dir: subDir, files: subFiles })
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 base = group.logicalName.replace(/\.svg$/, '')
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, fileName: hashedFileName })
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
  }