import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { parse } from 'path'
import { optimize } from 'svgo'
import { globSync } from 'tinyglobby'
import { normalizePath, Plugin } from 'vite'
interface ViteSvgIconsPlugin {
iconDirs: string[];
domId: string;
symbolId: string;
}
const createSymbolCode = (iconDirs: string[], symbolId?: string, domId?: string) => {
const ids = new Set()
const textArr = iconDirs
.map(d => normalizePath(d))
.flatMap(
cwd => globSync(
[ '**/*.svg' ],
{
cwd,
onlyFiles: true,
absolute: true,
},
)
.map(fullPath => {
const { dir, name: fileName } = parse(fullPath)
const dirName = dir.replace(cwd, '').replaceAll('/', '-')
const id = (symbolId ?? 'symbol-[dir]-[name]')
.replace(/\[dir]/g, dirName)
.replace(/-{2,}/g, '-')
.replace(/\[name]/g, fileName)
const input = readFileSync(fullPath).toString()
const { data: code } = optimize(input)
return { code, id }
})
.map(({ code, id }) => {
ids.add(id)
return code
.replace(/', '')
}),
)
const svg = ``
const code = `
if (typeof window !== 'undefined') {
function loadSvg() {
const body = document.body;
const id = '${ domId }' ?? 'svg-icons-dom';
let svgDom = document.getElementById(id);
if(!svgDom) {
svgDom = document.createElement('div');
svgDom.id = id;
document.body.appendChild(svgDom);
}
svgDom.innerHTML = '${ svg }';
body.insertBefore(svgDom, body.lastChild);
}
if(document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', loadSvg);
} else {
loadSvg()
}
}
export default {}
`
const idSet = `export default ${ JSON.stringify([ ...ids ]) }`
return { code, idSet }
}
const simpleEtag = (body: string, weak = true) => {
const hash = createHash('sha1').update(body).digest('base64')
return `${ weak ? 'W/' : '' }"${ hash }"`
}
export function createSvgIconsPlugin(opt: ViteSvgIconsPlugin): Plugin {
const { iconDirs, symbolId, domId } = opt
const SVG_ICONS_REGISTER_NAME = 'virtual:svg-icons-register'
const SVG_ICONS_CLIENT = 'virtual:svg-icons-names'
let isBuild = false
let { code, idSet } = createSymbolCode(iconDirs, symbolId, domId)
return {
name: 'SvgIconsPlugin',
configResolved({ command }) {
isBuild = command === 'build'
},
resolveId(id) {
if ([ SVG_ICONS_REGISTER_NAME, SVG_ICONS_CLIENT ].includes(id)) {
return id
}
return null
},
async load(id, ssr) {
if (!isBuild && !ssr) return null
const isRegister = id.endsWith(SVG_ICONS_REGISTER_NAME)
const isClient = id.endsWith(SVG_ICONS_CLIENT)
if (ssr && !isBuild && (isRegister || isClient)) {
return `export default {}`
}
if (isRegister) {
return code
}
if (isClient) {
return idSet
}
},
configureServer: ({ middlewares }) => {
middlewares.use(async (req, res, next) => {
const url = normalizePath(req.url!)
const registerId = `/@id/${ SVG_ICONS_REGISTER_NAME }`
const clientId = `/@id/${ SVG_ICONS_CLIENT }`
if ([ registerId, clientId ].some((item) => url.endsWith(item))) {
const content = url.endsWith(registerId) ? code : idSet
const tag = simpleEtag(content, true)
// ETag 命中,直接 304
if (req.headers['if-none-match'] === tag) {
res.statusCode = 304
return res.end()
}
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET,HEAD,PUT,PATCH,POST,DELETE')
res.setHeader('Content-Type', 'application/javascript')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Etag', tag)
res.statusCode = 200
res.end(content)
} else {
next()
}
})
},
}
}