generate-brand-marks.mjs
141 lines · archify/scripts/generate-brand-marks.mjs at a406594
1#!/usr/bin/env node
3import fs from 'node:fs';
4import path from 'node:path';
5import { fileURLToPath } from 'node:url';
6import * as simpleIcons from 'simple-icons';
8const here = path.dirname(fileURLToPath(import.meta.url));
9const root = path.resolve(here, '..');
10const catalogPath = path.join(root, 'brand-marks', 'catalog.json');
11const outputPath = path.join(root, 'renderers', 'shared', 'generated-brand-marks.mjs');
12const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
13const simpleIconsVersion = JSON.parse(fs.readFileSync(
14 path.join(root, 'node_modules', 'simple-icons', 'package.json'),
15 'utf8',
16)).version;
17const simpleBySlug = new Map(Object.values(simpleIcons)
18 .filter((icon) => icon && typeof icon === 'object' && icon.slug && icon.path)
19 .map((icon) => [icon.slug, icon]));
21function normalizedList(value) {
22 return [...new Set((Array.isArray(value) ? value : [])
23 .map((item) => String(item).trim())
24 .filter(Boolean))];
25}
27function lookupForms(value) {
28 const raw = String(value ?? '').trim().toLocaleLowerCase('en-US');
29 if (!raw) return [];
30 return [...new Set([
31 raw,
32 raw.replace(/[\s_]+/g, '-'),
33 raw.replace(/[\s_.-]+/g, ''),
34 ])];
35}
37function fail(message) {
38 console.error(`brand catalog: ${message}`);
39 process.exit(1);
40}
42if (catalog.schemaVersion !== 1 || !Array.isArray(catalog.marks) || catalog.marks.length === 0) {
43 fail('catalog.json must contain a non-empty schemaVersion 1 marks array');
44}
46const ids = new Set();
47const lookupKeys = new Map();
48const domains = new Map();
49const generated = catalog.marks.map((entry, index) => {
50 if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.id || '')) fail(`marks[${index}] has an invalid id`);
51 if (ids.has(entry.id)) fail(`duplicate id ${entry.id}`);
52 ids.add(entry.id);
54 const aliases = normalizedList(entry.aliases);
55 const entryDomains = normalizedList(entry.domains).map((domain) => domain.toLowerCase());
56 for (const key of [entry.id, ...aliases]) {
57 for (const form of lookupForms(key)) {
58 if (lookupKeys.has(form) && lookupKeys.get(form) !== entry.id) {
59 fail(`lookup key ${JSON.stringify(key)} is shared by ${lookupKeys.get(form)} and ${entry.id}`);
60 }
61 lookupKeys.set(form, entry.id);
62 }
63 }
64 for (const domain of entryDomains) {
65 if (domains.has(domain) && domains.get(domain) !== entry.id) {
66 fail(`domain ${domain} is shared by ${domains.get(domain)} and ${entry.id}`);
67 }
68 domains.set(domain, entry.id);
69 }
71 let mark;
72 if (entry.simpleIcon) {
73 const icon = simpleBySlug.get(entry.simpleIcon);
74 if (!icon) fail(`${entry.id} references missing Simple Icons slug ${entry.simpleIcon}`);
75 mark = {
76 id: entry.id,
77 title: entry.title || icon.title,
78 category: entry.category,
79 aliases,
80 domains: entryDomains,
81 viewBox: 24,
82 hex: icon.hex,
83 path: icon.path,
84 provenance: {
85 provider: 'Simple Icons',
86 providerVersion: simpleIconsVersion,
87 source: icon.source,
88 ...(icon.guidelines ? { guidelines: icon.guidelines } : {}),
89 ...(icon.license ? { license: icon.license } : {}),
90 },
91 };
92 } else if (entry.custom) {
93 const custom = entry.custom;
94 if (!entry.title || !custom.path || !custom.source || !/^[0-9A-F]{6}$/i.test(custom.hex || '')) {
95 fail(`${entry.id} custom mark requires title, path, source, and six-digit hex`);
96 }
97 mark = {
98 id: entry.id,
99 title: entry.title,
100 category: entry.category,
101 aliases,
102 domains: entryDomains,
103 viewBox: custom.viewBox || 24,
104 hex: custom.hex.toUpperCase(),
105 path: custom.path,
106 provenance: {
107 provider: 'Official brand asset',
108 source: custom.source,
109 ...(custom.guidelines ? { guidelines: custom.guidelines } : {}),
110 },
111 };
112 } else {
113 fail(`${entry.id} must provide simpleIcon or custom`);
114 }
115 if (!mark.category || !mark.title) fail(`${entry.id} is missing category or title`);
116 for (const form of lookupForms(mark.title)) {
117 if (lookupKeys.has(form) && lookupKeys.get(form) !== entry.id) {
118 fail(`title ${JSON.stringify(mark.title)} is shared by ${lookupKeys.get(form)} and ${entry.id}`);
119 }
120 lookupKeys.set(form, entry.id);
121 }
122 return mark;
123}).sort((left, right) => left.id.localeCompare(right.id));
125const banner = `// Generated by scripts/generate-brand-marks.mjs from brand-marks/catalog.json.\n// Simple Icons ${simpleIconsVersion}. Do not edit by hand.\n`;
126const source = `${banner}export const BRAND_MARKS = Object.freeze(${JSON.stringify(generated, null, 2)});\n`;
128if (process.argv.includes('--check')) {
129 const current = fs.existsSync(outputPath)
130 ? fs.readFileSync(outputPath, 'utf8').replace(/\r\n?/g, '\n')
131 : '';
132 if (current !== source) {
133 console.error('generated brand marks are stale — run npm run generate:brand-marks');
134 process.exit(1);
135 }
136} else {
137 const temporary = `${outputPath}.${process.pid}.tmp`;
138 fs.writeFileSync(temporary, source);
139 fs.renameSync(temporary, outputPath);
140 console.log(`generated ${path.relative(root, outputPath)} (${generated.length} marks)`);
141}