• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

SpiriitLabs / vite-plugin-svg-spritemap / 18503106552

14 Oct 2025 04:19PM UTC coverage: 96.728% (+1.4%) from 95.374%
18503106552

Pull #82

github

web-flow
Merge 2dc03142c into c8ba90332
Pull Request #82: Version 5.0.1

256 of 279 branches covered (91.76%)

Branch coverage included in aggregate %.

183 of 184 new or added lines in 7 files covered. (99.46%)

6 existing lines in 1 file now uncovered.

897 of 913 relevant lines covered (98.25%)

305.68 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.94
/src/svgManager.ts
1
import type { Jobs as OxvgConfig } from '@oxvg/napi'
2
import type { Config as SvgoConfig } from 'svgo'
3
import type { ResolvedConfig } from 'vite'
4
import type { Options, Pattern, SvgMapObject } from './types'
5
import { promises as fs } from 'node:fs'
1✔
6
import { basename, resolve } from 'node:path'
1✔
7
import { DOMImplementation, DOMParser, XMLSerializer } from '@xmldom/xmldom'
1✔
8
import hash_sum from 'hash-sum'
1✔
9
import { glob } from 'tinyglobby'
1✔
10
import { calculateY } from './helpers/calculateY'
1✔
11
import { cleanAttributes } from './helpers/cleanAttributes'
1✔
12
import { getOptimize as getOptimiseOxvg, getOptions as getOptionsOxvg } from './helpers/oxvg'
1✔
13
import { getOptimize as getOptimizeSvgo, getOptions as getOptionsSvgo } from './helpers/svgo'
1✔
14
import { Styles } from './styles/styles'
1✔
15

16
/**
17
 * Manages SVG files for creating a sprite map
18
 */
