const fs = require("fs") const path = require("path") const crypto = require("crypto") const axios = require("axios") const { Compilation } = require("webpack") const { generateAtlasViewsFromReferences, createDirectoriesForFile } = require("./utils.js") class MaterialSymbolsDownloader { static validStyls = [ "outlined", "rounded", "sharp" ] static validWghts = [ 100, 200, 300, 400, 500, 600, 700 ] static validGrads = [ -25, 0, 200 ] static validFills = [ false, true ] static validOpszs = [ 20, 24, 40, 48 ] constructor(ref) { this.icon = ref.symbol this.styl = ref.style ?? "outlined" this.wght = ref.wght ?? 400 this.grad = ref.grad ?? 0 this.fill = ref.fill ?? false this.opsz = ref.opsz ?? 24 this.validateProperties() } validateProperties() { // TODO validate icons if (!MaterialSymbolsDownloader.validStyls.includes(this.styl)) throw new Error(`Styl ${this.styl} is not valid, must be one of ${this.validStyls}`) if (!MaterialSymbolsDownloader.validWghts.includes(this.wght)) throw new Error(`Wght ${this.wght} is not valid, must be one of ${this.validWghts}`) if (!MaterialSymbolsDownloader.validGrads.includes(this.grad)) throw new Error(`Grad ${this.grad} is not valid, must be one of ${this.validGrads}`) if (!MaterialSymbolsDownloader.validFills.includes(this.fill)) throw new Error(`Fill ${this.fill} is not valid, must be one of ${this.validFills}`) if (!MaterialSymbolsDownloader.validOpszs.includes(this.opsz)) throw new Error(`Opsz ${this.opsz} is not valid, must be one of ${this.validOpszs}`) } get axes() { const wght = this.wght == 400 ? "" : `wght${this.wght}` const grad = this.grad == 0 ? "" : `grad${this.grad}` const fill = !this.fill ? "" : "fill1" return wght + grad + fill || "default" } get svgDownloadUrl() { return `https://fonts.gstatic.com/s/i/short-term/release/materialsymbols${this.styl}/${this.icon}/${this.axes}/${this.opsz}px.svg` } async downloadSvg(cache) { const url = this.svgDownloadUrl const cached = await cache.getPromise(url, null) if (cached) return cached try { const res = await axios.get(url) const svg = res.data await cache.storePromise(url, null, svg) return svg } catch (err) { throw new Error(`Symbol ${this.icon} could not be downloaded. Error: ${err}`) } } } class MaterialSymbolsAtlasPlugin { constructor({ referenceFile, filename }) { this.referenceFile = path.resolve(referenceFile) this.filename = filename ?? "icons.svg" } readReferences() { const ext = path.extname(this.referenceFile) if (ext !== ".json") throw new Error(`Unsupported reference file extension: ${ext}. Only .json files are supported at this time`) const content = fs.readFileSync(this.referenceFile, "utf8") return JSON.parse(content) } async resolveRefs(refs, cache) { const root = path.dirname(this.referenceFile) // Relative to referenceFile for (const ref of refs) { if (ref.path) { // Local files const filePath = path.join(root, ref.path) if (!fs.existsSync(filePath)) throw new Error(`SVG asset at ${filePath} does not exist`) ref.svg = fs.readFileSync(filePath, "utf8" ) } else if (ref.symbol) { // Material symbol icon refs const symbolDl = new MaterialSymbolsDownloader(ref) ref.svg = await symbolDl.downloadSvg(cache) } else { throw new Error(`Ref does not provide a symbol name or path:\n${ref}`) } } return refs } apply(compiler) { compiler.hooks.thisCompilation.tap("MaterialSymbolsAtlasPlugin", (compilation) => { compilation.hooks.processAssets.tapPromise( { name: "MaterialSymbolsAtlasPlugin", stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, }, async () => { try { // Handle cache const cache = compilation.getCache("MaterialSymbolsAtlasPlugin") const content = this.readReferences() const cacheKey = crypto.createHash("sha256") .update(JSON.stringify(content)) .digest("hex") const cached = await cache.getPromise(cacheKey, null) // Create asset if not existing let atlas = cached if (!cached) { const refs = content.refs const svgRefs = await this.resolveRefs(refs, cache) atlas = await generateAtlasViewsFromReferences(svgRefs) await cache.storePromise(cacheKey, null, atlas) } createDirectoriesForFile(this.filename) compilation.emitAsset( this.filename, new compiler.webpack.sources.RawSource(atlas) ) } catch (err) { compilation.errors.push(err) } } ) }) compiler.hooks.afterCompile.tap("MaterialSymbolsAtlasPlugin", (compilation) => { // Watch the reference file for changes compilation.fileDependencies.add(this.referenceFile) }) } } module.exports = MaterialSymbolsAtlasPlugin