From e01a5e79337f31e656ed6d87c3cc168a06f0f27a Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Fri, 23 May 2025 14:22:55 -0500 Subject: material symbols plugin added. Cleanup more make better --- material-symbols-plugin.js | 147 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 material-symbols-plugin.js (limited to 'material-symbols-plugin.js') diff --git a/material-symbols-plugin.js b/material-symbols-plugin.js new file mode 100644 index 0000000..b161883 --- /dev/null +++ b/material-symbols-plugin.js @@ -0,0 +1,147 @@ +const fs = require("fs") +const path = require("path") +const crypto = require("crypto") +const axios = require("axios") +const { Compilation } = require("webpack") +const { generateAtlasViewsFromReferences } = 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) + } + + 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 -- cgit v1.2.3