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 --- atlas-plugin.js | 36 ---------- index.js | 7 -- material-symbols-plugin.js | 149 ----------------------------------------- package.json | 2 +- src/atlas-plugin.js | 36 ++++++++++ src/index.js | 7 ++ src/material-symbols-plugin.js | 149 +++++++++++++++++++++++++++++++++++++++++ src/utils.js | 83 +++++++++++++++++++++++ utils.js | 81 ---------------------- 9 files changed, 276 insertions(+), 274 deletions(-) delete mode 100644 atlas-plugin.js delete mode 100644 index.js delete mode 100644 material-symbols-plugin.js 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 delete mode 100644 utils.js diff --git a/atlas-plugin.js b/atlas-plugin.js deleted file mode 100644 index 69f1174..0000000 --- a/atlas-plugin.js +++ /dev/null @@ -1,36 +0,0 @@ -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/index.js b/index.js deleted file mode 100644 index e28488f..0000000 --- a/index.js +++ /dev/null @@ -1,7 +0,0 @@ -const SVGSymbolAtlasViewPlugin = require("./atlas-plugin.js") -const MaterialSymbolsAtlasPlugin = require("./material-symbols-plugin.js") - -module.exports = { - SVGSymbolAtlasViewPlugin, - MaterialSymbolsAtlasPlugin, -} diff --git a/material-symbols-plugin.js b/material-symbols-plugin.js deleted file mode 100644 index 48b1123..0000000 --- a/material-symbols-plugin.js +++ /dev/null @@ -1,149 +0,0 @@ -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/package.json b/package.json index 3ea235c..6079a4f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "material-svg-atlas-plugin-webpack", "version": "1.0.0", - "main": "index.js", + "main": "src/index.js", "scripts": { "test": "echo no test" }, 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 } diff --git a/utils.js b/utils.js deleted file mode 100644 index 8284755..0000000 --- a/utils.js +++ /dev/null @@ -1,81 +0,0 @@ -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