Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← 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`)