From b4d0b0293812afba9637c7e636bfa51366a030f7 Mon Sep 17 00:00:00 2001 From: Tim Keller Date: Fri, 13 Jun 2025 13:42:21 -0500 Subject: cleanup package and fix bug from not importing fs/path in utils --- src/atlas-plugin.js | 36 ++++++++++ src/index.js | 7 ++ src/material-symbols-plugin.js | 149 +++++++++++++++++++++++++++++++++++++++++ src/utils.js | 83 +++++++++++++++++++++++ 4 files changed, 275 insertions(+) create mode 100644 src/atlas-plugin.js create mode 100644 src/index.js create mode 100644 src/material-symbols-plugin.js create mode 100644 src/utils.js (limited to 'src') diff --git a/src/atlas-plugin.js b/src/atlas-plugin.js new file mode 100644 index 0000000..69f1174 --- /dev/null +++ b/src/atlas-plugin.js @@ -0,0 +1,36 @@ +const fs = require("fs") +const path = require("path") +const { RawSource } = require("webpack-sources") +const { generateAtlasViews } = require("./utils.js") + + +class SVGSymbolAtlasViewPlugin { + constructor(options = {}) { + this.suffix = options.suffix || ".svg" + this.symbolId = options.symbolId || null + } + + apply(compiler) { + compiler.hooks.thisCompilation.tap("SVGSymbolAtlasViewPlugin", (compilation) => { + compilation.hooks.processAssets.tap( + { + name: "SVGSymbolAtlasViewPlugin", + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE, + }, + (assets) => { + for (const assetName in assets) { + if (assetName.endsWith(this.suffix)) { + const originalSVG = assets[assetName].source().toString() + const atlasSVG = generateAtlasViews(originalSVG, this.symbolId) + + // Replace old SVG asset with new SVG + compilation.updateAsset(assetName, new RawSource(atlasSVG)) + } + } + } + ) + }) + } +} + +module.exports = SVGSymbolAtlasViewPlugin diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..e28488f --- /dev/null +++ b/src/index.js @@ -0,0 +1,7 @@ +const SVGSymbolAtlasViewPlugin = require("./atlas-plugin.js") +const MaterialSymbolsAtlasPlugin = require("./material-symbols-plugin.js") + +module.exports = { + SVGSymbolAtlasViewPlugin, + MaterialSymbolsAtlasPlugin, +} diff --git a/src/material-symbols-plugin.js b/src/material-symbols-plugin.js new file mode 100644 index 0000000..48b1123 --- /dev/null +++ b/src/material-symbols-plugin.js @@ -0,0 +1,149 @@ +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 diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..fc424d3 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,83 @@ +const fs = require("fs") +const path = require("path") +const svgSprite = require("svg-sprite") +const { optimize } = require("svgo") +const { JSDOM } = require("jsdom") + +// Generate 's for referencing sprites in css +function generateAtlasViews(svgFile, symbolId) { + const dom = new JSDOM(svgFile) + const document = dom.window.document + const svg = document.querySelector("svg") + + const symbols = document.querySelectorAll("symbol") + const width = 24 + const height = 24 + let row = 0 + let col = 0 + const totalCols = Math.floor(Math.sqrt(symbols.length)) + for (const symbol of symbols) { + const x = col * width + const y = row * height + + let id = symbol.getAttribute("id") + symbol.removeAttribute("xmlns") + if (symbolId) { + id = symbolId(id) + symbol.setAttribute("id", id) + } + + // Create elements + const svgView = document.createElement("view") + svgView.setAttribute("id", `${id}-view`) + svgView.setAttribute("viewBox", `${x} ${y} ${width} ${height}`) + const svgUse = document.createElement("use") + svgUse.setAttribute("width", width) + svgUse.setAttribute("height", height) + //svgUse.setAttribute("viewBox", "0 -960 960 960") + svgUse.setAttribute("x", x) + svgUse.setAttribute("y", y) + svgUse.setAttribute("href", `#${id}`) + + // Append elements + svg.appendChild(svgView) + svg.appendChild(svgUse) + + // Adjust row/col + col++ + if (col > totalCols) { + col = 0 + row++ + } + } + const cWidth = (totalCols + 1) * width + const cHeight = (row + 1) * height + svg.setAttribute("viewBox", `0 0 ${cWidth} ${cHeight}`) + svg.setAttribute("width", `${cWidth}px`) + svg.setAttribute("height", `${cHeight}px`) + + const svgStr = svg.outerHTML.replaceAll("viewbox", "viewBox") // JSDOM will lower-case all attributes + return optimize(svgStr, { + multipass: true, + plugins: [{ name: "removeUnknownsAndDefaults", active: false }], // Prevent changing of ids + }).data +} + +async function generateAtlasViewsFromReferences(refs, symbolId) { + const spriter = new svgSprite({ mode: { symbol: { dest: "", sprite: "icons.svg" } } }) + // Add refs to spriter + for (const ref of refs) { + const name = (ref.name ?? ref.symbol ?? ref.path.replace(".svg", "")) + ".svg" // Supposed to pass a file path, make a fake one + spriter.add(name, null, ref.svg) + } + + // Compile and return atlas + const { result } = await spriter.compileAsync() + return generateAtlasViews(result.symbol.sprite.contents.toString(), symbolId) +} + +function createDirectoriesForFile(filePath) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) +} + +module.exports = { generateAtlasViews, generateAtlasViewsFromReferences, createDirectoriesForFile } -- cgit v1.2.3