aboutsummaryrefslogtreecommitdiff
path: root/material-symbols-plugin.js
diff options
context:
space:
mode:
Diffstat (limited to 'material-symbols-plugin.js')
-rw-r--r--material-symbols-plugin.js147
1 files changed, 147 insertions, 0 deletions
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