aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/atlas-plugin.js36
-rw-r--r--src/index.js7
-rw-r--r--src/material-symbols-plugin.js149
-rw-r--r--src/utils.js83
4 files changed, 275 insertions, 0 deletions
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 <view>'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 }