@onedarnleyroad/vite-plugin-svg-sprite 2.0.0-beta.6 → 2.0.0

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 +70 -14
  2. package/package.json +17 -2
  3. package/src/index.js +32 -12
package/README.md CHANGED
@@ -1,17 +1,20 @@
1
1
  # @onedarnleyroad/vite-plugin-svg-sprite
2
2
 
3
- A Vite plugin that builds SVG sprite sheets from a directory of SVG files — with content-hashed filenames and full Vite manifest integration.
3
+ [![npm](https://img.shields.io/npm/v/@onedarnleyroad/vite-plugin-svg-sprite)](https://www.npmjs.com/package/@onedarnleyroad/vite-plugin-svg-sprite)
4
+ [![license](https://img.shields.io/npm/l/@onedarnleyroad/vite-plugin-svg-sprite)](./LICENSE)
5
+
6
+ A Vite plugin that builds **content-hashed SVG sprite sheets** at build time and registers them in **Vite's manifest** — built for server-rendered apps like **Craft CMS** (via [nystudio107's Vite plugin](https://nystudio107.com/docs/vite/)), and works with any manifest consumer. Organise icons into folders to get one sprite per folder, and reference them from templates with no JavaScript.
4
7
 
5
8
  ## Features
6
9
 
7
10
  - 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
11
+ - **Content-hashed output** (e.g. `sprite-9a3f2c1e.svg`) so browsers never serve stale sprites
9
12
  - **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
13
  - **Multiple sprites from subfolders** — organise icons into `light/`, `dark/`, etc. and get one sprite per folder
11
14
  - 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
+ - **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).
16
+ - Preserves an existing `<title>`, or synthesizes one from the filename (`arrow.svg` → `arrow icon`)
17
+ - Strips comments, XML prolog / DOCTYPE declarations, and empty `<defs>` blocks
15
18
 
16
19
  ## Installation
17
20
 
@@ -46,7 +49,7 @@ export default defineConfig({
46
49
  | `outputDir` | `string` | `'assets'` | Output directory, relative to Vite's `build.outDir`. |
47
50
  | `prefix` | `string` | `'sprite'` | Filename prefix. The dash between prefix and folder name is always added. |
48
51
 
49
- All paths are resolved relative to the project root.
52
+ `inputDir` is resolved relative to the project root; `outputDir` is resolved relative to Vite's `build.outDir`.
50
53
 
51
54
  ## Subfolders
52
55
 
@@ -74,7 +77,7 @@ Each SVG becomes a `<symbol>` with `id="svg-{filename}"`. For example, `arrow.sv
74
77
  ```html
75
78
  <symbol id="svg-arrow" viewBox="0 0 24 24">
76
79
  <title>arrow icon</title>
77
- <!-- ... -->
80
+ <path d="…"/>
78
81
  </symbol>
79
82
  ```
80
83
 
@@ -82,29 +85,56 @@ Each SVG becomes a `<symbol>` with `id="svg-{filename}"`. For example, `arrow.sv
82
85
 
83
86
  ### With Vite manifest (recommended)
84
87
 
85
- With `build.manifest: true` enabled, each sprite is registered in `manifest.json` keyed by its `inputDir`-relative path:
88
+ 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
89
 
87
90
  ```json
88
91
  {
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 }
92
+ "sprite.svg": { "file": "assets/sprite-9a3f2c1e.svg", "src": "sprite.svg", "isEntry": false },
93
+ "sprite-light.svg": { "file": "assets/sprite-light-4b8d70e5.svg", "src": "sprite-light.svg", "isEntry": false }
91
94
  }
92
95
  ```
93
96
 
97
+ These keys don't correspond to real files on disk — they're the handle you pass to your manifest consumer.
98
+
94
99
  With Craft CMS + [nystudio107/craft-vite](https://nystudio107.com/docs/vite/):
95
100
 
96
101
  ```twig
97
102
  <svg aria-hidden="true">
98
- <use href="{{ craft.vite.entry('src/icons/sprite.svg') }}#svg-arrow"></use>
103
+ <use href="{{ craft.vite.entry('sprite.svg') }}#svg-arrow"></use>
99
104
  </svg>
100
105
 
101
106
  <svg aria-hidden="true">
102
- <use href="{{ craft.vite.entry('src/icons/sprite-light.svg') }}#svg-sun"></use>
107
+ <use href="{{ craft.vite.entry('sprite-light.svg') }}#svg-sun"></use>
103
108
  </svg>
104
109
  ```
105
110
 
106
111
  `entry()` resolves the hashed URL from the manifest.
107
112
 
113
+ #### A reusable, accessible macro
114
+
115
+ 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:
116
+
117
+ ```twig
118
+ {% macro icon(name, folder = null, label = null, prefix = 'sprite', class = 'icon', extra = {}) %}
119
+ {% set key = folder ? "#{prefix}-#{folder}.svg" : "#{prefix}.svg" %}
120
+ {% set a11y = label
121
+ ? { role: 'img', 'aria-label': label }
122
+ : { 'aria-hidden': 'true', focusable: 'false' } %}
123
+ <svg{{ attr({ class: class }|merge(a11y)|merge(extra)) }}>
124
+ <use href="{{ craft.vite.entry(key) }}#svg-{{ name }}"></use>
125
+ </svg>
126
+ {% endmacro %}
127
+ ```
128
+
129
+ ```twig
130
+ {{ icon('arrow') }} {# root sprite → entry('sprite.svg') #}
131
+ {{ icon('sun', folder: 'light') }} {# subfolder → entry('sprite-light.svg') #}
132
+ {{ icon('search', label: 'Search') }} {# meaningful → role="img" + aria-label #}
133
+ <button aria-label="Close">{{ icon('x') }}</button> {# icon-only control: label the button #}
134
+ ```
135
+
136
+ 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.)
137
+
108
138
  ## Development workflow
109
139
 
110
140
  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,11 +143,11 @@ Sprites are generated at build time only — the plugin does **not** run during
113
143
  vite build --watch
114
144
  ```
115
145
 
116
- Every edit under `inputDir` triggers a rebuild (a second or two), updates `manifest.json` with the new content hash, and `craft.vite.entry('src/icons/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.
146
+ 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
147
 
118
148
  ### Without manifest
119
149
 
120
- 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.
150
+ You can reference a sprite file directly without a manifest, but its filename is always content-hashed — there's no option to turn hashing off — so you'd have to discover the emitted name yourself (e.g. by globbing `assets/sprite-*.svg`). The manifest-based flow is strongly recommended.
121
151
 
122
152
  ## Migrating from v1
123
153
 
@@ -140,6 +170,32 @@ svgSprite({
140
170
 
141
171
  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.
142
172
 
173
+ ## How this differs from other SVG sprite plugins
174
+
175
+ Several Vite plugins generate SVG sprites; they mostly split by *how the sprite reaches the page*:
176
+
177
+ - **Runtime injection** (e.g. [`vite-plugin-svg-icons`](https://github.com/vbenjs/vite-plugin-svg-icons)) injects the sprite into the DOM via a virtual import. Great for SPAs; needs JavaScript to run.
178
+ - **Import-based** plugins expose each icon as a JS module or framework component. Great when your icons live in JS/JSX.
179
+ - **This plugin is build-time and manifest-driven.** It emits content-hashed `.svg` files and registers them in Vite's manifest under stable logical keys, so a **server-rendered** template (Craft/Twig, Laravel/Blade, etc.) resolves the hashed URL with no JavaScript — e.g. `craft.vite.entry('sprite.svg')`. It also emits one sprite per subfolder.
180
+
181
+ If you want runtime/HMR injection or a virtual import, one of the others will suit you better. If you render HTML on the server and resolve assets through a Vite manifest, this is built for that.
182
+
183
+ ## FAQ
184
+
185
+ **Does it work with Craft CMS?** Yes — it's the primary target. With [nystudio107's Craft Vite plugin](https://nystudio107.com/docs/vite/), reference a sprite with `craft.vite.entry('sprite.svg')` (see [Using the sprite](#using-the-sprite)).
186
+
187
+ **How do I reference an icon in a Twig template?** Use `<use href="{{ craft.vite.entry('sprite.svg') }}#svg-{name}">`, or the reusable [`icon()` macro](#a-reusable-accessible-macro).
188
+
189
+ **Can I have more than one sprite?** Yes — each subfolder of `inputDir` becomes its own sprite, keyed `sprite-{folder}.svg`. See [Subfolders](#subfolders).
190
+
191
+ **Can two icons share a filename across folders?** Yes. `arrow.svg` and `brand/arrow.svg` both become `<symbol id="svg-arrow">`, but in *separate* sprite files — and you reference each by its own sprite URL (`entry('sprite.svg')#svg-arrow` vs `entry('sprite-brand.svg')#svg-arrow`), so the file URL disambiguates them and they render fine on the same page. The ids only repeat *across* files, never within one; the only thing to avoid is inlining multiple sprites into a single document and referencing by bare `#svg-arrow`.
192
+
193
+ **Does it run during `vite dev` / support HMR?** No — it's build-only. Run `vite build --watch` alongside your dev server for a live-ish loop.
194
+
195
+ **Does it optimise SVGs?** No — it does light cleanup (strips the XML prolog / comments / empty `<defs>`, collapses whitespace, namespaces IDs), but it doesn't touch path data, so bring source SVGs that are already production-ready. Each icon needs a `viewBox` — the plugin strips `width`/`height` and scales from it.
196
+
197
+ **Why are the manifest keys `sprite.svg` and `sprite-light.svg`, not paths?** Because nystudio107's `entry()` resolves keys with a substring match, and a path-style `light/sprite.svg` would collide with the root `sprite.svg`. Basename keys avoid that.
198
+
143
199
  ## License
144
200
 
145
201
  MIT © [One Darnley Road](https://onedarnleyroad.com)
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@onedarnleyroad/vite-plugin-svg-sprite",
3
- "version": "2.0.0-beta.6",
3
+ "version": "2.0.0",
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
  ],
@@ -14,10 +15,24 @@
14
15
  "vite-plugin",
15
16
  "svg",
16
17
  "sprite",
17
- "svg-sprite"
18
+ "svg-sprite",
19
+ "svg-symbols",
20
+ "icons",
21
+ "manifest",
22
+ "craft-cms",
23
+ "nystudio107"
18
24
  ],
19
25
  "author": "One Darnley Road <hello@onedarnleyroad.com>",
20
26
  "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/onedarnleyroad/vite-plugin-svg-sprite.git"
30
+ },
31
+ "homepage": "https://github.com/onedarnleyroad/vite-plugin-svg-sprite#readme",
32
+ "bugs": "https://github.com/onedarnleyroad/vite-plugin-svg-sprite/issues",
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
21
36
  "peerDependencies": {
22
37
  "vite": ">=4.0.0"
23
38
  }
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
@@ -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 base = group.logicalName.replace(/\.svg$/, '')
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, 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
+ }
119
139
  }
120
140
  },
121
141