Skip to content

Commit

Permalink
feat: bring build-icons into the epic stack (epicweb-dev#340)
Browse files Browse the repository at this point in the history
Co-authored-by: Jacob Paris <[email protected]>
  • Loading branch information
kentcdodds and jacobparis authored Jul 25, 2023
1 parent e3e7ca5 commit 7aa9290
Show file tree
Hide file tree
Showing 8 changed files with 184 additions and 32 deletions.
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,4 @@ node_modules
*.local.*

# generated files
/app/components/ui/icon.svg
/app/components/ui/icon.tsx
/app/components/ui/icons
8 changes: 4 additions & 4 deletions other/svg-icon-template.txt → app/components/ui/icon.tsx
Original file line number Diff line number Diff line change
@@ -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]',
Expand Down
13 changes: 4 additions & 9 deletions docs/icons.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
111 changes: 111 additions & 0 deletions other/build-icons.ts
Original file line number Diff line number Diff line change
@@ -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 = [
`<?xml version="1.0" encoding="UTF-8"?>`,
`<!-- This file is generated by npm run build:icons -->`,
`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="0" height="0">`,
`<defs>`, // for semantics: https://developer.mozilla.org/en-US/docs/Web/SVG/Element/defs
...symbols,
`</defs>`,
`</svg>`,
].join('\n')

return fsExtra.writeFile(outputPath, output, 'utf8')
}
68 changes: 54 additions & 14 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions types/icon-name.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// This file is a fallback until you run npm run build:icons

export type IconName = string

0 comments on commit 7aa9290

Please sign in to comment.