Skip to content
Source
Sign inSign up

sync-context-chip-icons.mjs

183 lines6.6 KB

1import * as NodeFS from "node:fs";
2import * as NodeModule from "node:module";
3import * as NodePath from "node:path";
4import * as NodeURL from "node:url";
5​
6const scriptDirectory = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
7const appDirectory = NodePath.resolve(scriptDirectory, "..");
8const repositoryRoot = NodePath.resolve(appDirectory, "../..");
9const outputDirectory = NodePath.join(appDirectory, "assets/context-chip-icons");
10const generatedModulePath = NodePath.join(appDirectory, "src/lib/contextChipIcons.generated.ts");
11​
12const requireFromWeb = NodeModule.createRequire(
13 NodePath.join(repositoryRoot, "apps/web/package.json"),
14);
15const requireFromScripts = NodeModule.createRequire(
16 NodePath.join(repositoryRoot, "scripts/package.json"),
17);
18const sharp = requireFromScripts("sharp");
19const lucideIconsDirectory = NodePath.join(
20 NodePath.dirname(requireFromWeb.resolve("lucide-react/package.json")),
21 "dist/esm/icons",
22);
23const webChipSource = NodeFS.readFileSync(
24 NodePath.join(repositoryRoot, "apps/web/src/components/composerInlineChip.ts"),
25 "utf8",
26);
27const skillSvg = webChipSource.match(/SKILL_CHIP_ICON_SVG = `([\s\S]*?)`;/)?.[1];
28​
29if (!skillSvg) {
30 throw new Error(
31 "Could not read SKILL_CHIP_ICON_SVG from apps/web/src/components/composerInlineChip.ts",
32 );
34​
35const lucideIcons = [
36 "bot",
37 "circle-dashed",
38 "film",
39 "git-merge",
40 "git-pull-request-arrow",
41 "git-pull-request-closed",
42 "git-pull-request-draft",
43 "image",
44 "message-circle",
45 "messages-square",
46 "mouse-pointer-click",
47 "server",
48 "terminal",
49];
50​
51async function lucideSvg(name) {
52 const module = await import(
53 NodeURL.pathToFileURL(NodePath.join(lucideIconsDirectory, `${name}.js`)).href
54 );
55 const body = module.__iconNode
56 .map(([tag, { key: _key, ...attributes }]) => {
57 const attributeText = Object.entries(attributes)
58 .map(([attribute, value]) => `${attribute}="${value}"`)
59 .join(" ");
60 return `<${tag} ${attributeText}/>`;
61 })
62 .join("");
63 return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
65​
66async function renderIcon(token, svg) {
67 const sized = svg
68 .replace(/^<svg[^>]*?>/, (tag) =>
69 tag
70 .replace(/ (?:width|height)="[^"]*"/g, "")
71 .replace("<svg ", '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" '),
72 )
73 .replaceAll("currentColor", "#ffffff");
74 await sharp(Buffer.from(sized), { density: 72 * (64 / 24) })
75 .resize(64, 64)
76 .png()
77 .toFile(NodePath.join(outputDirectory, `${token}.png`));
79​
80NodeFS.rmSync(outputDirectory, { recursive: true, force: true });
81NodeFS.mkdirSync(outputDirectory, { recursive: true });
82​
83for (const name of lucideIcons) {
84 await renderIcon(name, await lucideSvg(name));
86await renderIcon("skill", skillSvg);
87​
88// Provider logos, so a model chip can show what the model menu shows. Native chips take images,
89// not SVG. Logos that swap between black and white per theme take the text color, like the chip;
90// colored logos keep their colors and lend the chip their main color as its accent.
91const mobileProviderIcon = NodeFS.readFileSync(
92 NodePath.join(appDirectory, "src/components/ProviderIcon.tsx"),
93 "utf8",
94);
95function bundledPath(viewBox) {
96 const path = mobileProviderIcon
97 .slice(mobileProviderIcon.indexOf(`viewBox="${viewBox}"`))
98 .match(/\bd="([^"]+)"/)?.[1];
99 if (!path) throw new Error(`Could not read the ${viewBox} logo from ProviderIcon.tsx`);
100 return path;
102const packageClients = await Promise.all(
103 ["cursor", "fx", "grok", "kiro", "muse", "opencode", "pi"].map(async (name) => {
104 const module = await import(
105 NodeURL.pathToFileURL(
106 NodePath.join(repositoryRoot, `packages/provider-${name}/src/client.ts`),
107 ).href
108 );
109 const client = Object.values(module).find((value) => value?.icon && value?.driverKind);
110 if (!client) throw new Error(`packages/provider-${name} exports no client with an icon`);
111 return client;
112 }),
113);
114const providerSvg = (viewBox, paths) =>
115 `<svg viewBox="${viewBox}" fill="none">${paths
116 .map(
117 (path) =>
118 `<path d="${path.d}" fill="${path.fill}" fill-opacity="${path.opacity ?? 1}"${
119 path.fillRule ? ` fill-rule="${path.fillRule}" clip-rule="${path.fillRule}"` : ""
120 }/>`,
121 )
122 .join("")}</svg>`;
123const providerLogos = [
124 {
125 driverKind: "codex",
126 tintsIcon: true,
127 svg: providerSvg("100 100 411 411", [
128 { d: bundledPath("100 100 411 411"), fill: "#ffffff", fillRule: "evenodd" },
129 ]),
130 },
131 {
132 driverKind: "claudeAgent",
133 tintsIcon: false,
134 accent: "#D97757",
135 svg: providerSvg("0 0 256 257", [{ d: bundledPath("0 0 256 257"), fill: "#D97757" }]),
136 },
137 ...packageClients.map(({ driverKind, icon }) => {
138 const tintsIcon = icon.fill.light !== icon.fill.dark;
139 return {
140 driverKind,
141 tintsIcon,
142 ...(tintsIcon ? {} : { accent: icon.fill.light }),
143 svg: providerSvg(
144 icon.viewBox,
145 icon.paths.map((path) => ({
146 d: path.d,
147 fillRule: path.fillRule,
148 fill: tintsIcon ? "#ffffff" : (path.fill ?? icon.fill).light,
149 // A tint flattens color, so a logo's secondary shapes keep their contrast through alpha.
150 ...(tintsIcon && path.fill ? { opacity: 0.4 } : {}),
151 })),
152 ),
153 };
154 }),
155];
156for (const { driverKind, svg } of providerLogos) {
157 await renderIcon(`provider-${driverKind}`, svg);
159​
160const tokens = [...lucideIcons, "skill"].sort();
161const generatedSource = `import type { ImageSourcePropType } from "react-native";\n\nexport const CONTEXT_CHIP_ICON_SOURCES = {\n${tokens
162 .map(
163 (token) =>
164 ` ${/^\w+$/.test(token) ? token : `"${token}"`}: require("../../assets/context-chip-icons/${token}.png"),`,
165 )
166 .join("\n")}\n} as const satisfies Readonly<Record<string, ImageSourcePropType>>;\n`;
167const providerSource = `\nexport const PROVIDER_CHIP_ICONS: Readonly<\n Record<
168 string,
169 {
170 readonly source: ImageSourcePropType;
171 readonly tintsIcon: boolean;
172 readonly accent?: string;
173 }
174 >\n> = {\n${providerLogos
175 .toSorted((left, right) => left.driverKind.localeCompare(right.driverKind))
176 .map(
177 ({ driverKind, tintsIcon, accent }) =>
178 ` ${driverKind}: {\n source: require("../../assets/context-chip-icons/provider-${driverKind}.png"),\n tintsIcon: ${tintsIcon},\n${accent ? ` accent: "${accent}",\n` : ""} },`,
179 )
180 .join(
181 "\n",
182 )}\n antigravity: {\n source: require("../../assets/antigravity.png"),\n tintsIcon: false,\n accent: "#5b87bf",\n },\n};\n`;
183NodeFS.writeFileSync(generatedModulePath, generatedSource + providerSource);