From 7aa92901b5b99488d11f1da33ce07c378e0b4220 Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Mon, 24 Jul 2023 20:47:06 -0600 Subject: [PATCH] feat: bring build-icons into the epic stack (#340) Co-authored-by: Jacob Paris --- .gitignore | 3 +- .../components/ui/icon.tsx | 8 +- docs/icons.md | 13 +- other/build-icons.ts | 111 ++++++++++++++++++ package-lock.json | 68 ++++++++--- package.json | 4 +- tsconfig.json | 6 +- types/icon-name.d.ts | 3 + 8 files changed, 184 insertions(+), 32 deletions(-) rename other/svg-icon-template.txt => app/components/ui/icon.tsx (90%) create mode 100644 other/build-icons.ts create mode 100644 types/icon-name.d.ts diff --git a/.gitignore b/.gitignore index 831c73e2b..a3667f155 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,4 @@ node_modules *.local.* # generated files -/app/components/ui/icon.svg -/app/components/ui/icon.tsx +/app/components/ui/icons diff --git a/other/svg-icon-template.txt b/app/components/ui/icon.tsx similarity index 90% rename from other/svg-icon-template.txt rename to app/components/ui/icon.tsx index 1c3887701..3ef0b2f1e 100644 --- a/other/svg-icon-template.txt +++ b/app/components/ui/icon.tsx @@ -1,10 +1,10 @@ -// THIS FILE IS GENERATED, edit ./other/svg-icon-template.txt instead -// then run "npm run build:icons" - import { type SVGProps } from 'react' import { cn } from '~/utils/misc.ts' -import href from './icon.svg' +import href from './icons/sprite.svg' + +import { type IconName } from '@/icon-name' export { href } +export { IconName } const sizeClassName = { font: 'w-[1em] h-[1em]', diff --git a/docs/icons.md b/docs/icons.md index fe23fec45..c0f88509c 100644 --- a/docs/icons.md +++ b/docs/icons.md @@ -4,7 +4,8 @@ The Epic Stack uses SVG sprites for [optimal icon performance](https://benadam.me/thoughts/react-svg-sprites/). You'll find raw SVGs in the `./other/svg-icons` directory. These are then compiled into a sprite using the `npm run build:icons` script which generates -the `app/components/ui/icon.tsx` file and its associated `icon.svg` file. +the `icon.svg` file and an `icons.json` manifest file that allows Typescript to +pick up the names of the icons. You can use [Sly](https://github.com/jacobparis-insiders/sly/tree/main/cli) to add new icons from the command line. @@ -32,14 +33,8 @@ to render it. The `icon` prop is the name of the file without the `.svg` extension. We recommend using `kebab-case` filenames rather than `PascalCase` to avoid casing issues with different operating systems. -Note that [`rmx-cli`](https://github.com/kiliman/rmx-cli) (the tool used to -generate this sprite) automatically removes `width` and `height` props from your -SVGs to ensure they scale properly. - -You can also customize the template used for the `Icon` component by editing the -`./other/svg-icon-template.txt` file. `rmx-cli` will simply add the -`type IconNames` at the bottom of the template based on the available icons in -the sprite. +Note that the `./other/build-icons.ts` file automatically removes `width` and +`height` props from your SVGs to ensure they scale properly. By default, all the icons will have a height and width of `1em` so they should match the font-size of the text they're next to. You can also customize the size diff --git a/other/build-icons.ts b/other/build-icons.ts new file mode 100644 index 000000000..6fa90b5d3 --- /dev/null +++ b/other/build-icons.ts @@ -0,0 +1,111 @@ +import * as path from 'node:path' +import { glob } from 'glob' +import fsExtra from 'fs-extra' +import { parse } from 'node-html-parser' + +const cwd = process.cwd() +const inputDir = path.join(cwd, 'other', 'svg-icons') +const inputDirRelative = path.relative(cwd, inputDir) +const outputDir = path.join(cwd, 'app', 'components', 'ui', 'icons') + +const files = glob.sync('**/*.svg', { + cwd: inputDir, +}) + +const shouldVerboseLog = process.argv.includes('--log=verbose') +const logVerbose = shouldVerboseLog ? console.log : () => {} + +if (files.length === 0) { + console.log(`No SVG files found in ${inputDirRelative}`) +} else { + await generateIconFiles() + console.log(`Generated ${files.length} icons`) +} + +async function generateIconFiles() { + logVerbose(`Generating sprite for ${inputDirRelative}`) + await fsExtra.emptyDir(outputDir) + + const spriteFilepath = path.join(outputDir, 'sprite.svg') + await generateSvgSprite({ + files, + inputDir, + outputPath: spriteFilepath, + }) + + for (const file of files) { + logVerbose('✅', file) + } + logVerbose(`Saved to ${path.relative(cwd, spriteFilepath)}`) + + const iconNames = files.map(file => + JSON.stringify(path.basename(file, '.svg')), + ) + + const typeOutputFilepath = path.join(outputDir, 'name.d.ts') + const typeOutputContent = `// This file is generated by npm run build:icons + +export type IconName = +\t| ${iconNames.join('\n\t| ')}; +` + await fsExtra.writeFile(typeOutputFilepath, typeOutputContent, 'utf8') + + logVerbose(`Manifest saved to ${path.relative(cwd, typeOutputFilepath)}`) + + await fsExtra.writeFile( + path.join(outputDir, 'README.md'), + `# Icons + +This directory contains SVG icons that are used by the app. + +Everything in this directory is generated by \`npm run build:icons\`. +`, + 'utf8', + ) +} + +/** + * Creates a single SVG file that contains all the icons + */ +async function generateSvgSprite({ + files, + inputDir, + outputPath, +}: { + files: string[] + inputDir: string + outputPath: string +}) { + // Each SVG becomes a symbol and we wrap them all in a single SVG + const symbols = await Promise.all( + files.map(async file => { + const input = await fsExtra.readFile(path.join(inputDir, file), 'utf8') + const root = parse(input) + + const svg = root.querySelector('svg') + if (!svg) throw new Error('No SVG element found') + + svg.tagName = 'symbol' + svg.setAttribute('id', file.replace(/\.svg$/, '')) + svg.removeAttribute('xmlns') + svg.removeAttribute('xmlns:xlink') + svg.removeAttribute('version') + svg.removeAttribute('width') + svg.removeAttribute('height') + + return root.toString().trim() + }), + ) + + const output = [ + ``, + ``, + ``, + ``, // for semantics: https://developer.mozilla.org/en-US/docs/Web/SVG/Element/defs + ...symbols, + ``, + ``, + ].join('\n') + + return fsExtra.writeFile(outputPath, output, 'utf8') +} diff --git a/package-lock.json b/package-lock.json index 3f6e9395d..dba1c3b6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -101,13 +101,13 @@ "glob": "^10.3.3", "jsdom": "^22.1.0", "msw": "^1.2.2", + "node-html-parser": "^6.1.5", "npm-run-all": "^4.1.5", "patch-package": "^7.0.0", "prettier": "^2.8.8", "prettier-plugin-sql": "^0.15.1", "prettier-plugin-tailwindcss": "^0.3.0", "remix-flat-routes": "^0.5.10", - "rmx-cli": "^0.4.9", "tsconfig-paths": "^4.2.0", "tsx": "^3.12.7", "typescript": "^5.1.6", @@ -7616,6 +7616,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true + }, "node_modules/bplist-parser": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.2.0.tgz", @@ -8453,6 +8459,22 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/css-what": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", @@ -11548,6 +11570,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, "node_modules/headers-polyfill": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-3.1.2.tgz", @@ -14859,6 +14890,16 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/node-html-parser": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.5.tgz", + "integrity": "sha512-fAaM511feX++/Chnhe475a0NHD8M7AxDInsqQpz6x63GRF7xYNdS8Vo5dKsIVPgsOvG7eioRRTZQnWBrhDHBSg==", + "dev": true, + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, "node_modules/node-releases": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", @@ -15148,6 +15189,18 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nwsapi": { "version": "2.2.7", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz", @@ -17662,19 +17715,6 @@ "node": "*" } }, - "node_modules/rmx-cli": { - "version": "0.4.9", - "resolved": "https://registry.npmjs.org/rmx-cli/-/rmx-cli-0.4.9.tgz", - "integrity": "sha512-FMuSclyem3hzdYuGvXvyM6t9zb1zBhR2VbbVM2zL6w5z9VcV/Y9Pz70qZEzKauWUFghv4cQuvrUnseOtl7AOcw==", - "dev": true, - "dependencies": { - "@npmcli/package-json": "^2.0.0", - "node-fetch": "^2.6.7" - }, - "bin": { - "rmx": "dist/cli.js" - } - }, "node_modules/rollup": { "version": "3.26.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.26.3.tgz", diff --git a/package.json b/package.json index c96040c7a..20c81f128 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "scripts": { "postinstall": "patch-package --patch-dir ./other/patches", "build": "run-s build:*", - "build:icons": "rmx svg-sprite other/svg-icons app/components/ui/icon.tsx --template=./other/svg-icon-template.txt", + "build:icons": "tsx ./other/build-icons.ts", "build:remix": "remix build --sourcemap", "build:server": "tsx ./other/build-server.ts", "dev": "remix dev -c \"node ./server/dev-server.js\" --no-restart", @@ -130,13 +130,13 @@ "glob": "^10.3.3", "jsdom": "^22.1.0", "msw": "^1.2.2", + "node-html-parser": "^6.1.5", "npm-run-all": "^4.1.5", "patch-package": "^7.0.0", "prettier": "^2.8.8", "prettier-plugin-sql": "^0.15.1", "prettier-plugin-tailwindcss": "^0.3.0", "remix-flat-routes": "^0.5.10", - "rmx-cli": "^0.4.9", "tsconfig-paths": "^4.2.0", "tsx": "^3.12.7", "typescript": "^5.1.6", diff --git a/tsconfig.json b/tsconfig.json index 5f127089f..4dd7307c4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,11 @@ "baseUrl": ".", "paths": { "~/*": ["./app/*"], - "tests/*": ["./tests/*"] + "tests/*": ["./tests/*"], + "@/icon-name": [ + "app/components/ui/icons/name.d.ts", + "types/icon-name.d.ts" + ] }, "skipLibCheck": true, "allowImportingTsExtensions": true, diff --git a/types/icon-name.d.ts b/types/icon-name.d.ts new file mode 100644 index 000000000..6f47700d3 --- /dev/null +++ b/types/icon-name.d.ts @@ -0,0 +1,3 @@ +// This file is a fallback until you run npm run build:icons + +export type IconName = string