// Reproduce with installed lucide-react 1.69.0 and an extracted @tabler/icons 3.50.0 // tarball: node scripts/generate-canvas-icons.mjs /path/to/tabler/package import { readFile, writeFile, mkdir } from "node:fs/promises"; import { createHash } from "node:crypto"; import { resolve } from "node:path"; import dynamicIconImports from "lucide-react/dynamicIconImports"; import { chinese, keywords } from "./canvas-icon-labels.mjs"; const tablerDir = process.argv[2]; if (!tablerDir) throw new Error("Pass the extracted @tabler/icons 3.50.0 directory"); const tablerPackage = JSON.parse(await readFile(resolve(tablerDir, "package.json"), "utf8")); if (tablerPackage.version !== "3.50.0") throw new Error("Expected @tabler/icons 3.50.0"); const lucidePackage = JSON.parse(await readFile("node_modules/lucide-react/package.json", "utf8")); if (lucidePackage.version !== "1.69.0") throw new Error("Review generated assets when upgrading Lucide"); const pascal = (slug) => { const value = slug.split("-").map((word) => word[0].toUpperCase() + word.slice(1)).join(""); return /^[A-Z]/.test(value) ? value : "Icon" + value; }; const categories = [ ["arrows", /arrow|chevron|navigation|corner|undo|redo|move|expand|shrink/], ["people", /user|person|baby|smile|frown|angry|laugh|heart|hand|thumb|accessibility/], ["office", /file|folder|book|notebook|clipboard|calendar|mail|message|text|type|list|table|pencil|pen-|archive/], ["media", /image|photo|camera|video|movie|film|music|audio|volume|mic|headphone|play|pause|palette|paint|brush|crop/], ["tech", /code|terminal|cpu|circuit|robot|brain|bot|monitor|laptop|phone|computer|database|server|network|wifi|bluetooth|battery|plug|usb|chip/], ["business", /chart|graph|trend|dollar|euro|currency|coin|bank|wallet|credit|shopping|store|briefcase|receipt|badge|ticket/], ["nature", /sun|moon|cloud|snow|rain|wind|leaf|tree|flower|sprout|mountain|dog|cat|fish|bird|bug|rabbit|turtle|paw|flame|waves/], ["travel", /map|pin|compass|globe|plane|train|car|bus|bike|ship|boat|building|house|home|tent|hotel|lighthouse|rocket|ufo/], ["shapes", /circle|square|triangle|hexagon|pentagon|octagon|diamond|star|shape|cube|sphere|box/], ]; const common = new Set(["Star", "Heart", "Lightbulb", "Sparkles", "Zap", "Target", "Check", "Smile", "Coffee", "Leaf", "Sun", "Moon", "Globe", "MapPin", "ArrowRight", "BookOpen", "FileText", "Folder", "Calendar", "Users", "Image", "Camera", "Music", "Palette"]); const entries = new Map(); for (const [slug, load] of Object.entries(dynamicIconImports)) { const name = pascal(slug); const { __iconData } = await load(); entries.set(name, { name, slug, source: "lucide", label: chinese[name] || slug.replaceAll("-", " "), node: __iconData.node }); } const tabler = ["alien", "ufo", "robot", "brain", "circuit-cell", "cube-3d-sphere", "hexagon-3d", "building-lighthouse", "cat", "dog", "fish", "leaf", "sun", "moon", "rocket", "bulb", "heart", "star", "photo", "movie", "music", "palette", "coffee", "mountain"]; const tablerLabels = ["外星人", "飞碟", "机器人", "大脑", "电路", "立体球", "立体六边形", "灯塔", "猫", "狗", "鱼", "叶子", "太阳", "月亮", "火箭", "灯泡", "爱心", "星星", "照片", "电影", "音乐", "调色板", "咖啡", "山峰"]; for (const [index, slug] of tabler.entries()) { const svg = await readFile(resolve(tablerDir, "icons/outline", slug + ".svg"), "utf8"); const node = [...svg.matchAll(/<(path|circle|ellipse|rect|line|polyline|polygon)\s+([^>]+?)\/?\s*>/g)].map((match, index) => [match[1], Object.fromEntries([ ["key", String(index)], ...[...match[2].matchAll(/([\w-]+)="([^"]*)"/g)].map(([, key, value]) => [key.replace(/-([a-z])/g, (_, char) => char.toUpperCase()), value]), ])]); if (!node.length) throw new Error("Empty Tabler SVG " + slug); const name = "Tabler" + pascal(slug); entries.set(name, { name, slug, source: "tabler", label: tablerLabels[index], node }); } const records = [...entries.values()].sort((a, b) => a.name.localeCompare(b.name, "en")); const shards = new Map(); const bucket = (name) => { let hash = 0; for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0; return hash % 96; }; const catalog = records.map(({ node, ...entry }) => { const key = entry.source === "tabler" ? "tabler" : `lucide-${bucket(entry.name)}`; if (!shards.has(key)) shards.set(key, {}); shards.get(key)[entry.name] = node; return { ...entry, categories: categories.filter(([, regex]) => regex.test(entry.slug)).map(([id]) => id), common: common.has(entry.name) || entry.source === "tabler", keywords: entry.slug.split("-").map((word) => keywords[word] || "").join(" ") }; }); const assetDir = "public/canvas-icons/lucide-1.69.0-tabler-3.50.0"; await mkdir(assetDir, { recursive: true }); let totalBytes = 0; const assets = {}; for (const [key, nodes] of shards) { const content = JSON.stringify(nodes); const hash = createHash("sha256").update(content).digest("hex").slice(0, 12); const filename = `${key}-${hash}.json`; assets[key] = filename; await writeFile(`${assetDir}/${filename}`, content + "\n"); totalBytes += Buffer.byteLength(content); } await writeFile("src/components/canvas/icon-catalog-data.json", JSON.stringify(catalog) + "\n"); await writeFile("src/lib/canvas-icon-assets.json", JSON.stringify({ root: "/" + assetDir.replace(/^public\//, ""), assets }) + "\n"); await writeFile(`${assetDir}/LUCIDE-LICENSE.txt`, await readFile("node_modules/lucide-react/LICENSE")); await writeFile(`${assetDir}/TABLER-LICENSE.txt`, await readFile(resolve(tablerDir, "LICENSE"))); console.log(`${records.length} icons (${records.filter((item) => item.source === "tabler").length} Tabler), ${shards.size} data shards, ${totalBytes} uncompressed bytes`);