← Public packages
@kentcdodds/package-app-kit
Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.
scripts/add-lucide-icon.mjs
169 lines · 4.9 KB · JavaScript#!/usr/bin/env node
/**
* Pull named Lucide icons (ISC) into the package as checked-in SVG + Remix TSX.
*
* Usage:
* node scripts/add-lucide-icon.mjs download trash-2 plus
* node scripts/add-lucide-icon.mjs download --dir starter-remix/app/icons
* node scripts/add-lucide-icon.mjs download --out app/icons trash-2
*
* Source: lucide-static on unpkg (pinned). Browse names at https://lucide.dev/icons
* License: ISC — see app/icons/ATTRIBUTION.md
*
* Generated TSX uses Remix `Handle` components (return a render function).
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const pkgRoot = path.resolve(here, '..')
/** Pin for reproducibility; bump intentionally when refreshing icons. */
const LUCIDE_STATIC_VERSION = '0.544.0'
const CDN = `https://unpkg.com/lucide-static@${LUCIDE_STATIC_VERSION}/icons`
function parseArgs(argv) {
const names = []
let outDir = path.join(pkgRoot, 'app/icons')
let alsoStarter = true
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a === '--dir' || a === '--out') {
outDir = path.resolve(pkgRoot, argv[++i] || '')
alsoStarter = false
} else if (a === '--no-starter') {
alsoStarter = false
} else if (a.startsWith('-')) {
throw new Error(`Unknown flag: ${a}`)
} else {
names.push(a)
}
}
return { names, outDir, alsoStarter }
}
function toComponentName(iconName) {
return (
'Icon' +
iconName
.split('-')
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
.join('')
)
}
function svgToTsx(iconName, svgText) {
const component = toComponentName(iconName)
let body = svgText.replace(/<!--[\s\S]*?-->/g, '').trim()
const open = body.match(/^<svg\b[^>]*>/i)?.[0]
if (!open) throw new Error(`Could not parse SVG for ${iconName}`)
const inner = body.replace(/^<svg\b[^>]*>/i, '').replace(/<\/svg>\s*$/i, '').trim()
const paths = inner
.split(/\n/)
.map((l) => l.trim())
.filter(Boolean)
.map((l) => `\t\t\t\t${l}`)
.join('\n')
return `/** @jsxRuntime automatic */
/** @jsxImportSource remix/ui */
/**
* Lucide icon \`${iconName}\` (ISC) — generated by scripts/add-lucide-icon.mjs
* Source: https://lucide.dev/icons/${iconName}
* Remix Handle component (returns a render function). Re-run the script to refresh.
*/
import type { Handle } from 'remix/ui'
export type ${component}Props = {
size?: number
class?: string
}
export function ${component}(handle: Handle<${component}Props>) {
return () => {
const size = handle.props.size ?? 20
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={handle.props.class}
aria-hidden="true"
data-lucide="${iconName}"
>
${paths}
</svg>
)
}
}
`
}
function ensureAttribution(dir) {
const file = path.join(dir, 'ATTRIBUTION.md')
if (fs.existsSync(file)) return
fs.writeFileSync(
file,
`# Icon attribution
Icons in this folder are from [Lucide](https://lucide.dev) ([GitHub](https://github.com/lucide-icons/lucide)).
- License: **ISC** (and MIT for icons derived from Feather — see Lucide LICENSE)
- Added via \`node scripts/add-lucide-icon.mjs <name…>\` from \`lucide-static@${LUCIDE_STATIC_VERSION}\`
- Prefer checked-in SVG + generated Remix Handle TSX over bundling the full Lucide package
Keep this notice when copying icons into scaffolded apps.
`,
)
}
async function fetchIcon(name) {
const url = `${CDN}/${name}.svg`
const res = await fetch(url)
if (!res.ok) {
throw new Error(`Failed to fetch ${name}: ${res.status} ${url}`)
}
return await res.text()
}
async function writeIcon(dir, name, svgText) {
fs.mkdirSync(dir, { recursive: true })
ensureAttribution(dir)
const svgPath = path.join(dir, `${name}.svg`)
const tsxPath = path.join(dir, `${name}.tsx`)
const clean = svgText.replace(/<!--[\s\S]*?-->/g, '').trim() + '\n'
fs.writeFileSync(svgPath, clean)
fs.writeFileSync(tsxPath, svgToTsx(name, svgText))
return { svgPath, tsxPath }
}
const { names, outDir, alsoStarter } = parseArgs(process.argv.slice(2))
if (names.length === 0) {
console.error('Usage: node scripts/add-lucide-icon.mjs <icon-name…> [--dir app/icons]')
process.exit(1)
}
const written = []
for (const name of names) {
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
throw new Error(`Invalid Lucide icon name: ${name}`)
}
const svgText = await fetchIcon(name)
written.push(await writeIcon(outDir, name, svgText))
if (alsoStarter) {
const starterDir = path.join(pkgRoot, 'starter-remix/app/icons')
written.push(await writeIcon(starterDir, name, svgText))
}
}
for (const w of written) {
console.log('wrote', path.relative(pkgRoot, w.svgPath), '+', path.basename(w.tsxPath))
}
console.log(`lucide-static@${LUCIDE_STATIC_VERSION} — https://lucide.dev/icons`)