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

SpiriitLabs / vite-plugin-svg-spritemap / 13570194087

27 Feb 2025 03:39PM UTC coverage: 94.747% (-2.2%) from 96.979%
13570194087

push

github

web-flow
Merge pull request #64 from SpiriitLabs/dev

Version 4.0.0

230 of 251 branches covered (91.63%)

Branch coverage included in aggregate %.

107 of 125 new or added lines in 5 files covered. (85.6%)

6 existing lines in 1 file now uncovered.

780 of 815 relevant lines covered (95.71%)

281.82 hits per line

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

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

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

28
  constructor(iconsPattern: Pattern, options: Options, config: ResolvedConfig) {
1✔
29
    this._parser = new DOMParser()
62✔
30
    this._options = options
62✔
31
    this._ids = new Set()
62✔
32
    this._svgs = new Map()
62✔
33
    this._iconsPattern = iconsPattern
62✔
34
    this._config = config
62✔
35
    this._optimizeOptions = getOptions(typeof this._options.svgo === 'undefined' ? true : this._options.svgo, this._options.prefix)
62✔
36
  }
62✔
37

38
  /**
39
   * Update a single SVG file in the sprite map
40
   */
41
  async update(filePath: string, loop = false) {
1✔
42
    const name = basename(filePath, '.svg')
347✔
43
    if (!name)
347✔
44
      return false
347!
45

46
    let svg: string
347✔
47
    try {
347✔
48
      svg = await fs.readFile(filePath, 'utf8')
347✔
49
    }
347!
NEW
50
    catch (error) {
×
NEW
51
      this._config.logger.error(`[vite-plugin-svg-spritemap] Failed to read file '${filePath}': ${error}`)
×
NEW
52
      return false
×
NEW
53
    }
×
54

55
    const { width, height, viewBox } = this._extractSvgDimensions(svg, filePath)
347✔
56
    if (!width || !height || !viewBox)
347✔
57
      return false
347✔
58

59
    if (!loop) {
347!
NEW
60
      await this._initializeOptimizer()
×
NEW
61
    }
✔
62

63
    // Optimize SVG if SVGO is enabled and available
64
    svg = await this._optimizeSvg(svg)
235✔
65

66
    const svgData = {
235✔
67
      width,
235✔
68
      height,
235✔
69
      viewBox,
235✔
70
      filePath,
235✔
71
      source: svg,
235✔
72
    }
235✔
73

74
    const id = this._options.idify(name, svgData)
235✔
75

76
    if (this._ids.has(id)) {
347!
NEW
77
      this._config.logger.warn(`[vite-plugin-svg-spritemap] Sprite '${filePath}' has the same id (${id}) as another sprite.`)
×
NEW
78
    }
✔
79

80
    this._ids.add(id)
235✔
81
    this._svgs.set(filePath, {
235✔
82
      ...svgData,
235✔
83
      id,
235✔
84
    })
235✔
85

86
    if (!loop) {
347!
NEW
87
      this.hash = hash_sum(this.spritemap)
×
NEW
88
      this._sortSvgs()
×
NEW
89
      await this.createFileStyle()
×
NEW
90
    }
✔
91

92
    return true
235✔
93
  }
347✔
94

95
  /**
96
   * Extract width, height and viewBox from SVG
97
   */
98
  private _extractSvgDimensions(svg: string, filePath: string): { width?: number, height?: number, viewBox?: number[] } {
1✔
99
    const document = this._parser.parseFromString(svg, 'image/svg+xml')
347✔
100
    const documentElement = document.documentElement
347✔
101

102
    let viewBox = (
347✔
103
      documentElement?.getAttribute('viewBox')
347✔
104
      || documentElement?.getAttribute('viewbox')
112✔
105
    )
106
      ?.split(' ')
347✔
107
      .map(a => Number.parseFloat(a))
347✔
108

109
    const widthAttr = documentElement?.getAttribute('width')
347✔
110
    const heightAttr = documentElement?.getAttribute('height')
347✔
111
    let width = widthAttr ? Number.parseFloat(widthAttr) : undefined
347✔
112
    let height = heightAttr ? Number.parseFloat(heightAttr) : undefined
347✔
113

114
    if (viewBox && viewBox.length !== 4 && (!width || !height)) {
347!
115
      this._config.logger.warn(`[vite-plugin-svg-spritemap] Sprite '${filePath}' is invalid, it's lacking both a viewBox and width/height attributes.`)
×
NEW
116
      return {}
×
117
    }
×
118

119
    if ((!viewBox || viewBox.length !== 4) && width && height)
347✔
120
      viewBox = [0, 0, width, height]
347✔
121

122
    if (!width && viewBox)
347✔
123
      width = viewBox[2]
347✔
124

125
    if (!height && viewBox)
347✔
126
      height = viewBox[3]
347✔
127

128
    return { width, height, viewBox }
347✔
129
  }