19
export class SVGManager {
1✔
20
  private _options: Options
21
  private _parser: DOMParser
22
  private _ids: Set<string>
23
  private _svgs: Map<string, SvgMapObject>
24
  private _iconsPattern: Pattern
25
  private _config: ResolvedConfig
26
  public hash: string | null = null
1✔
27
  private _optimizeType: 'svgo' | 'oxvg' | null = null
1✔
28
  private _optimize: Awaited<ReturnType<typeof getOptimizeSvgo | typeof getOptimiseOxvg>> | null = null
1✔
29

30
  constructor(iconsPattern: Pattern, options: Options, config: ResolvedConfig) {
1✔
31
    this._parser = new DOMParser()
68✔
32
    this._options = options
68✔
33
    this._ids = new Set()
68✔
34
    this._svgs = new Map()
68✔
35
    this._iconsPattern = iconsPattern
68✔
36
    this._config = config
68✔
37
  }
68✔
38

39
  /**
40
   * Update a single SVG file in the spritemap
41
   * @param filePath - The path of the SVG file to update
42
   * @param mode - The mode of operation, either 'create' or 'update' (default: 'create')
43
   * @param loop - Whether this update is part of a bulk update (to optimize performance)
44
   * @returns True if the SVG file was updated, false otherwise
45
   */
46
  async update(filePath: string, mode: 'create' | 'update' = 'create', loop = false) {
1✔
47
    const name = basename(filePath, '.svg')
395✔
48
    if (!name)
395✔
49
      return false
395!
50

51
    let svg: string
395✔
52
    try {
395✔
53
      svg = await fs.readFile(filePath, 'utf8')
395✔
54
    }
395!
55
    catch (error) {
×
56
      this._config.logger.error(`[vite-plugin-svg-spritemap] Failed to read file '${filePath}': ${error}`)
×
57
      return false
×
UNCOV
58
    }
×
59

60
    const { width, height, viewBox } = this._extractSvgDimensions(svg, filePath)
395✔
61
    if (!width || !height || !viewBox)
395✔
62
      return false
395✔
63

64
    if (!loop) {
395✔
65
      await this._initializeOptimizer()
3✔
66
    }
3✔
67

68
    svg = await this._optimizeSvg(svg)
267✔
69

70
    const svgData = {
267✔
71
      width,
267✔
72
      height,
267✔
73
      viewBox,
267✔
74
      filePath,
267✔
75
      source: svg,
267✔
76
    }
267✔
77

78
    const id = this._options.idify(name, svgData)
267✔
79

80
    if (this._ids.has(id) && mode === 'create') {
395✔
81
      this._config.logger.warn(`[vite-plugin-svg-spritemap] Sprite '${filePath}' has the same id (${id}) as another sprite.`)
1✔
82
    }
1✔
83

84
    this._ids.add(id)
267✔
85
    this._svgs.set(filePath, {
267✔
86
      ...svgData,
267✔
87
      id,
267✔
88
    })
267✔
89

90
    if (!loop) {
395✔
91
      this.hash = hash_sum(this.spritemap)
3✔
92
      this._sortSvgs()
3✔
93
      await this.createFileStyle()
3✔
94
    }
3✔
95

96
    return true
267✔
97
  }
395✔
98

99
  /**
100
   * Remove a single SVG file from the spritemap
101
   * @param filePath - The path of the SVG file to remove
102
   * @returns True if the SVG file was removed, false if it was not found
103
   */
104
  async delete(filePath: string) {
1✔
105
    if (!this._svgs.has(filePath))
1✔
106
      return false
1!
107

108
    const svg = this._svgs.get(filePath)
1✔
109
    if (svg)
1✔
110
      this._ids.delete(svg.id)
1✔
111

112
    this._svgs.delete(filePath)
1✔
113
    this.hash = hash_sum(this.spritemap)
1✔
114
    this._sortSvgs()
1✔
115
    await this.createFileStyle()
1✔
116
    return true
1✔
117
  }
1✔
118

119
  /**
120
   * Extract width, height and viewBox from SVG
121
   * @param svg - The SVG content as a string
122
   * @param filePath - The path of the SVG file (for logging purposes)
123
   * @returns An object containing width, height and viewBox (if available)
124
   */
125
  private _extractSvgDimensions(svg: string, filePath: string): { width?: number, height?: number, viewBox?: number[] } {
1✔
126
    const document = this._parser.parseFromString(svg, 'image/svg+xml')
395✔
127
    const documentElement = document.documentElement
395✔
128

129
    let viewBox = (
395✔
130
      documentElement?.getAttribute('viewBox')
395✔
131
      || documentElement?.getAttribute('viewbox')
128✔
132
    )
133
      ?.split(' ')
395✔
134
      .map(a => Number.parseFloat(a))
395✔
135

136
    const widthAttr = documentElement?.getAttribute('width')
395✔
137
    const heightAttr = documentElement?.getAttribute('height')
395✔
138
    let width = widthAttr ? Number.parseFloat(widthAttr) : undefined
395✔
139
    let height = heightAttr ? Number.parseFloat(heightAttr) : undefined
395✔
140

141
    if (viewBox && viewBox.length !== 4 && (!width || !height)) {
395!
142
      this._config.logger.warn(`[vite-plugin-svg-spritemap] Sprite '${filePath}' is invalid, it's lacking both a viewBox and width/height attributes.`)
×
143
      return {}
×
UNCOV
144
    }
×
145

146
    if ((!viewBox || viewBox.length !== 4) && width && height)
395✔
147
      viewBox = [0, 0, width, height]
395✔
148

149
    if (!width && viewBox)
395✔
150
      width = viewBox[2]
395✔
151

152
    if (!height && viewBox)
395✔
153
      height = viewBox[3]
395✔
154

155
    return { width, height, viewBox }
395✔
156
  }
395✔
157

158
  /**
159
   * Optimize SVG using SVGO or OXVG if available
160
   * @param svg - The SVG content as a string
161
   * @returns The optimized SVG content as a string
162
   */
163
  private async _optimizeSvg(svg: string): Promise<string> {
1✔
164
    if (this._optimize && this._optimizeType) {
267✔
165
      try {
235✔
166
        let config: SvgoConfig | OxvgConfig | undefined = getOptionsSvgo(this._options.svgo, this._options.prefix)
235✔
167
        if (this._optimizeType === 'oxvg') {
235✔
168
          config = getOptionsOxvg(this._options.oxvg)
8✔
169
        }
8✔
170
        const optimizedSvg = this._optimize(svg, config)
235✔
171
        if (typeof optimizedSvg === 'string')
235✔
172
          return optimizedSvg
235✔
173
        else if ('data' in optimizedSvg)
227✔
174
          return optimizedSvg.data
227✔
175
      }
235!
176
      catch (error) {
×
177
        this._config.logger.warn(`[vite-plugin-svg-spritemap] SVGO optimization failed: ${error}`)
×
UNCOV
178
      }
×
179
    }
235✔
180

181
    return svg
32✔
182
  }
267✔
183

184
  /**
185
   * Initialize SVGO optimizer
186
   */
187
  private async _initializeOptimizer(): Promise<void> {
1✔
188
    if (this._optimize !== null)
71✔
189
      return
71✔
190

191
    // Try to load SVGO first, if not available, fallback to OXVG
192
    if (this._options.svgo !== false)
68✔
193
      this._optimize = await getOptimizeSvgo()
68✔
194
    if (this._optimize) {
68✔
195
      this._config.logger.info(`[vite-plugin-svg-spritemap] Using SVGO for SVG optimization on ${this._options.route}.`)
58✔
196
      this._optimizeType = 'svgo'
58✔
197
    }
58✔
198
    if (this._options.svgo && !this._optimize) {
71✔
199
      this._config.logger.warn(`[vite-plugin-svg-spritemap] You need to install SVGO to be able to optimize your SVG with it.`)
2✔
200
    }
2✔
201

202
    if (this._optimize)
68✔
203
      return
68✔
204

205
    if (this._options.oxvg !== false)
10✔
206
      this._optimize = await getOptimiseOxvg(this._config.logger)
12✔
207
    if (this._optimize) {
12✔
208
      this._config.logger.info(`[vite-plugin-svg-spritemap] Using OXVG for SVG optimization on ${this._options.route}.`)
2✔
209
      this._optimizeType = 'oxvg'
2✔
210
    }
2✔
211
    if (this._options.oxvg && !this._optimize) {
71✔
212
      this._config.logger.warn(`[vite-plugin-svg-spritemap] You need to install OXVG to be able to optimize your SVG with it.`)
2✔
213
    }
2✔
214
  }
71✔
215

216
  /**
217
   * Update all SVG files in the glob pattern
218
   * @param mode - The mode of operation, either 'create' or 'update' (default: 'create')
219
   */
220
  async updateAll(mode: 'create' | 'update' = 'create'): Promise<void> {
1✔
221
    const iconsPath = await glob(this._iconsPattern, {
68✔
222
      cwd: this._config.root,
68✔
223
      absolute: true,
68✔
224
    })
68✔
225

226
    // Initialize SVGO before parallel processing to avoid race conditions
227
    await this._initializeOptimizer()
68✔
228

229
    // Process files in parallel for better performance
230
    await Promise.all(
68✔
231
      iconsPath.map(iconPath => this.update(iconPath, mode, true)),
68✔
232
    )
68✔
233
    this._sortSvgs()
68✔
234

235
    this.hash = hash_sum(this.spritemap)
68✔
236
    await this.createFileStyle()
68✔
237
  }
68✔
238

239
  /**
240
   * Generate the SVG sprite map
241
   */
242
  get spritemap(): string {
1✔
243
    const DOM = new DOMImplementation().createDocument(null, '', null)
213✔
244
    const Serializer = new XMLSerializer()
213✔
245
    const spritemap = DOM.createElement('svg')
213✔
246
    spritemap.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
213✔
247

248
    if (this._options.output && this._options.output.use)
213✔
249
      spritemap.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink')
213✔
250

251
    // return empty spritemap
252
    if (!this._svgs.size)
213✔
253
      return Serializer.serializeToString(spritemap)
213✔
254

255
    const sizes: { width: number[], height: number[] } = {
210✔
256
      width: [],
210✔
257
      height: [],
210✔
258
    }
210✔
259
    const parser = new DOMParser()
210✔
260

261
    this._svgs.forEach((svg) => {
210✔
262
      const symbol = DOM.createElement('symbol')
840✔
263
      const document = parser.parseFromString(svg.source, 'image/svg+xml')
840✔
264
      const documentElement = document.documentElement
840✔
265
      let attributes = documentElement
840✔
266
        ? cleanAttributes(
840✔
267
            Array.from(documentElement.attributes),
840✔
268
            'symbol',
840✔
269
          )
840!
UNCOV
270
        : []
×
271

272
      // spritemap attributes
273
      attributes.forEach((attr) => {
840✔
274
        if (attr.name.toLowerCase().startsWith('xmlns:'))
245✔
275
          spritemap.setAttribute(attr.name, attr.value)
245!
276
      })
840✔
277

278
      // symbol attributes
279
      attributes.forEach((attr) => {
840✔
280
        symbol.setAttribute(attr.name, attr.value)
245✔
281
      })
840✔
282
      symbol.setAttribute('id', this._options.prefix + svg.id)
840✔
283
      symbol.setAttribute('viewBox', svg.viewBox.join(' '))
840✔
284

285
      // add childs
286
      if (documentElement) {
840✔
287
        Array.from(documentElement.childNodes).forEach((child) => {
840✔
288
          if (child)
1,239✔
289
            symbol.appendChild(child)
1,239✔
290
        })
840✔
291
      }
840✔
292

293
      spritemap.appendChild(symbol)
840✔
294
      const y = calculateY(sizes.height, this._options.gutter)
840✔
295

296
      // use
297
      if (this._options.output && this._options.output.use) {
840✔
298
        const use = DOM.createElement('use')
824✔
299
        use.setAttribute('xlink:href', `#${this._options.prefix + svg.id}`)
824✔
300
        use.setAttribute('width', svg.width.toString())
824✔
301
        use.setAttribute('height', svg.height.toString())
824✔
302
        use.setAttribute('y', y.toString())
824✔
303
        spritemap.appendChild(use)
824✔
304
      }
824✔
305

306
      // view
307
      if (this._options.output && this._options.output.view) {
840✔
308
        const view = DOM.createElement('view')
818✔
309
        attributes = documentElement && documentElement.attributes
818✔
310
          ? cleanAttributes(
818✔
311
              Array.from(documentElement.attributes),
818✔
312
              'view',
818✔
313
            )
818!
UNCOV
314
          : []
×
315
        attributes.forEach((attr) => {
818✔
316
          view.setAttribute(attr.name, attr.value)
238✔
317
        })
818✔
318
        view.setAttribute('id', `${this._options.prefix + svg.id}-view`)
818✔
319
        view.setAttribute(
818✔
320
          'viewBox',
818✔
321
          `0 ${Math.max(0, y)} ${svg.width} ${svg.height}`,
818✔
322
        )
818✔
323
        spritemap.appendChild(view)
818✔
324
      }
818✔
325

326
      sizes.width.push(svg.width)
840✔
327
      sizes.height.push(svg.height)
840✔
328
    })
210✔
329

330
    return Serializer.serializeToString(spritemap)
210✔
331
  }
213✔
332

333
  /**
334
   * Generate and write CSS styles file
335
   */
336
  private async createFileStyle(): Promise<void> {
1✔
337
    if (typeof this._options.styles !== 'object')
72✔
338
      return
72✔
339

340
    try {
36✔
341
      const styleGen: Styles = new Styles(this._svgs, this._options)
36✔
342
      const content = await styleGen.generate()
36✔
343
      const path = resolve(this._config.root, this._options.styles.filename)
36✔
344

345
      await fs.writeFile(path, content, 'utf8')
36✔
346
    }
36!
347
    catch (error) {
×
348
      this._config.logger.error(`[vite-plugin-svg-spritemap] Failed to create style file: ${error}`)
×
UNCOV
349
    }
×
350
  }
72✔
351

352
  /**
353
   * Get all SVG objects
354
   */
355
  public get svgs(): Map<string, SvgMapObject> {
1✔
356
    return this._svgs
8✔
357
  }
8✔
358

359
  /**
360
   * Get all directories containing SVGs
361
   */
362
  public get directories(): Set<string> {
1✔
363
    const directories = new Set<string>()
6✔
364
    this._svgs.forEach((svg) => {
6✔
365
      const folder = svg.filePath.split('/').slice(0, -1).join('/')
20✔
366
      directories.add(folder)
20✔
367
    })
6✔
368
    return directories
6✔
369
  }
6✔
370

371
  /**
372
   * Sort the internal SVGs Map alphabetically by file path
373
   */
374
  private _sortSvgs(): void {
1✔
375
    const entries = [...this._svgs.entries()]
72✔
376
    entries.sort((a, b) => a[0].localeCompare(b[0]))
72✔
377

378
    this._svgs.clear()
72✔
379
    for (const [key, value] of entries) {
72✔
380
      this._svgs.set(key, value)
287✔
381
    }
287✔
382
  }
72✔
383

384
  /**
385
   * Check if an SVG file is already managed
386
   * @param filePath - The path of the SVG file to check
387
   * @returns True if the SVG file is managed, false otherwise
388
   */
389
  public has(filePath: string): boolean {
1✔
390
    return this._svgs.has(filePath)
6✔
391
  }
6✔
392
}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc