inkbridge 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +201 -0
  2. package/bin/inkhouse.mjs +171 -0
  3. package/code.js +11802 -0
  4. package/manifest.json +30 -0
  5. package/package.json +45 -0
  6. package/scanner/blob-placement-regression.ts +132 -0
  7. package/scanner/class-collector.ts +69 -0
  8. package/scanner/cli.ts +336 -0
  9. package/scanner/component-scanner.ts +2876 -0
  10. package/scanner/css-patch-regression.ts +112 -0
  11. package/scanner/css-token-reader-regression.ts +92 -0
  12. package/scanner/css-token-reader.ts +477 -0
  13. package/scanner/font-style-resolver-regression.ts +32 -0
  14. package/scanner/index.ts +9 -0
  15. package/scanner/radial-gradient-regression.ts +53 -0
  16. package/scanner/style-map.ts +149 -0
  17. package/scanner/tailwind-parser.ts +644 -0
  18. package/scanner/transform-math-regression.ts +42 -0
  19. package/scanner/types.ts +298 -0
  20. package/src/blob-placement.ts +111 -0
  21. package/src/change-detection.ts +204 -0
  22. package/src/class-utils.ts +105 -0
  23. package/src/clip-path-decorative.ts +194 -0
  24. package/src/color-resolver.ts +98 -0
  25. package/src/colors.ts +196 -0
  26. package/src/component-defs.ts +54 -0
  27. package/src/component-gen.ts +561 -0
  28. package/src/component-lookup.ts +82 -0
  29. package/src/config.ts +115 -0
  30. package/src/design-system.ts +59 -0
  31. package/src/dev-server.ts +173 -0
  32. package/src/figma-globals.d.ts +3 -0
  33. package/src/font-style-resolver.ts +171 -0
  34. package/src/github.ts +1465 -0
  35. package/src/icon-builder.ts +607 -0
  36. package/src/image-cache.ts +22 -0
  37. package/src/inline-text.ts +271 -0
  38. package/src/layout-parser.ts +667 -0
  39. package/src/layout-utils.ts +155 -0
  40. package/src/main.ts +687 -0
  41. package/src/node-ir.ts +595 -0
  42. package/src/pack-provider.ts +148 -0
  43. package/src/packs.ts +126 -0
  44. package/src/radial-gradient.ts +84 -0
  45. package/src/render-context.ts +138 -0
  46. package/src/responsive-analyzer.ts +139 -0
  47. package/src/state-analyzer.ts +143 -0
  48. package/src/story-builder.ts +1706 -0
  49. package/src/story-layout.ts +38 -0
  50. package/src/tailwind.ts +2379 -0
  51. package/src/text-builder.ts +116 -0
  52. package/src/text-line.ts +42 -0
  53. package/src/token-source.ts +43 -0
  54. package/src/tokens.ts +717 -0
  55. package/src/transform-math.ts +44 -0
  56. package/src/ui-builder.ts +1996 -0
  57. package/src/utility-resolver.ts +125 -0
  58. package/src/variables.ts +1042 -0
  59. package/src/width-solver.ts +466 -0
  60. package/templates/patch-tokens-route.ts +165 -0
  61. package/templates/scan-components-route.ts +57 -0
  62. package/ui.html +1222 -0
package/manifest.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "Inkbridge",
3
+ "id": "inkbridge-tokens-plugin",
4
+ "api": "1.0.0",
5
+ "main": "code.js",
6
+ "ui": "ui.html",
7
+ "capabilities": ["codegen"],
8
+ "codegenLanguages": [
9
+ { "label": "Tailwind CSS", "value": "PLAINTEXT" }
10
+ ],
11
+ "editorType": ["figma", "dev"],
12
+ "networkAccess": {
13
+ "allowedDomains": [
14
+ "https://api.github.com"
15
+ ],
16
+ "devAllowedDomains": [
17
+ "http://localhost:4000",
18
+ "http://localhost:3000",
19
+ "http://localhost:5173"
20
+ ],
21
+ "reasoning": "Fetches component definitions from local dev server for Design System generation, and uses GitHub API to create PRs for design token syncing."
22
+ },
23
+ "menu": [
24
+ { "name": "Push to Code", "command": "push" },
25
+ { "name": "Settings", "command": "settings" },
26
+ { "separator": true },
27
+ { "name": "Generate Design System Page", "command": "generate" },
28
+ { "name": "Debug Selection (Console)", "command": "debug-selection" }
29
+ ]
30
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "inkbridge",
3
+ "version": "0.1.0-beta.1",
4
+ "description": "Figma plugin that generates a pixel-accurate design system from your Tailwind React components.",
5
+ "type": "module",
6
+ "bin": {
7
+ "inkbridge": "./bin/inkbridge.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "scanner/",
12
+ "src/",
13
+ "templates/",
14
+ "code.js",
15
+ "ui.html",
16
+ "manifest.json",
17
+ "README.md"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "scripts": {
23
+ "build": "node build.mjs",
24
+ "watch": "node build.mjs --watch",
25
+ "doctor": "cd ../.. && node scripts/figma-doctor.mjs",
26
+ "scan": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin-tailwind-tokens/scanner/cli.ts",
27
+ "test:blob": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin-tailwind-tokens/scanner/blob-placement-regression.ts",
28
+ "test:tokens": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin-tailwind-tokens/scanner/css-token-reader-regression.ts",
29
+ "test:font": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin-tailwind-tokens/scanner/font-style-resolver-regression.ts",
30
+ "test:radial": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin-tailwind-tokens/scanner/radial-gradient-regression.ts",
31
+ "test:transform": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin-tailwind-tokens/scanner/transform-math-regression.ts",
32
+ "test:csspatch": "cd ../.. && ./node_modules/.bin/tsx tools/figma-plugin-tailwind-tokens/scanner/css-patch-regression.ts",
33
+ "verify": "pnpm run scan && pnpm run test:blob && pnpm run test:tokens && pnpm run test:font && pnpm run test:radial && pnpm run test:transform && pnpm run test:csspatch && pnpm run build",
34
+ "postinstall": "node ./bin/inkbridge.mjs postinstall",
35
+ "prepublishOnly": "node build.mjs"
36
+ },
37
+ "keywords": ["figma", "plugin", "tailwind", "design-tokens", "storybook"],
38
+ "license": "MIT",
39
+ "homepage": "https://inkbridge.io",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "https://github.com/inkn9ne/inkbridge.git",
43
+ "directory": "tools/figma-plugin-tailwind-tokens"
44
+ }
45
+ }
@@ -0,0 +1,132 @@
1
+ import assert from 'node:assert/strict';
2
+ import { resolveBlobDimensions, resolveBlobPlacement } from '../src/blob-placement';
3
+ import { getClassesForBreakpoint } from '../src/responsive-analyzer';
4
+
5
+ type CaseDef = {
6
+ name: string;
7
+ classes: string[];
8
+ breakpoint: 'base' | 'sm';
9
+ containerWidth: number;
10
+ expected: {
11
+ width: number;
12
+ height: number;
13
+ layoutLeft: number;
14
+ centerX: number;
15
+ rotateDeg: number;
16
+ topOffset: number;
17
+ };
18
+ };
19
+
20
+ const BLOB1_CLASSES = [
21
+ 'relative',
22
+ 'left-[calc(50%-11rem)]',
23
+ 'aspect-1155/678',
24
+ 'w-144.5',
25
+ '-translate-x-1/2',
26
+ 'rotate-30',
27
+ 'bg-linear-to-tr',
28
+ 'from-primary-foreground',
29
+ 'to-primary',
30
+ 'opacity-30',
31
+ 'sm:left-[calc(50%-30rem)]',
32
+ 'sm:w-288.75',
33
+ ];
34
+
35
+ const BLOB2_CLASSES = [
36
+ 'relative',
37
+ 'left-[calc(50%+3rem)]',
38
+ 'aspect-1155/678',
39
+ 'w-144.5',
40
+ '-translate-x-1/2',
41
+ 'bg-linear-to-tr',
42
+ 'from-primary-foreground',
43
+ 'to-primary',
44
+ 'opacity-30',
45
+ 'sm:left-[calc(50%+36rem)]',
46
+ 'sm:w-288.75',
47
+ ];
48
+
49
+ const CASES: CaseDef[] = [
50
+ {
51
+ name: 'blob1-base',
52
+ classes: BLOB1_CLASSES,
53
+ breakpoint: 'base',
54
+ containerWidth: 600,
55
+ expected: {
56
+ width: 578,
57
+ height: (578 * 678) / 1155,
58
+ layoutLeft: 124,
59
+ centerX: 124,
60
+ rotateDeg: 30,
61
+ topOffset: 0,
62
+ },
63
+ },
64
+ {
65
+ name: 'blob1-sm',
66
+ classes: BLOB1_CLASSES,
67
+ breakpoint: 'sm',
68
+ containerWidth: 1024,
69
+ expected: {
70
+ width: 1155,
71
+ height: 678,
72
+ layoutLeft: 32,
73
+ centerX: 32,
74
+ rotateDeg: 30,
75
+ topOffset: 0,
76
+ },
77
+ },
78
+ {
79
+ name: 'blob2-base',
80
+ classes: BLOB2_CLASSES,
81
+ breakpoint: 'base',
82
+ containerWidth: 600,
83
+ expected: {
84
+ width: 578,
85
+ height: (578 * 678) / 1155,
86
+ layoutLeft: 348,
87
+ centerX: 348,
88
+ rotateDeg: 0,
89
+ topOffset: 0,
90
+ },
91
+ },
92
+ {
93
+ name: 'blob2-sm',
94
+ classes: BLOB2_CLASSES,
95
+ breakpoint: 'sm',
96
+ containerWidth: 1024,
97
+ expected: {
98
+ width: 1155,
99
+ height: 678,
100
+ layoutLeft: 1088,
101
+ centerX: 1088,
102
+ rotateDeg: 0,
103
+ topOffset: 0,
104
+ },
105
+ },
106
+ ];
107
+
108
+ function approxEqual(actual: number, expected: number, epsilon = 0.01): void {
109
+ assert.ok(Math.abs(actual - expected) <= epsilon, `expected ${expected}, got ${actual}`);
110
+ }
111
+
112
+ for (const testCase of CASES) {
113
+ const active = testCase.breakpoint === 'base'
114
+ ? testCase.classes
115
+ : getClassesForBreakpoint(testCase.classes, testCase.breakpoint);
116
+ const dimensions = resolveBlobDimensions(active);
117
+ const placement = resolveBlobPlacement(
118
+ active,
119
+ testCase.containerWidth,
120
+ dimensions.vectorWidth,
121
+ dimensions.vectorHeight,
122
+ );
123
+
124
+ approxEqual(dimensions.vectorWidth, testCase.expected.width);
125
+ approxEqual(dimensions.vectorHeight, testCase.expected.height);
126
+ approxEqual(placement.layoutLeft, testCase.expected.layoutLeft);
127
+ approxEqual(placement.desiredCenterX, testCase.expected.centerX);
128
+ approxEqual(placement.cssRotateDeg, testCase.expected.rotateDeg);
129
+ approxEqual(placement.topOffset, testCase.expected.topOffset);
130
+ }
131
+
132
+ console.log(`blob placement regression passed (${CASES.length} cases)`);
@@ -0,0 +1,69 @@
1
+ import type { ComponentAnalysis, StoryInfo, JsxNode, JsxElement } from './types';
2
+
3
+ function splitClasses(value?: string): string[] {
4
+ if (!value) return [];
5
+ return value.split(/\s+/).filter(Boolean);
6
+ }
7
+
8
+ function collectFromJsx(node?: JsxNode, out: string[] = []): string[] {
9
+ if (!node) return out;
10
+ if (node.type === 'element') {
11
+ const el = node as JsxElement;
12
+ const className = (el.props && el.props.className) || '';
13
+ out.push(...splitClasses(className));
14
+ for (const child of el.children || []) {
15
+ collectFromJsx(child, out);
16
+ }
17
+ }
18
+ return out;
19
+ }
20
+
21
+ function collectFromStory(story: StoryInfo): string[] {
22
+ const classes: string[] = [];
23
+ if (story.layoutClasses) classes.push(...story.layoutClasses);
24
+ if (story.instances) {
25
+ for (const inst of story.instances) {
26
+ if (inst.props && inst.props.className) {
27
+ classes.push(...splitClasses(inst.props.className));
28
+ }
29
+ }
30
+ }
31
+ if (story.jsxTree) {
32
+ collectFromJsx(story.jsxTree, classes);
33
+ }
34
+ return classes;
35
+ }
36
+
37
+ export function collectAllClasses(analyses: ComponentAnalysis[]): string[] {
38
+ const classes: string[] = [];
39
+
40
+ for (const analysis of analyses) {
41
+ if (analysis.type === 'cva') {
42
+ classes.push(...analysis.baseClasses);
43
+ for (const variantGroup of Object.values(analysis.variantClasses)) {
44
+ for (const cls of Object.values(variantGroup)) {
45
+ classes.push(...cls);
46
+ }
47
+ }
48
+ } else if (analysis.type === 'compound') {
49
+ for (const sub of analysis.subComponents) {
50
+ classes.push(...sub.classes);
51
+ }
52
+ } else if (analysis.type === 'state') {
53
+ classes.push(...analysis.baseClasses);
54
+ for (const state of Object.values(analysis.states)) {
55
+ classes.push(...state.classes);
56
+ }
57
+ } else if (analysis.type === 'simple') {
58
+ classes.push(...analysis.classes);
59
+ }
60
+
61
+ if (analysis.stories) {
62
+ for (const story of analysis.stories) {
63
+ classes.push(...collectFromStory(story));
64
+ }
65
+ }
66
+ }
67
+
68
+ return classes;
69
+ }
package/scanner/cli.ts ADDED
@@ -0,0 +1,336 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Component Scanner CLI
4
+ *
5
+ * Scans component files and generates a JSON definitions file
6
+ * that the Figma plugin uses to create components.
7
+ *
8
+ * Usage (consumer project, after `pnpm add -D inkbridge && pnpm exec inkbridge setup`):
9
+ * pnpm figma:scan — run scanner manually
10
+ * GET /api/figma/scan-components — called automatically by the Figma plugin
11
+ *
12
+ * Usage (monorepo dev):
13
+ * npx tsx tools/figma-plugin-tailwind-tokens/scanner/cli.ts
14
+ */
15
+
16
+ import * as fs from 'fs';
17
+ import * as path from 'path';
18
+ import React from 'react';
19
+ import { renderToStaticMarkup } from 'react-dom/server';
20
+ import { ComponentScanner } from './component-scanner';
21
+ import { extractColorTokens, extractPaletteTokens, TAILWIND_SPACING, inferLayout, groupClassesByBreakpoint, groupClassesByColorScheme } from './tailwind-parser';
22
+ import { collectAllClasses } from './class-collector';
23
+ import { buildStyleMap } from './style-map';
24
+ import { readTokenSourceMap } from './css-token-reader';
25
+ import type { ComponentDefinitions, ScannerConfig, LayoutInfo, ResponsiveInfo, ColorSchemeInfo, IconImportSpec, IconRegistryEntry } from './types';
26
+ import type { TokenSourceMode } from '../src/token-source';
27
+
28
+ // ============================================================================
29
+ // CLI flags
30
+ // ============================================================================
31
+
32
+ const argv = process.argv.slice(2);
33
+ const getFlag = (flag: string): string | undefined => {
34
+ const i = argv.indexOf(flag);
35
+ return i !== -1 ? argv[i + 1] : undefined;
36
+ };
37
+ const hasFlag = (flag: string): boolean => argv.includes(flag);
38
+
39
+ const CLI_OUTPUT = getFlag('--output');
40
+ const CLI_INCLUDE_TOKENS = hasFlag('--include-tokens');
41
+ const CLI_TOKEN_MODE = (getFlag('--token-mode') || 'auto') as TokenSourceMode;
42
+ const CLI_CSS_TOKEN_PATH = getFlag('--css-token-path');
43
+ const CLI_DTCG_TOKEN_PATH = getFlag('--dtcg-token-path');
44
+
45
+ // ============================================================================
46
+ // Configuration
47
+ // ============================================================================
48
+
49
+ const DEFAULT_CONFIG: ScannerConfig = {
50
+ componentPaths: ['src'],
51
+ filePattern: '**/*.tsx',
52
+ exclude: ['index'],
53
+ onlyWithStories: true,
54
+ iconPackages: ['lucide-react', 'react-icons/'],
55
+ };
56
+
57
+ const COMPONENT_DEFS_SCHEMA_VERSION = 1;
58
+
59
+ // ============================================================================
60
+ // Main
61
+ // ============================================================================
62
+
63
+ async function main() {
64
+ console.log('šŸ” Scanning components...\n');
65
+
66
+ const config = loadConfig();
67
+ const scanner = new ComponentScanner(config);
68
+
69
+ try {
70
+ const analyses = await scanner.scanAll();
71
+
72
+ // Collect all classes used across components + stories
73
+ const allClasses = collectAllClasses(analyses);
74
+
75
+ const colorTokens = extractColorTokens(allClasses);
76
+ const paletteTokens = extractPaletteTokens(allClasses);
77
+ const styleMap = await buildStyleMap(allClasses);
78
+ const iconRegistry = await buildIconRegistry(scanner.getIconRegistry());
79
+
80
+ // Enrich each analysis with layout, responsive, and color-scheme info
81
+ const enriched = analyses.map(analysis => {
82
+ const classes = getAllClassesFromAnalysis(analysis);
83
+ const layout = inferLayout(classes);
84
+ const breakpoints = groupClassesByBreakpoint(classes);
85
+ const hasResponsiveVisibility = classes.some(c =>
86
+ /^(sm|md|lg|xl|2xl):(hidden|flex|block|grid|inline|table|contents)$/.test(c) ||
87
+ /^(hidden)\s/.test(c) // "hidden" at base with responsive show
88
+ );
89
+ const responsive: ResponsiveInfo = { breakpoints, hasResponsiveVisibility };
90
+ const colorScheme = groupClassesByColorScheme(classes);
91
+
92
+ return { analysis, layout, responsive, colorScheme };
93
+ });
94
+
95
+ // Build output
96
+ const output: ComponentDefinitions = {
97
+ schemaVersion: COMPONENT_DEFS_SCHEMA_VERSION,
98
+ version: '1.0.0',
99
+ generatedAt: new Date().toISOString(),
100
+ components: enriched,
101
+ spacingScale: TAILWIND_SPACING,
102
+ colorTokens,
103
+ paletteTokens,
104
+ iconRegistry,
105
+ styleMap,
106
+ };
107
+
108
+ // Optionally include token map when called with --include-tokens
109
+ const finalOutput: typeof output & { tokens?: ReturnType<typeof readTokenSourceMap> } = output;
110
+ if (CLI_INCLUDE_TOKENS) {
111
+ finalOutput.tokens = readTokenSourceMap({
112
+ projectRoot: process.cwd(),
113
+ tokenSourceMode: CLI_TOKEN_MODE,
114
+ cssTokenPath: CLI_CSS_TOKEN_PATH,
115
+ dtcgTokenPath: CLI_DTCG_TOKEN_PATH,
116
+ });
117
+ }
118
+
119
+ // Write to JSON file
120
+ const defaultOutputPath = 'tools/figma-plugin-tailwind-tokens/component-definitions.json';
121
+ const outputPath = CLI_OUTPUT
122
+ ? path.resolve(process.cwd(), CLI_OUTPUT)
123
+ : path.resolve(process.cwd(), defaultOutputPath);
124
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
125
+ fs.writeFileSync(outputPath, JSON.stringify(finalOutput, null, 2));
126
+
127
+ // Print summary
128
+ console.log('āœ… Scanned components:\n');
129
+
130
+ const byType = {
131
+ cva: enriched.filter(e => e.analysis.type === 'cva'),
132
+ compound: enriched.filter(e => e.analysis.type === 'compound'),
133
+ state: enriched.filter(e => e.analysis.type === 'state'),
134
+ simple: enriched.filter(e => e.analysis.type === 'simple'),
135
+ };
136
+
137
+ // Helper to format story info
138
+ const storyStr = (a: import('./types').ComponentAnalysis): string => {
139
+ if (!a.stories || a.stories.length === 0) return '';
140
+ const names = a.stories.map(s => s.name).join(', ');
141
+ const totalInstances = a.stories.reduce((sum, s) => sum + s.instances.length, 0);
142
+ return ` — ${a.stories.length} stories (${names}) [${totalInstances} instances]`;
143
+ };
144
+
145
+ if (byType.cva.length > 0) {
146
+ console.log(' CVA Components (variants + states):');
147
+ for (const e of byType.cva) {
148
+ const a = e.analysis;
149
+ if (a.type === 'cva') {
150
+ const variantCount = Object.keys(a.variants).length;
151
+ const totalVariants = Object.values(a.variants).reduce((sum, arr) => sum + arr.length, 0);
152
+ console.log(` • ${a.name} (${variantCount} variant groups, ${totalVariants} values)${storyStr(a)}`);
153
+ }
154
+ }
155
+ console.log();
156
+ }
157
+
158
+ if (byType.compound.length > 0) {
159
+ console.log(' Compound Components (sub-components):');
160
+ for (const e of byType.compound) {
161
+ const a = e.analysis;
162
+ if (a.type === 'compound') {
163
+ console.log(` • ${a.name} (${a.subComponents.length} sub-components)${storyStr(a)}`);
164
+ for (const sub of a.subComponents) {
165
+ console.log(` - ${sub.name} [${sub.slot}]`);
166
+ }
167
+ }
168
+ }
169
+ console.log();
170
+ }
171
+
172
+ if (byType.state.length > 0) {
173
+ console.log(' State Components (state modifiers):');
174
+ for (const e of byType.state) {
175
+ const a = e.analysis;
176
+ if (a.type === 'state') {
177
+ const states = Object.keys(a.states).join(', ');
178
+ console.log(` • ${a.name} (states: ${states})${storyStr(a)}`);
179
+ }
180
+ }
181
+ console.log();
182
+ }
183
+
184
+ if (byType.simple.length > 0) {
185
+ console.log(' Simple Components:');
186
+ for (const e of byType.simple) {
187
+ console.log(` • ${e.analysis.name}${storyStr(e.analysis)}`);
188
+ }
189
+ console.log();
190
+ }
191
+
192
+ const totalStories = analyses.reduce((sum, a) => sum + (a.stories?.length || 0), 0);
193
+ console.log(`\nšŸ“¦ Output written to: ${outputPath}`);
194
+ console.log(` ${analyses.length} components, ${totalStories} stories, ${colorTokens.length} color tokens`);
195
+
196
+ } catch (err) {
197
+ console.error('āŒ Scanner failed:', err);
198
+ process.exit(1);
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Extract all classes from any component analysis type
204
+ */
205
+ function getAllClassesFromAnalysis(analysis: import('./types').ComponentAnalysis): string[] {
206
+ const classes: string[] = [];
207
+ if (analysis.type === 'cva') {
208
+ classes.push(...analysis.baseClasses);
209
+ for (const variantGroup of Object.values(analysis.variantClasses)) {
210
+ for (const cls of Object.values(variantGroup)) {
211
+ classes.push(...cls);
212
+ }
213
+ }
214
+ } else if (analysis.type === 'compound') {
215
+ for (const sub of analysis.subComponents) {
216
+ classes.push(...sub.classes);
217
+ }
218
+ } else if (analysis.type === 'state') {
219
+ classes.push(...analysis.baseClasses);
220
+ for (const state of Object.values(analysis.states)) {
221
+ classes.push(...state.classes);
222
+ }
223
+ } else if (analysis.type === 'simple') {
224
+ classes.push(...analysis.classes);
225
+ }
226
+ return classes;
227
+ }
228
+
229
+ /**
230
+ * Extract component paths from Storybook's stories globs.
231
+ * e.g. "../src/**\/\*.stories.@(ts|tsx)" → ["src"]
232
+ */
233
+ function detectStorybookPaths(cwd: string): string[] {
234
+ const candidates = [
235
+ '.storybook/main.ts',
236
+ '.storybook/main.js',
237
+ '.storybook/main.mjs',
238
+ '.storybook/main.cjs',
239
+ ];
240
+
241
+ for (const candidate of candidates) {
242
+ const fullPath = path.resolve(cwd, candidate);
243
+ if (!fs.existsSync(fullPath)) continue;
244
+
245
+ const content = fs.readFileSync(fullPath, 'utf-8');
246
+
247
+ // Extract the stories array value (handles single or multi-line)
248
+ const storiesMatch = content.match(/stories\s*:\s*\[([^\]]+)\]/s);
249
+ if (!storiesMatch) continue;
250
+
251
+ const globs = [...storiesMatch[1].matchAll(/["'`]([^"'`]+)["'`]/g)].map(m => m[1]);
252
+ const dirs = new Set<string>();
253
+
254
+ for (const glob of globs) {
255
+ // Resolve relative to the project root (strip leading "../" from .storybook/ context)
256
+ const normalized = glob.replace(/^\.\.\//, '');
257
+ // Take the first non-glob path segment as the root directory
258
+ const firstSegment = normalized.split('/')[0];
259
+ if (firstSegment && !firstSegment.includes('*') && !firstSegment.includes('{')) {
260
+ dirs.add(firstSegment);
261
+ }
262
+ }
263
+
264
+ if (dirs.size > 0) {
265
+ console.log(` ↳ Detected story paths from ${candidate}: ${[...dirs].join(', ')}`);
266
+ return [...dirs];
267
+ }
268
+ }
269
+
270
+ return [];
271
+ }
272
+
273
+ function loadConfig(): ScannerConfig {
274
+ const cwd = process.cwd();
275
+
276
+ // 1. inkbridge.config.json (primary)
277
+ const inkbridgeCfg = path.resolve(cwd, 'inkbridge.config.json');
278
+ if (fs.existsSync(inkbridgeCfg)) {
279
+ const custom = JSON.parse(fs.readFileSync(inkbridgeCfg, 'utf-8'));
280
+ return { ...DEFAULT_CONFIG, ...custom };
281
+ }
282
+
283
+ // 2. Legacy figma-scanner.config.json
284
+ const legacyCfg = path.resolve(cwd, 'figma-scanner.config.json');
285
+ if (fs.existsSync(legacyCfg)) {
286
+ const custom = JSON.parse(fs.readFileSync(legacyCfg, 'utf-8'));
287
+ return { ...DEFAULT_CONFIG, ...custom };
288
+ }
289
+
290
+ // 3. Auto-detect from .storybook/main.{ts,js,mjs}
291
+ const storybookPaths = detectStorybookPaths(cwd);
292
+ if (storybookPaths.length > 0) {
293
+ return { ...DEFAULT_CONFIG, componentPaths: storybookPaths };
294
+ }
295
+
296
+ // 4. Fallback: scan all of src/
297
+ return DEFAULT_CONFIG;
298
+ }
299
+
300
+ async function buildIconRegistry(imports: Record<string, IconImportSpec>): Promise<Record<string, IconRegistryEntry>> {
301
+ const entries: Record<string, IconRegistryEntry> = {};
302
+ const names = Object.keys(imports);
303
+ for (const localName of names) {
304
+ const spec = imports[localName];
305
+ if (!spec) continue;
306
+ try {
307
+ const mod = await import(spec.module);
308
+ const IconComponent = (mod as any)[spec.exportName];
309
+ if (!IconComponent) continue;
310
+ const element = React.createElement(IconComponent, { size: 24, color: '#000' });
311
+ let svg = renderToStaticMarkup(element);
312
+ if (!svg.startsWith('<svg')) {
313
+ svg = `<svg>${svg}</svg>`;
314
+ }
315
+ if (!/viewBox=/.test(svg)) {
316
+ svg = svg.replace('<svg', '<svg viewBox="0 0 24 24"');
317
+ }
318
+ if (!/width=/.test(svg)) {
319
+ svg = svg.replace('<svg', '<svg width="24"');
320
+ }
321
+ if (!/height=/.test(svg)) {
322
+ svg = svg.replace('<svg', '<svg height="24"');
323
+ }
324
+ entries[localName] = { ...spec, svg };
325
+ } catch (err) {
326
+ console.warn(`Failed to render icon ${spec.module}:${spec.exportName}`, err);
327
+ }
328
+ }
329
+ return entries;
330
+ }
331
+
332
+ // Run
333
+ main().catch(err => {
334
+ console.error(err);
335
+ process.exit(1);
336
+ });