347✔
130

131
  /**
132
   * Optimize SVG using SVGO if available
133
   */
134
  private async _optimizeSvg(svg: string): Promise<string> {
1✔
135
    if (this._optimize === null) {
235!
UNCOV
136
      this._optimize = await getOptimize()
×
UNCOV
137
      if (this._options.svgo && !this._optimize) {
×
UNCOV
138
        this._config.logger.warn(`[vite-plugin-svg-spritemap] You need to install SVGO to be able to optimize your SVG with it.`)
×
UNCOV
139
      }
×
UNCOV
140
    }
×
141

142
    if (this._optimize && this._optimizeOptions) {
235✔
143
      try {
219✔
144
        const optimizedSvg = this._optimize(svg, this._optimizeOptions)
219✔
145
        if ('data' in optimizedSvg)
219✔
146
          return optimizedSvg.data
219✔
147
      }
219!
NEW
148
      catch (error) {
×
NEW
149
        this._config.logger.warn(`[vite-plugin-svg-spritemap] SVGO optimization failed: ${error}`)
×
UNCOV
150
      }
×
151
    }
219✔
152

153
    return svg
16✔
154
  }
235✔
155

156
  /**
157
   * Initialize SVGO optimizer
158
   */
159
  private async _initializeOptimizer(): Promise<void> {
1✔
160
    if (this._optimize === null) {
62✔
161
      this._optimize = await getOptimize()
62✔
162
      if (this._options.svgo && !this._optimize) {
62✔
163
        this._config.logger.warn(`[vite-plugin-svg-spritemap] You need to install SVGO to be able to optimize your SVG with it.`)
2✔
164
      }
2✔
165
    }
62✔
166
  }
62✔
167

168
  /**
169
   * Update all SVG files in the glob pattern
170
   */
171
  async updateAll(): Promise<void> {
1✔
172
    const iconsPath = await glob(this._iconsPattern, {
62✔
173
      cwd: this._config.root,
62✔
174
      absolute: true,
62✔
175
    })
62✔
176

177
    // Initialize SVGO before parallel processing to avoid race conditions
178
    await this._initializeOptimizer()
62✔
179

180
    // Process files in parallel for better performance
181
    await Promise.all(
62✔
182
      iconsPath.map(iconPath => this.update(iconPath, true)),
62✔
183
    )
62✔
184
    this._sortSvgs()
62✔
185

186
    this.hash = hash_sum(this.spritemap)
62✔
187
    await this.createFileStyle()
62✔
188
  }
62✔
189

190
  /**
191
   * Generate the SVG sprite map
192
   */
