Files
pbakaus_impeccable/scripts/vendor-icons.mjs
T
2026-09-01 10:02:39 +05:00

539 lines
19 KiB
JavaScript

#!/usr/bin/env node
/**
* Vendor one fixed specimen from each open-source icon pack the picker offers.
*
* node scripts/vendor-icons.mjs # rebuild from the pinned versions
* node scripts/vendor-icons.mjs --latest # re-resolve each pack's latest first
* node scripts/vendor-icons.mjs --dry-run # resolve and report, write nothing
*
* Every pack draws the same 24 concepts, so the specimen grid compares packs
* rather than vocabularies. Output is picker/data/icon-packs.json: pack
* metadata plus sanitized inner SVG markup, small enough to ship with the
* picker and offline once it is there.
*/
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const outputPath = path.join(root, 'picker/data/icon-packs.json');
const dryRun = process.argv.includes('--dry-run');
const useLatest = process.argv.includes('--latest');
/** The specimen. Order is the reading order of the grid. */
const CONCEPTS = [
'home', 'search', 'user', 'settings', 'bell', 'heart',
'star', 'calendar', 'mail', 'message', 'trash', 'download',
'upload', 'plus', 'check', 'close', 'chevron-right', 'arrow-right',
'menu', 'filter', 'edit', 'lock', 'eye', 'external-link',
];
/**
* Candidate names per concept, best glyph first. Packs disagree on almost
* every noun here, so the resolver walks the list against each pack's real
* file listing and takes the first hit. Anything this misses gets an explicit
* entry in the pack's `overrides`.
*/
const ALIASES = {
home: ['home', 'house'],
search: ['search', 'magnifying-glass'],
user: ['user', 'person', 'account'],
settings: ['settings', 'gear', 'gear-six', 'cog'],
bell: ['bell', 'notification'],
heart: ['heart', 'favourite'],
star: ['star'],
calendar: ['calendar', 'calendar-blank', 'calendar-days', 'calendar3'],
mail: ['mail', 'envelope', 'envelope-closed', 'email'],
message: [
'message-circle', 'chat-circle', 'chat-bubble-left', 'comment',
'chat-bubble', 'message-square', 'chat-dots', 'message', 'chat',
],
trash: ['trash-2', 'trash', 'trash-can', 'delete-bin'],
download: ['download', 'download-simple', 'arrow-down-tray'],
upload: ['upload', 'upload-simple', 'arrow-up-tray'],
plus: ['plus', 'add'],
check: ['check', 'checkmark', 'tick'],
close: ['x', 'x-mark', 'cross-1', 'xmark', 'close'],
'chevron-right': ['chevron-right', 'caret-right', 'nav-arrow-right', 'arrow-right-s'],
'arrow-right': ['arrow-right'],
menu: ['menu', 'bars-3', 'hamburger-menu', 'three-bars', 'list', 'menu-2'],
filter: ['filter', 'funnel', 'funnel-simple', 'mixer-horizontal'],
edit: ['pencil', 'pencil-1', 'pencil-simple', 'edit-pencil', 'edit-2', 'edit'],
lock: ['lock', 'lock-closed', 'lock-key'],
eye: ['eye', 'eye-open', 'view'],
'external-link': [
'external-link', 'arrow-square-out', 'arrow-top-right-on-square',
'box-arrow-up-right', 'link-external', 'open-new-window',
'square-arrow-out-up-right', 'share-box',
],
};
/**
* One row per pack. `dir` and `suffix` describe where the variant we want
* lives; `count` is measured from the real listing of that variant, not
* copied from a marketing page. `note` is the row's one line of editorial.
*/
const PACKS = [
{
id: 'lucide',
name: 'Lucide',
license: 'ISC',
url: 'https://lucide.dev',
note: 'Feather\u2019s successor. Same 2px round-cap hand, far wider catalog, still shipping.',
grid: '24',
weight: '2px stroke',
source: { kind: 'npm', pkg: 'lucide-static', version: '1.27.0' },
dir: 'icons/',
viewBox: '0 0 24 24',
attrs: {
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
},
},
{
id: 'feather',
name: 'Feather',
license: 'MIT',
url: 'https://feathericons.com',
note: 'The 2px original most stroke sets descend from. Small, even, no longer growing.',
grid: '24',
weight: '2px stroke',
source: { kind: 'npm', pkg: 'feather-icons', version: '4.29.2' },
dir: 'dist/icons/',
viewBox: '0 0 24 24',
attrs: {
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
},
},
{
id: 'phosphor',
name: 'Phosphor',
license: 'MIT',
url: 'https://phosphoricons.com',
note: 'Drawn on a 256 grid with a soft geometric hand. Six weights upstream, regular here.',
grid: '256',
weight: 'regular weight',
source: { kind: 'npm', pkg: '@phosphor-icons/core', version: '2.1.1' },
dir: 'assets/regular/',
viewBox: '0 0 256 256',
attrs: { fill: 'currentColor' },
},
{
id: 'heroicons',
name: 'Heroicons',
license: 'MIT',
url: 'https://heroicons.com',
note: 'Tailwind\u2019s set. 1.5px strokes at 24, with solid and 20px mini twins for dense UI.',
grid: '24',
weight: '1.5px stroke',
source: { kind: 'npm', pkg: 'heroicons', version: '2.2.0' },
dir: '24/outline/',
viewBox: '0 0 24 24',
attrs: {
fill: 'none',
stroke: 'currentColor',
'stroke-width': '1.5',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
},
},
{
id: 'tabler',
name: 'Tabler',
license: 'MIT',
url: 'https://tabler.io/icons',
note: 'The widest stroke catalog here, uniform enough that mixed rows read as one hand.',
grid: '24',
weight: '2px stroke',
source: { kind: 'npm', pkg: '@tabler/icons', version: '3.45.0' },
dir: 'icons/outline/',
viewBox: '0 0 24 24',
attrs: {
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
},
},
{
id: 'radix',
name: 'Radix Icons',
license: 'MIT',
url: 'https://www.radix-ui.com/icons',
note: 'A 15px grid built for product chrome. Crisp at control size, thin if you scale it up.',
grid: '15',
weight: 'filled paths',
source: { kind: 'gh', repo: 'radix-ui/icons', version: '112af91ad275a63c3a29b0da2588342af74ef9bf' },
dir: 'packages/radix-icons/icons/',
viewBox: '0 0 15 15',
attrs: { fill: 'none' },
},
{
id: 'bootstrap',
name: 'Bootstrap Icons',
license: 'MIT',
url: 'https://icons.getbootstrap.com',
note: 'Filled paths on a 16 grid, so weight holds at any size. More literal than the stroke sets.',
grid: '16',
weight: 'filled paths',
source: { kind: 'npm', pkg: 'bootstrap-icons', version: '1.13.1' },
dir: 'icons/',
exclude: (name) => name.endsWith('-fill'),
viewBox: '0 0 16 16',
attrs: { fill: 'currentColor' },
},
{
id: 'iconoir',
name: 'Iconoir',
license: 'MIT',
url: 'https://iconoir.com',
note: '1.5px strokes on 24 with an open, geometric hand. Roomier than Lucide at the same size.',
grid: '24',
weight: '1.5px stroke',
source: { kind: 'npm', pkg: 'iconoir', version: '7.11.1' },
dir: 'icons/regular/',
viewBox: '0 0 24 24',
attrs: {
fill: 'none',
stroke: 'currentColor',
'stroke-width': '1.5',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
},
},
{
id: 'octicons',
name: 'Octicons',
license: 'MIT',
url: 'https://primer.style/octicons',
note: 'GitHub\u2019s set, redrawn per size rather than scaled. Compact and developer-fluent.',
grid: '24',
weight: 'filled paths',
source: { kind: 'npm', pkg: '@primer/octicons', version: '19.31.0' },
dir: 'build/svg/',
suffix: '-24',
viewBox: '0 0 24 24',
attrs: { fill: 'currentColor' },
},
{
id: 'hugeicons',
name: 'Hugeicons',
license: 'MIT (free core)',
url: 'https://hugeicons.com',
note: '1.5px rounded strokes on 24. The free core is one style; the other styles are paid.',
grid: '24',
weight: '1.5px stroke',
source: { kind: 'npm', pkg: '@hugeicons/core-free-icons', version: '4.2.3' },
dir: 'dist/esm/',
extension: '.js',
viewBox: '0 0 24 24',
attrs: {
fill: 'none',
stroke: 'currentColor',
'stroke-width': '1.5',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
},
},
{
id: 'remix',
name: 'Remix Icon',
license: 'Apache-2.0',
url: 'https://remixicon.com',
note: 'Line and fill twins across a broad catalog. Neutral, a touch heavier than the 2px sets.',
grid: '24',
weight: 'filled paths',
source: { kind: 'npm', pkg: 'remixicon', version: '4.9.1' },
dir: 'icons/',
suffix: '-line',
viewBox: '0 0 24 24',
attrs: { fill: 'currentColor' },
},
];
/** Explicit picks where the alias walk lands on a worse glyph, or nothing. */
const OVERRIDES = {
lucide: { edit: 'pencil', menu: 'menu' },
feather: { edit: 'edit-3', menu: 'menu' },
phosphor: { menu: 'list', settings: 'gear-six' },
// Tabler's menu is two rules, and Bootstrap files the funnel apart from the
// stacked-rule filter. Both rows compare better against the other nine.
tabler: { menu: 'menu-2' },
bootstrap: { menu: 'list', message: 'chat', settings: 'gear', filter: 'funnel' },
octicons: { menu: 'three-bars', settings: 'gear', message: 'comment' },
radix: { menu: 'hamburger-menu', settings: 'gear', filter: 'mixer-horizontal' },
// Iconoir's x.svg is the letter, not the close control.
iconoir: { close: 'xmark' },
// Remix files a hand bell under bell and a hex plate under settings, so the
// two rows that should read as notification and preferences do not.
remix: { bell: 'notification-2', settings: 'settings-3' },
// Hugeicons names in PascalCase and numbers its variants, so nothing here is
// guessable from the concept. Every pick is deliberate.
hugeicons: {
home: 'Home01Icon', search: 'Search01Icon', user: 'UserIcon',
settings: 'Settings01Icon', bell: 'Notification01Icon', heart: 'FavouriteIcon',
star: 'StarIcon', calendar: 'Calendar01Icon', mail: 'Mail01Icon',
message: 'BubbleChatIcon', trash: 'Delete02Icon', download: 'Download01Icon',
upload: 'Upload01Icon', plus: 'Add01Icon', check: 'Tick02Icon',
close: 'Cancel01Icon', 'chevron-right': 'ChevronRightIcon',
// ArrowRight01 is Hugeicons' chevron, and LockIcon is a keyhole disc.
'arrow-right': 'ArrowRight02Icon', menu: 'Menu01Icon', filter: 'FilterIcon',
edit: 'PencilEdit01Icon', lock: 'SquareLock01Icon', eye: 'ViewIcon',
'external-link': 'ExternalLinkIcon',
},
};
const ALLOWED_TAGS = new Set([
'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'ellipse', 'g',
]);
const ALLOWED_ATTRS = new Set([
'd', 'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'x1', 'y1', 'x2', 'y2',
'width', 'height', 'points', 'transform', 'fill', 'fill-rule', 'clip-rule',
'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin',
'stroke-miterlimit', 'stroke-dasharray', 'opacity', 'fill-opacity',
'stroke-opacity',
]);
async function getJson(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`${response.status} ${url}`);
return response.json();
}
async function getText(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`${response.status} ${url}`);
return response.text();
}
/** Resolve the version to pin, then list every file the package ships. */
async function listFiles(pack) {
const source = useLatest ? { ...pack.source, version: null } : pack.source;
if (source.kind === 'npm') {
const version = source.version
|| (await getJson(`https://data.jsdelivr.com/v1/packages/npm/${source.pkg}`)).tags.latest;
const listing = await getJson(
`https://data.jsdelivr.com/v1/packages/npm/${source.pkg}@${version}?structure=flat`,
);
return {
version,
base: `https://cdn.jsdelivr.net/npm/${source.pkg}@${version}`,
files: listing.files.map((file) => file.name.replace(/^\//, '')),
};
}
// GitHub sources are pinned to a commit: branches move, and a specimen that
// silently redraws itself is worse than one that fails loudly.
const version = source.version
|| (await getJson(`https://api.github.com/repos/${source.repo}/commits/HEAD`)).sha;
const tree = await getJson(
`https://api.github.com/repos/${source.repo}/git/trees/${version}?recursive=1`,
);
return {
version,
base: `https://raw.githubusercontent.com/${source.repo}/${version}`,
files: tree.tree.filter((node) => node.type === 'blob').map((node) => node.path),
};
}
function basenames(pack, files) {
const extension = pack.extension || '.svg';
const map = new Map();
for (const file of files) {
if (!file.startsWith(pack.dir) || !file.endsWith(extension)) continue;
const name = file.slice(pack.dir.length, -extension.length);
// Remix nests by category (icons/system/home-line.svg); everything else is
// flat, and a nested name would only collide with itself.
map.set(name.includes('/') ? name.slice(name.lastIndexOf('/') + 1) : name, file);
}
return map;
}
function variantNames(pack, names) {
const suffix = pack.suffix || '';
return [...names]
.filter((name) => name.endsWith(suffix))
.filter((name) => !pack.exclude?.(name));
}
function resolveName(pack, concept, names) {
const suffix = pack.suffix || '';
const override = OVERRIDES[pack.id]?.[concept];
const candidates = override ? [override] : ALIASES[concept];
for (const candidate of candidates) {
for (const name of [
`${candidate}${suffix}`,
candidate,
`${candidate}-01${suffix}`,
`${candidate}-1${suffix}`,
]) {
if (names.has(name) && !pack.exclude?.(name)) return name;
}
}
return null;
}
/** Keep drawing elements and drawing attributes. Drop everything else. */
function sanitize(markup) {
const body = markup
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<\?[\s\S]*?\?>/g, '')
.replace(/<(title|desc|style|script|metadata)\b[\s\S]*?<\/\1>/gi, '')
.replace(/<\/?svg\b[^>]*>/gi, '');
let dropped = 0;
const cleaned = body.replace(/<\/?([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>/g, (match, rawTag, rawAttrs) => {
const tag = rawTag.toLowerCase();
if (!ALLOWED_TAGS.has(tag)) {
dropped += 1;
return '';
}
if (match.startsWith('</')) return `</${tag}>`;
// Names carry digits (x1, y2), so the class cannot be letters only.
const attrs = [...rawAttrs.matchAll(/([a-zA-Z][a-zA-Z0-9-]*)\s*=\s*"([^"]*)"/g)]
.map(([, key, value]) => [key.toLowerCase(), value.trim()])
.filter(([key]) => ALLOWED_ATTRS.has(key));
// Tabler opens every icon with an invisible bounding path, and a few packs
// carry the same trick. It draws nothing, so it only costs bytes.
const paint = Object.fromEntries(attrs);
if (paint.fill === 'none' && paint.stroke === 'none') return '';
if (paint.opacity === '0') return '';
const rendered = attrs.map(([key, value]) => `${key}="${value}"`).join(' ');
const selfClosing = match.endsWith('/>');
return `<${tag}${rendered ? ` ${rendered}` : ''}${selfClosing ? '/>' : '>'}`;
});
return { body: cleaned.replace(/>\s+</g, '><').replace(/\s+/g, ' ').trim(), dropped };
}
/**
* Hugeicons ships React-shaped tuples rather than SVG files:
* [["path", { d: "...", key: "0" }], ...]. Same drawing, one hop away.
*/
function tuplesToMarkup(module) {
const start = module.indexOf('[', module.indexOf('='));
const end = module.lastIndexOf(']');
if (start === -1 || end < start) throw new Error('No icon tuple array found');
const literal = module
.slice(start, end + 1)
.replace(/([{,])\s*([A-Za-z][A-Za-z0-9]*)\s*:/g, '$1"$2":');
const tuples = JSON.parse(literal);
return tuples
.map(([tag, props]) => {
const attrs = Object.entries(props)
.filter(([key]) => key !== 'key')
.map(([key, value]) => [key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`), value])
.map(([key, value]) => `${key}="${value}"`)
.join(' ');
return `<${tag} ${attrs}/>`;
})
.join('');
}
const rootAttr = (markup, name) => markup.match(new RegExp(`<svg[^>]*\\b${name}="([^"]*)"`, 'i'))?.[1];
async function vendorPack(pack) {
const { version, base, files } = await listFiles(pack);
const names = basenames(pack, files);
const count = variantNames(pack, names.keys()).length;
if (!count) throw new Error(`${pack.id}: no files under ${pack.dir}`);
const glyphs = [];
const misses = [];
let droppedTotal = 0;
let viewBoxMismatch = null;
for (const concept of CONCEPTS) {
const name = resolveName(pack, concept, names);
if (!name) {
misses.push(concept);
continue;
}
const raw = await getText(`${base}/${encodeURI(names.get(name))}`);
const markup = pack.extension === '.js' ? tuplesToMarkup(raw) : raw;
if (pack.extension !== '.js') {
const viewBox = rootAttr(markup, 'viewBox');
if (viewBox && viewBox !== pack.viewBox) viewBoxMismatch ??= `${name}: ${viewBox}`;
}
const { body, dropped } = sanitize(markup);
droppedTotal += dropped;
if (!body) throw new Error(`${pack.id}/${name}: sanitized to nothing`);
glyphs.push({ concept, name, body });
}
return {
pack: {
id: pack.id,
name: pack.name,
license: pack.license,
url: pack.url,
note: pack.note,
grid: pack.grid,
weight: pack.weight,
count,
version,
viewBox: pack.viewBox,
attrs: pack.attrs,
glyphs,
},
report: { misses, dropped: droppedTotal, viewBoxMismatch },
};
}
const results = await Promise.all(PACKS.map(async (pack) => {
try {
return { pack, ...(await vendorPack(pack)) };
} catch (error) {
return { pack, error };
}
}));
let failed = false;
const packs = [];
for (const result of results) {
if (result.error) {
failed = true;
console.error(`FAIL ${result.pack.id}: ${result.error.message}`);
continue;
}
const { pack, report } = result;
packs.push(pack);
const flags = [
report.misses.length ? `MISSING ${report.misses.join(', ')}` : null,
report.viewBoxMismatch ? `VIEWBOX ${report.viewBoxMismatch}` : null,
report.dropped ? `dropped ${report.dropped} tags` : null,
].filter(Boolean);
if (report.misses.length) failed = true;
console.log(
`${pack.id.padEnd(11)} v${String(pack.version).slice(0, 12).padEnd(13)}`
+ `${String(pack.count).padStart(5)} glyphs ${pack.glyphs.length}/${CONCEPTS.length}`
+ `${flags.length ? ` ${flags.join(' | ')}` : ''}`,
);
console.log(` ${pack.glyphs.map((glyph) => `${glyph.concept}=${glyph.name}`).join(' ')}`);
}
if (!dryRun && packs.length) {
const payload = {
generated: new Date().toISOString().slice(0, 10),
concepts: CONCEPTS,
packs,
};
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(payload)}\n`);
const bytes = Buffer.byteLength(JSON.stringify(payload));
console.log(`\nWrote ${path.relative(root, outputPath)} (${(bytes / 1024).toFixed(0)} KB)`);
}
process.exit(failed ? 1 : 0);