Skip to content

Commit

Permalink
feat: add svgo to minify sprite, re-add hashing (#21)
Browse files Browse the repository at this point in the history
  • Loading branch information
mcansh authored Apr 4, 2024
1 parent d9ee8ed commit b4955f5
Show file tree
Hide file tree
Showing 9 changed files with 296 additions and 49 deletions.
8 changes: 8 additions & 0 deletions .changeset/khaki-spoons-join.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@mcansh/vite-svg-sprite-plugin": patch
"vite-no-remix": patch
---

- add svgo to minify sprite, re-add hashing to sprite.svg file name
- adds a virtual moduel `virtual:vite-svg-sprite-plugin` that returns the sprite url for preloading, etc
- adds client.d.ts file for virtual module
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
dist
.ds_store
node_modules
tsconfig.tsbuildinfo
Expand Down
2 changes: 1 addition & 1 deletion examples/vite-no-remix/.gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
node_modules

/.cache
/build
/dist
.env
2 changes: 2 additions & 0 deletions packages/vite-svg-sprite-plugin/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist/**
!dist/client.d.ts
10 changes: 7 additions & 3 deletions packages/vite-svg-sprite-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ createSvgSpritePlugin({
```tsx
import linkIconHref from "@primer/octicons/build/svg/link-16.svg";

<svg className="size-4" aria-hidden>
<use href={linkIconHref} />
</svg>;
function Component() {
return (
<svg className="size-4" aria-hidden>
<use href={linkIconHref} />
</svg>
);
}
```
4 changes: 4 additions & 0 deletions packages/vite-svg-sprite-plugin/dist/client.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
declare module "virtual:vite-svg-sprite-plugin" {
const component: string;
export default component;
}
4 changes: 4 additions & 0 deletions packages/vite-svg-sprite-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./client.d.ts": {
"types": "./dist/client.d.ts"
}
},
"main": "dist/index.js",
Expand All @@ -33,6 +36,7 @@
"dependencies": {
"fs-extra": "^11.2.0",
"hasha": "^6.0.0",
"svgo": "^3.2.0",
"svgstore": "^3.0.1"
},
"devDependencies": {
Expand Down
205 changes: 161 additions & 44 deletions packages/vite-svg-sprite-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,74 +4,191 @@ import fse from "fs-extra";
import svgstore from "svgstore";
import { ResolvedConfig, Plugin } from "vite";
import { hash } from "hasha";
import svgo from "svgo";

let svgRegex = /\.svg$/;
let PLUGIN_NAME = "vite-svg-sprite-plugin";
let virtualModuleId = `virtual:${PLUGIN_NAME}`;
let resolvedVirtualModuleId = "\0" + virtualModuleId;

type Config = {
spriteOutputName?: string;
symbolId?: string;
logging?: boolean;
};

export function createSvgSpritePlugin(configOptions?: Config): Plugin {
let config: ResolvedConfig;
let store = svgstore();
let icons = new Set<string>();
let store = svgstore({
// use file name in symbol defs
renameDefs: true,
});
let icons = new Set<string>();

export function createSvgSpritePlugin(configOptions?: Config): Array<Plugin> {
let config: ResolvedConfig;
let options: Required<Config> = {
spriteOutputName: "sprite.svg",
spriteOutputName: "sprite-[hash].svg",
symbolId: "icon-[name]-[hash]",
logging: false,
...configOptions,
};

return {
name: "create-svg-sprite-plugin",

configResolved(resolvedConfig) {
config = resolvedConfig;
},
return [
{
name: PLUGIN_NAME,

async transform(_code, id) {
if (svgRegex.test(id)) {
let basename = path.basename(id, ".svg");
let content = await fse.readFile(id, "utf-8");
configResolved(resolvedConfig) {
config = resolvedConfig;
},

let symbolId = options.symbolId;
if (options.symbolId.includes("[name]")) {
symbolId = symbolId.replace("[name]", basename);
resolveId(id) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId;
}
if (options.symbolId.includes("[hash]")) {
let contentHash = await hash(id);
symbolId = symbolId.replace("[hash]", contentHash);
},
async load(id) {
if (id === resolvedVirtualModuleId) {
let url = `/${config.build.assetsDir}/${options.spriteOutputName}`;
let sprite = store.toString();
let { spriteHash } = await getSpriteHash(sprite);
url = url.replace("[hash]", spriteHash);
return `export default "${url}";`;
}
},

config(userConfig) {
return {
build: {
assetsInlineLimit(filePath, content) {
// don't inline svg files
if (svgRegex.test(filePath)) {
return true;
}

if (typeof userConfig.build?.assetsInlineLimit === "function") {
return userConfig.build.assetsInlineLimit(filePath, content);
}

// only add the icon if it hasn't been added before
if (!icons.has(symbolId)) {
store.add(symbolId, content);
icons.add(symbolId);
// check buffer length in bytes (default is 4096) and return true if it's less than the limit
return content.length < 4096;
},
},
};
},

async transform(_code, id) {
if (svgRegex.test(id)) {
let basename = path.basename(id, ".svg");
let content = await fse.readFile(id, "utf-8");

let symbolId = options.symbolId;
if (options.symbolId.includes("[name]")) {
symbolId = symbolId.replace("[name]", basename);
}
if (options.symbolId.includes("[hash]")) {
let contentHash = await hash(id);
symbolId = symbolId.replace("[hash]", contentHash);
}

// only add the icon if it hasn't been added before
if (!icons.has(symbolId)) {
store.add(symbolId, content);
icons.add(symbolId);
}

let url = `/${config.build.assetsDir}/${options.spriteOutputName}`;
return {
code: `export default "${url}#${symbolId}";`,
map: null,
};
}
},

let url = `/${config.build.assetsDir}/${options.spriteOutputName}`;
return `export default "${url}#${symbolId}";`;
}
},
async writeBundle(this) {
let { assetsDir, outDir } = config.build;
let sprite = store.toString();
let { data, spriteHash } = await getSpriteHash(sprite);
let spritePath = path.join(outDir, assetsDir, options.spriteOutputName);

async writeBundle() {
let { assetsDir, outDir } = config.build;
let sprite = store.toString();
let spritePath = path.join(outDir, assetsDir, options.spriteOutputName);
await fse.outputFile(spritePath, sprite);
if (options.logging) {
console.log({
["WRITE_BUNDLE"]: {
hash: spriteHash,
ssr: config.build.ssr,
icons: [...icons.keys()],
},
});
}

await fse.outputFile(spritePath.replace("[hash]", spriteHash), data);
},

configureServer(server) {
server.middlewares.use(async (req, res, next) => {
let url = `/${config.build.assetsDir}/${options.spriteOutputName}`;
let sprite = store.toString();
let { spriteHash, data } = await getSpriteHash(sprite);
url = url.replace("[hash]", spriteHash);
if (options.logging) {
console.log({ url });
}
if (req.url === url) {
res.setHeader("Content-Type", "image/svg+xml");
res.end(data);
} else {
next();
}
});
},
},

configureServer(server) {
let url = `/${config.build.assetsDir}/${options.spriteOutputName}`;
server.middlewares.use((req, res, next) => {
if (req.url === url) {
res.setHeader("Content-Type", "image/svg+xml");
res.end(store.toString());
} else {
next();
// re-transform each imported svg to inject the hash
{
name: `${PLUGIN_NAME}:transform`,
enforce: "post",
async transform(code, id) {
if (svgRegex.test(id)) {
let sprite = store.toString();

let { spriteHash } = await getSpriteHash(sprite);

if (options.logging) {
console.log({
[`${PLUGIN_NAME}:transform`]: {
hash: spriteHash,
ssr: config.build.ssr,
icons: [...icons.keys()],
code,
},
});
}

return {
code: code.replace("[hash]", spriteHash),
map: null,
};
}
});
},
},
};
];
}

async function getSpriteHash(sprite: string) {
let optimized = svgo.optimize(sprite, {
plugins: [
{
name: "preset-default",
params: {
overrides: {
removeHiddenElems: false,
removeUselessDefs: false,
cleanupIds: false,
},
},
},
],
});

let spriteHash = await hash(optimized.data);

return { data: optimized.data, spriteHash };
}
Loading

0 comments on commit b4955f5

Please sign in to comment.