193
  get spritemap(): string {
1✔
194
    const DOM = new DOMImplementation().createDocument(null, '', null)
184✔
195
    const Serializer = new XMLSerializer()
184✔
196
    const spritemap = DOM.createElement('svg')
184✔
197
    spritemap.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
184✔
198

199
    if (this._options.output && this._options.output.use)
184✔
200
      spritemap.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink')
184✔
201

202
    // return empty spritemap
203
    if (!this._svgs.size)
184✔
204
      return Serializer.serializeToString(spritemap)
184✔
205

206
    const sizes: { width: number[], height: number[] } = {
181✔
207
      width: [],
181✔
208
      height: [],
181✔
209
    }
181✔
210
    const parser = new DOMParser()
181✔
211

212
    this._svgs.forEach((svg) => {
181✔
213
      const symbol = DOM.createElement('symbol')
703✔
214
      const document = parser.parseFromString(svg.source, 'image/svg+xml')
703✔
215
      const documentElement = document.documentElement
703✔
216
      let attributes = documentElement
703✔
217
        ? cleanAttributes(
703✔
218
            Array.from(documentElement.attributes),
703✔
219
            'symbol',
703✔
220
          )
703!
221
        : []
×
222

223
      // spritemap attributes
224
      attributes.forEach((attr) => {
703✔
225
        if (attr.name.toLowerCase().startsWith('xmlns:'))
193✔
226
          spritemap.setAttribute(attr.name, attr.value)
193!
227
      })
703✔
228

229
      // symbol attributes
230
      attributes.forEach((attr) => {
703✔
231
        symbol.setAttribute(attr.name, attr.value)
193✔
232
      })
703✔
233
      symbol.setAttribute('id', this._options.prefix + svg.id)
703✔
234
      symbol.setAttribute('viewBox', svg.viewBox.join(' '))
703✔
235

236
      // add childs
237
      if (documentElement) {
703✔
238
        Array.from(documentElement.childNodes).forEach((child) => {
703✔
239
          if (child)
977✔
240
            symbol.appendChild(child)
977✔
241
        })
703✔
242
      }
703✔
243

244
      spritemap.appendChild(symbol)
703✔
245
      const y = calculateY(sizes.height)
703✔
246

247
      // use
248
      if (this._options.output && this._options.output.use) {
703✔
249
        const use = DOM.createElement('use')
687✔
250
        use.setAttribute('xlink:href', `#${this._options.prefix + svg.id}`)
687✔
251
        use.setAttribute('width', svg.width.toString())
687✔
252
        use.setAttribute('height', svg.height.toString())
687✔
253
        use.setAttribute('y', y.toString())
687✔
254
        spritemap.appendChild(use)
687✔
255
      }
687✔
256

257
      // view
258
      if (this._options.output && this._options.output.view) {
703✔
259
        const view = DOM.createElement('view')
679✔
260
        attributes = documentElement && documentElement.attributes
679✔
261
          ? cleanAttributes(
679✔
262
              Array.from(documentElement.attributes),
679✔
263
              'view',
679✔
264
            )
679!
265
          : []
×
266
        attributes.forEach((attr) => {
679✔
267
          view.setAttribute(attr.name, attr.value)
185✔
268
        })
679✔
269
        view.setAttribute('id', `${this._options.prefix + svg.id}-view`)
679✔
270
        view.setAttribute(
679✔
271
          'viewBox',
679✔
272
          `0 ${Math.max(0, y)} ${svg.width} ${svg.height}`,
679✔
273
        )
679✔
274
        spritemap.appendChild(view)
679✔
275
      }
679✔
276

277
      sizes.width.push(svg.width)
703✔
278
      sizes.height.push(svg.height)
703✔
279
    })
181✔
280

281
    return Serializer.serializeToString(spritemap)
181✔
282
  }
184✔
283

284
  /**
285
   * Generate and write CSS styles file
286
   */
287
  private async createFileStyle(): Promise<void> {
1✔
288
    if (typeof this._options.styles !== 'object')
62✔
289
      return
62✔
290

291
    try {
32✔
292
      const styleGen: Styles = new Styles(this._svgs, this._options)
32✔
293
      const content = await styleGen.generate()
32✔
294
      const path = resolve(this._config.root, this._options.styles.filename)
32✔
295

296
      await fs.writeFile(path, content, 'utf8')
32✔
297
    }
32!
NEW
298
    catch (error) {
×
NEW
299
      this._config.logger.error(`[vite-plugin-svg-spritemap] Failed to create style file: ${error}`)
×
NEW
300
    }
×
301
  }
62✔
302

303
  /**
304
   * Get all SVG objects
305
   */
306
  public get svgs(): Map<string, SvgMapObject> {
1✔
307
    return this._svgs
8✔
308
  }
8✔
309

310
  /**
311
   * Get all directories containing SVGs
312
   */
313
  public get directories(): Set<string> {
1✔
314
    const directories = new Set<string>()
6✔
315
    this._svgs.forEach((svg) => {
6✔
316
      const folder = svg.filePath.split('/').slice(0, -1).join('/')
19✔
317
      directories.add(folder)
19✔
318
    })
6✔
319
    return directories
6✔
320
  }
6✔
321

322
  /**
323
   * Sort the internal SVGs Map alphabetically by file path
324
   */
325
  private _sortSvgs(): void {
1✔
326
    const entries = [...this._svgs.entries()]
62✔
327
    entries.sort((a, b) => a[0].localeCompare(b[0]))
62✔
328

329
    this._svgs.clear()
62✔
330
    for (const [key, value] of entries) {
62✔
331
      this._svgs.set(key, value)
235✔
332
    }
235✔
333
  }
62✔
334
}
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