inkhouse 0.1.0-beta.0

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 +145 -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
@@ -0,0 +1,466 @@
1
+ import { LayoutParser } from './layout-parser';
2
+ import { type NodeIR, isElementLikeNode } from './node-ir';
3
+
4
+ export type NodeLayoutComputed = {
5
+ layoutIR: ReturnType<typeof LayoutParser.parseToIR>;
6
+ maxWidth: number | null;
7
+ explicitWidth: number | null;
8
+ gridCols: number | null;
9
+ hasLayoutClass: boolean;
10
+ hasFlexChildClass: boolean;
11
+ hasExplicitSize: boolean;
12
+ };
13
+
14
+ const NODE_LAYOUT_CACHE = new WeakMap<object, NodeLayoutComputed>();
15
+
16
+ const BLOCK_TAGS = new Set([
17
+ 'div',
18
+ 'nav',
19
+ 'section',
20
+ 'header',
21
+ 'footer',
22
+ 'main',
23
+ 'article',
24
+ 'aside',
25
+ 'ul',
26
+ 'ol',
27
+ 'li',
28
+ 'table',
29
+ 'thead',
30
+ 'tbody',
31
+ 'tfoot',
32
+ 'tr',
33
+ ]);
34
+
35
+ const GRID_BREAKPOINTS: Record<string, number> = {
36
+ sm: 640,
37
+ md: 768,
38
+ lg: 1024,
39
+ xl: 1280,
40
+ '2xl': 1536,
41
+ };
42
+
43
+ export function getBaseClass(value: string): string | null {
44
+ if (!value) return null;
45
+ if (value.indexOf(':') !== -1) return null;
46
+ return value;
47
+ }
48
+
49
+ export function extractFixedWidth(classes: string[] | undefined): number | null {
50
+ if (!classes) return null;
51
+ for (const cls of classes) {
52
+ const base = getBaseClass(cls);
53
+ if (!base) continue;
54
+ const wBracket = base.match(/^w-\[(\d+(?:\.\d+)?)px\]$/);
55
+ if (wBracket) return parseFloat(wBracket[1]);
56
+ const wMatch = base.match(/^w-(\d+(?:\.\d+)?)$/);
57
+ if (wMatch) return parseFloat(wMatch[1]) * 4;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ export function extractMaxWidth(classes: string[] | undefined): number | null {
63
+ if (!classes) return null;
64
+ const map: Record<string, number> = {
65
+ 'max-w-xs': 320,
66
+ 'max-w-sm': 384,
67
+ 'max-w-md': 448,
68
+ 'max-w-lg': 512,
69
+ 'max-w-xl': 576,
70
+ 'max-w-2xl': 672,
71
+ 'max-w-3xl': 768,
72
+ 'max-w-4xl': 896,
73
+ 'max-w-5xl': 1024,
74
+ 'max-w-6xl': 1152,
75
+ 'max-w-7xl': 1280,
76
+ };
77
+ for (const cls of classes) {
78
+ const base = getBaseClass(cls);
79
+ if (!base) continue;
80
+ if (map[base]) return map[base];
81
+ const bracket = base.match(/^max-w-\[(\d+(?:\.\d+)?)px\]$/);
82
+ if (bracket) return parseFloat(bracket[1]);
83
+ }
84
+ return null;
85
+ }
86
+
87
+ export function extractGridColumns(classes: string[] | undefined, availableWidth?: number): number | null {
88
+ if (!classes) return null;
89
+ const responsivePrefixes = new Set(Object.keys(GRID_BREAKPOINTS));
90
+ let baseCols: number | null = null;
91
+ let best: { bp: number; cols: number } | null = null;
92
+ let hasBaseGrid = false;
93
+ for (const cls of classes) {
94
+ const parts = cls.split(':');
95
+ const tail = parts[parts.length - 1];
96
+ const prefixes = parts.slice(0, -1);
97
+ if ((tail === 'grid' || tail === 'inline-grid') && prefixes.length === 0) {
98
+ hasBaseGrid = true;
99
+ }
100
+ const match = tail.match(/^grid-cols-(\d+)$/);
101
+ if (!match) continue;
102
+ const cols = parseInt(match[1], 10);
103
+ if (!Number.isFinite(cols) || cols <= 0) continue;
104
+
105
+ if (prefixes.length === 0) {
106
+ baseCols = cols;
107
+ continue;
108
+ }
109
+
110
+ for (const prefix of prefixes) {
111
+ if (!responsivePrefixes.has(prefix)) continue;
112
+ const bp = GRID_BREAKPOINTS[prefix];
113
+ if (!bp) continue;
114
+ if (!best || bp > best.bp) {
115
+ best = { bp: bp, cols: cols };
116
+ }
117
+ }
118
+ }
119
+
120
+ if (!hasBaseGrid && baseCols == null && !best) return null;
121
+
122
+ if (availableWidth != null && Number.isFinite(availableWidth)) {
123
+ if (best && availableWidth >= best.bp) return best.cols;
124
+ if (baseCols != null) return baseCols;
125
+ // Tailwind defaults to one implicit column for grid containers until a responsive override applies.
126
+ if (hasBaseGrid) return 1;
127
+ return best ? best.cols : null;
128
+ }
129
+
130
+ if (baseCols != null) return baseCols;
131
+ if (best) return best.cols;
132
+ if (hasBaseGrid) return 1;
133
+ return null;
134
+ }
135
+
136
+ export function extractGridBreakpointWidth(classes: string[] | undefined): number | null {
137
+ if (!classes) return null;
138
+ let best = 0;
139
+ for (const cls of classes) {
140
+ const parts = cls.split(':');
141
+ if (parts.length < 2) continue;
142
+ const prefix = parts[0];
143
+ const tail = parts[parts.length - 1];
144
+ if (!tail.startsWith('grid-cols-')) continue;
145
+ const bp = GRID_BREAKPOINTS[prefix];
146
+ if (bp && bp > best) best = bp;
147
+ }
148
+ return best || null;
149
+ }
150
+
151
+ export function shouldStretchToParentWidth(tag: string, classes: string[]): boolean {
152
+ if (!BLOCK_TAGS.has(tag)) return false;
153
+ for (const cls of classes) {
154
+ const base = getBaseClass(cls);
155
+ if (!base) continue;
156
+ if (base === 'inline' || base === 'inline-block' || base === 'inline-flex' || base === 'inline-grid') {
157
+ return false;
158
+ }
159
+ if (base.startsWith('w-') || base.startsWith('max-w-') || base.startsWith('min-w-')) {
160
+ return false;
161
+ }
162
+ if (base.startsWith('self-')) {
163
+ return false;
164
+ }
165
+ }
166
+ return true;
167
+ }
168
+
169
+ export function shouldSkipFullWidthForClasses(classes: string[] | undefined): boolean {
170
+ if (!classes || classes.length === 0) return false;
171
+ if (classes.includes('grid') || classes.includes('inline-grid')) return true;
172
+ return extractGridColumns(classes) != null;
173
+ }
174
+
175
+ export function resolveExplicitWidthFromClasses(classes: string[]): number | null {
176
+ if (!classes) return null;
177
+ let width = extractFixedWidth(classes);
178
+ if (width != null && classes.includes('w-full')) {
179
+ width = null;
180
+ }
181
+ return width;
182
+ }
183
+
184
+ export function getNodeLayoutComputed(node: NodeIR): NodeLayoutComputed {
185
+ const cached = NODE_LAYOUT_CACHE.get(node as any);
186
+ if (cached) return cached;
187
+
188
+ const classes = isElementLikeNode(node) ? node.classes : [];
189
+ const semanticLayoutClasses = getSemanticLayoutClasses(node);
190
+ const layoutClasses = semanticLayoutClasses.length > 0
191
+ ? [...semanticLayoutClasses, ...classes]
192
+ : classes;
193
+ const layoutIR = LayoutParser.parseToIR(layoutClasses);
194
+ const maxWidth = extractMaxWidth(classes);
195
+ const explicitWidth = resolveExplicitWidthFromClasses(classes);
196
+ const gridCols = extractGridColumns(classes);
197
+ const hasLayoutClass = layoutClasses.some(c =>
198
+ c === 'flex' || c === 'inline-flex' || c === 'grid' || c === 'inline-grid' ||
199
+ c.startsWith('flex-') || c.startsWith('items-') || c.startsWith('justify-') ||
200
+ c.startsWith('gap-') || c.startsWith('space-')
201
+ );
202
+ const hasFlexChildClass = classes.some(c =>
203
+ c === 'flex-1' || c === 'flex-grow' || c === 'grow' ||
204
+ c === 'flex-grow-0' || c === 'grow-0' ||
205
+ c === 'flex-shrink' || c === 'shrink' ||
206
+ c === 'flex-shrink-0' || c === 'shrink-0' ||
207
+ c.startsWith('self-')
208
+ );
209
+ const hasExplicitSize = classes.some(c =>
210
+ /^w-\d+$/.test(c) || /^w-\[/.test(c) || /^h-\d+$/.test(c) || /^h-\[/.test(c) ||
211
+ c === 'w-full' || c === 'h-full' || /^w-\d+\/\d+$/.test(c)
212
+ );
213
+
214
+ const computed: NodeLayoutComputed = {
215
+ layoutIR: layoutIR,
216
+ maxWidth: maxWidth,
217
+ explicitWidth: explicitWidth,
218
+ gridCols: gridCols,
219
+ hasLayoutClass: hasLayoutClass,
220
+ hasFlexChildClass: hasFlexChildClass,
221
+ hasExplicitSize: hasExplicitSize,
222
+ };
223
+ NODE_LAYOUT_CACHE.set(node as any, computed);
224
+ return computed;
225
+ }
226
+
227
+ function getSemanticLayoutClasses(node: NodeIR): string[] {
228
+ if (node.kind !== 'element' && node.kind !== 'component') return [];
229
+ switch (node.tagLower) {
230
+ case 'table':
231
+ case 'thead':
232
+ case 'tbody':
233
+ case 'tfoot':
234
+ return ['flex', 'flex-col'];
235
+ case 'tr':
236
+ return ['flex', 'flex-row'];
237
+ default:
238
+ return [];
239
+ }
240
+ }
241
+
242
+ export function solveLayoutWidths(node: NodeIR, availableWidth?: number): NodeIR {
243
+ if (node.kind === 'text' || node.kind === 'divider') {
244
+ return node;
245
+ }
246
+
247
+ if (node.kind === 'fragment') {
248
+ const nextChildren: NodeIR[] = [];
249
+ for (const child of node.children) {
250
+ nextChildren.push(solveLayoutWidths(child, availableWidth));
251
+ }
252
+ return Object.assign({}, node, { children: nextChildren });
253
+ }
254
+
255
+ if (node.kind === 'ring') {
256
+ let childWidth: number | undefined = availableWidth;
257
+ if (childWidth != null) {
258
+ childWidth = Math.max(0, childWidth - (node.ringWidth + node.offsetWidth) * 2);
259
+ }
260
+ return Object.assign({}, node, { child: solveLayoutWidths(node.child, childWidth) });
261
+ }
262
+
263
+ let classes = node.classes;
264
+ const layoutComputed = getNodeLayoutComputed(node);
265
+ const maxWidthConstraint = layoutComputed.maxWidth;
266
+ let widthOverride: number | null = layoutComputed.explicitWidth;
267
+ let widthDerivedFromMaxConstraint = false;
268
+ if (widthOverride == null && maxWidthConstraint != null) {
269
+ if (availableWidth != null) {
270
+ widthOverride = Math.min(maxWidthConstraint, availableWidth);
271
+ widthDerivedFromMaxConstraint = true;
272
+ } else {
273
+ widthOverride = maxWidthConstraint;
274
+ }
275
+ }
276
+ if (widthOverride == null && availableWidth != null && classes.includes('w-full')) {
277
+ widthOverride = availableWidth;
278
+ }
279
+ if (widthOverride == null && availableWidth != null && node.kind === 'element') {
280
+ if (shouldStretchToParentWidth(node.tagLower, classes)) {
281
+ widthOverride = availableWidth;
282
+ }
283
+ }
284
+ // Component wrappers (Card, CardHeader, etc.) are not in BLOCK_TAGS but should
285
+ // still inherit the parent's available width when they have no explicit width of their own.
286
+ if (widthOverride == null && availableWidth != null && node.kind === 'component') {
287
+ if (!hasNonFullWidthClass(classes)) {
288
+ widthOverride = availableWidth;
289
+ }
290
+ }
291
+
292
+ const wantsFixedWidth = shouldForceFixedWidth(node, availableWidth);
293
+ if (wantsFixedWidth && availableWidth != null && !hasNonFullWidthClass(classes)) {
294
+ classes = applyFixedWidthClass(classes, availableWidth);
295
+ widthOverride = availableWidth;
296
+ }
297
+ if (widthDerivedFromMaxConstraint && widthOverride != null) {
298
+ classes = applyFixedWidthClass(classes, widthOverride);
299
+ }
300
+
301
+ let layoutIR = layoutComputed.layoutIR;
302
+ if (classes !== node.classes) {
303
+ layoutIR = LayoutParser.parseToIR(classes);
304
+ }
305
+ const paddingX = layoutIR.paddingLeft + layoutIR.paddingRight;
306
+ const containerWidth = widthOverride != null ? widthOverride : undefined;
307
+ const contentWidth = containerWidth != null ? Math.max(0, containerWidth - paddingX) : undefined;
308
+
309
+ const gridCols = extractGridColumns(classes, containerWidth ?? availableWidth);
310
+ let gridChildWidth: number | null = null;
311
+ if (gridCols && containerWidth != null) {
312
+ const gap = layoutIR.gapX != null ? layoutIR.gapX : layoutIR.gap;
313
+ const available = containerWidth - paddingX - gap * (gridCols - 1);
314
+ if (available > 0) {
315
+ gridChildWidth = Math.max(0, available / gridCols);
316
+ }
317
+ }
318
+
319
+ const childWidth = contentWidth != null ? contentWidth : undefined;
320
+ let flexChildWidth: number | null = null;
321
+ let remainingChildWidth: number | null = null;
322
+ if (layoutIR.layoutMode === 'HORIZONTAL' && contentWidth != null && node.children && node.children.length > 0) {
323
+ const gap = layoutIR.gapX != null ? layoutIR.gapX : layoutIR.gap;
324
+ const totalGap = gap * Math.max(0, node.children.length - 1);
325
+ let fixedTotal = 0;
326
+ let growCount = 0;
327
+ let variableCount = 0;
328
+ for (const child of node.children) {
329
+ if (!isElementLikeNode(child)) continue;
330
+ const explicitChildWidth = resolveExplicitWidthFromChild(child);
331
+ if (explicitChildWidth != null) {
332
+ fixedTotal += explicitChildWidth;
333
+ continue;
334
+ }
335
+ if (hasGrowClass(child.classes)) {
336
+ growCount += 1;
337
+ } else {
338
+ variableCount += 1;
339
+ }
340
+ }
341
+ const remaining = contentWidth - fixedTotal - totalGap;
342
+ if (growCount > 0 && Number.isFinite(remaining) && remaining > 0) {
343
+ flexChildWidth = Math.max(0, remaining / growCount);
344
+ } else if (growCount === 0 && fixedTotal > 0 && variableCount > 0 && Number.isFinite(remaining) && remaining > 0) {
345
+ // When there are fixed-width siblings but no grow children, split the remaining
346
+ // space equally among variable-width children (e.g. icon + text wrapper in flex row).
347
+ remainingChildWidth = Math.max(0, remaining / variableCount);
348
+ }
349
+ }
350
+
351
+ const nextChildren: NodeIR[] = [];
352
+ for (const child of node.children) {
353
+ let nextChild = child;
354
+ let nextWidth = childWidth;
355
+ if (gridChildWidth != null) {
356
+ nextChild = enforceFixedWidthOnGridChild(child, gridChildWidth);
357
+ nextWidth = gridChildWidth;
358
+ } else if (layoutIR.layoutMode === 'HORIZONTAL' && contentWidth != null) {
359
+ const explicitChildWidth = resolveExplicitWidthFromChild(child);
360
+ if (explicitChildWidth != null) {
361
+ nextWidth = explicitChildWidth;
362
+ } else if (flexChildWidth != null && isElementLikeNode(child) && hasGrowClass(child.classes)) {
363
+ nextChild = enforceFixedWidthOnFlexChild(child, flexChildWidth);
364
+ nextWidth = flexChildWidth;
365
+ } else if (remainingChildWidth != null && isElementLikeNode(child)) {
366
+ nextChild = enforceFixedWidthOnGridChild(child, remainingChildWidth);
367
+ nextWidth = remainingChildWidth;
368
+ } else {
369
+ nextWidth = undefined;
370
+ }
371
+ }
372
+ nextChildren.push(solveLayoutWidths(nextChild, nextWidth));
373
+ }
374
+
375
+ return Object.assign({}, node, {
376
+ classes: classes,
377
+ children: nextChildren,
378
+ });
379
+ }
380
+
381
+ function shouldForceFixedWidth(node: NodeIR, availableWidth?: number): boolean {
382
+ if (availableWidth == null) return false;
383
+ if (node.kind !== 'element' && node.kind !== 'component') return false;
384
+ if (!node.children || node.children.length === 0) return false;
385
+
386
+ const classes = node.classes;
387
+ if (classes.includes('justify-between')) return true;
388
+ if (extractGridColumns(classes, availableWidth)) return true;
389
+ if (classes.includes('grid') || classes.includes('inline-grid')) return true;
390
+
391
+ for (const child of node.children) {
392
+ if (!isElementLikeNode(child)) continue;
393
+ if (hasGrowClass(child.classes)) return true;
394
+ }
395
+
396
+ return false;
397
+ }
398
+
399
+ function hasGrowClass(classes: string[]): boolean {
400
+ for (const cls of classes) {
401
+ const base = getBaseClass(cls);
402
+ if (!base) continue;
403
+ if (base === 'flex-1' || base === 'grow' || base === 'flex-grow' || base === 'grow-1') {
404
+ return true;
405
+ }
406
+ }
407
+ return false;
408
+ }
409
+
410
+ function hasNonFullWidthClass(classes: string[]): boolean {
411
+ for (const cls of classes) {
412
+ const base = getBaseClass(cls);
413
+ if (!base) continue;
414
+ if (base === 'w-full' || base === 'w-auto') continue;
415
+ if (base.startsWith('w-')) return true;
416
+ if (base.startsWith('max-w-') || base.startsWith('min-w-')) return true;
417
+ if (base.startsWith('size-') || base.startsWith('basis-')) return true;
418
+ }
419
+ return false;
420
+ }
421
+
422
+ function resolveExplicitWidthFromChild(child: NodeIR): number | null {
423
+ if (!isElementLikeNode(child)) return null;
424
+ return getNodeLayoutComputed(child).explicitWidth;
425
+ }
426
+
427
+ function enforceFixedWidthOnGridChild(child: NodeIR, width: number): NodeIR {
428
+ if (width <= 0) return child;
429
+ if (child.kind === 'ring') {
430
+ const inner = child.child;
431
+ if (isElementLikeNode(inner)) {
432
+ const explicit = resolveExplicitWidthFromClasses(inner.classes);
433
+ if (explicit == null) {
434
+ const nextInner = Object.assign({}, inner, {
435
+ classes: applyFixedWidthClass(inner.classes, width - (child.ringWidth + child.offsetWidth) * 2),
436
+ });
437
+ return Object.assign({}, child, { child: nextInner });
438
+ }
439
+ }
440
+ return child;
441
+ }
442
+ if (!isElementLikeNode(child)) return child;
443
+ const explicit = resolveExplicitWidthFromClasses(child.classes);
444
+ if (explicit != null) return child;
445
+ return Object.assign({}, child, {
446
+ classes: applyFixedWidthClass(child.classes, width),
447
+ });
448
+ }
449
+
450
+ function enforceFixedWidthOnFlexChild(child: NodeIR, width: number): NodeIR {
451
+ if (width <= 0) return child;
452
+ return enforceFixedWidthOnGridChild(child, width);
453
+ }
454
+
455
+ function applyFixedWidthClass(classes: string[], width: number): string[] {
456
+ if (!Number.isFinite(width) || width <= 0) return classes;
457
+ const rounded = Math.round(width);
458
+ if (!Number.isFinite(rounded) || rounded <= 0) return classes;
459
+ const out: string[] = [];
460
+ for (const cls of classes) {
461
+ if (cls === 'w-full') continue;
462
+ out.push(cls);
463
+ }
464
+ out.push('w-[' + rounded + 'px]');
465
+ return out;
466
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Figma Token Patch API Route
3
+ *
4
+ * Generated by `inkhouse setup`. Do not move this file.
5
+ * The Figma plugin calls POST /api/figma/patch-tokens during Push to Code
6
+ * to apply token updates via a PostCSS AST patcher.
7
+ *
8
+ * To re-generate: pnpm exec inkhouse setup
9
+ */
10
+
11
+ import { NextResponse } from 'next/server';
12
+ import postcss, { type AtRule, type Rule, type Declaration, type Container } from 'postcss';
13
+
14
+ const CORS = {
15
+ 'Access-Control-Allow-Origin': '*',
16
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
17
+ 'Access-Control-Allow-Headers': 'Content-Type',
18
+ };
19
+
20
+ type ThemeUpdateMap = Record<string, Record<string, string>>;
21
+
22
+ function parseThemeSelectors(selector: string): string[] {
23
+ const themes = new Set<string>();
24
+ const normalized = String(selector || '').trim().toLowerCase();
25
+ if (!normalized) return [];
26
+
27
+ const matches = normalized.matchAll(/\[data-theme\s*=\s*["']?([^"'\\\]]+)["']?\]/g);
28
+ for (const match of matches) {
29
+ const themeName = String(match[1] || '').trim();
30
+ if (themeName) themes.add(themeName);
31
+ }
32
+
33
+ if (/(\.|:)(dark)\b/.test(normalized)) themes.add('dark');
34
+ if (normalized.includes(':root') && themes.size === 0) themes.add('primary');
35
+ return Array.from(themes);
36
+ }
37
+
38
+ function cloneUpdates(updatesByTheme: ThemeUpdateMap): Map<string, Map<string, string>> {
39
+ const out = new Map<string, Map<string, string>>();
40
+ for (const [themeName, updates] of Object.entries(updatesByTheme || {})) {
41
+ const normalizedTheme = String(themeName || '').trim().toLowerCase();
42
+ if (!normalizedTheme) continue;
43
+ if (!updates || typeof updates !== 'object') continue;
44
+ const map = out.get(normalizedTheme) || new Map<string, string>();
45
+ for (const [prop, value] of Object.entries(updates)) {
46
+ if (!prop || !prop.startsWith('--')) continue;
47
+ map.set(prop, String(value));
48
+ }
49
+ if (map.size > 0) out.set(normalizedTheme, map);
50
+ }
51
+ return out;
52
+ }
53
+
54
+ function resolveThemesForNode(node: Rule | AtRule): string[] {
55
+ if (node.type === 'atrule') {
56
+ return String((node as AtRule).name || '').toLowerCase() === 'theme' ? ['primary'] : [];
57
+ }
58
+ const selectors = Array.isArray((node as Rule).selectors)
59
+ ? (node as Rule).selectors
60
+ : String((node as Rule).selector || '').split(',');
61
+ const themes = new Set<string>();
62
+ for (const selector of selectors) {
63
+ for (const themeName of parseThemeSelectors(selector)) {
64
+ themes.add(themeName);
65
+ }
66
+ }
67
+ return Array.from(themes);
68
+ }
69
+
70
+ function walkTokenContainers(root: postcss.Root): Array<Rule | AtRule> {
71
+ const containers: Array<Rule | AtRule> = [];
72
+ root.walk((node) => {
73
+ if (node.type !== 'rule' && node.type !== 'atrule') return;
74
+ const typed = node as Rule | AtRule;
75
+ const themes = resolveThemesForNode(typed);
76
+ if (themes.length > 0) containers.push(typed);
77
+ });
78
+ return containers;
79
+ }
80
+
81
+ function appendDecl(container: Rule | AtRule, prop: string, value: string): void {
82
+ const decl = postcss.decl({ prop, value });
83
+ if (!container.nodes || container.nodes.length === 0) {
84
+ decl.raws.before = '\n ';
85
+ } else {
86
+ const sample = container.nodes.find((n) => n.type === 'decl') as Declaration | undefined;
87
+ decl.raws.before = sample?.raws?.before || '\n ';
88
+ }
89
+ (container as unknown as Container).append(decl);
90
+ }
91
+
92
+ function patchCssVariables(cssText: string, updatesByTheme: ThemeUpdateMap): string {
93
+ const root = postcss.parse(cssText);
94
+ const pending = cloneUpdates(updatesByTheme);
95
+ const containers = walkTokenContainers(root);
96
+
97
+ for (const container of containers) {
98
+ const themes = resolveThemesForNode(container);
99
+ if (themes.length === 0) continue;
100
+
101
+ for (const themeName of themes) {
102
+ const updates = pending.get(String(themeName || '').toLowerCase());
103
+ if (!updates || updates.size === 0) continue;
104
+
105
+ for (const node of container.nodes || []) {
106
+ if (node.type !== 'decl') continue;
107
+ const decl = node as Declaration;
108
+ if (!decl.prop || !updates.has(decl.prop)) continue;
109
+ decl.value = String(updates.get(decl.prop) || '');
110
+ updates.delete(decl.prop);
111
+ }
112
+ }
113
+ }
114
+
115
+ for (const [themeName, updates] of pending.entries()) {
116
+ if (updates.size === 0) continue;
117
+
118
+ let target: Rule | AtRule | null = null;
119
+ for (const container of containers) {
120
+ const themes = resolveThemesForNode(container);
121
+ if (themes.includes(themeName)) {
122
+ target = container;
123
+ break;
124
+ }
125
+ }
126
+
127
+ // Do not create missing theme blocks from sparse updates here.
128
+ // The plugin runtime performs the authoritative second pass that
129
+ // handles stale-theme pruning and full-theme block insertion.
130
+ if (!target) continue;
131
+
132
+ for (const [prop, value] of updates.entries()) {
133
+ appendDecl(target, prop, value);
134
+ }
135
+ }
136
+
137
+ return root.toString();
138
+ }
139
+
140
+ export async function POST(request: Request) {
141
+ try {
142
+ const payload = await request.json();
143
+ const cssText = typeof payload?.cssText === 'string' ? payload.cssText : '';
144
+ const updatesByTheme = (payload?.updatesByTheme || {}) as ThemeUpdateMap;
145
+
146
+ if (!cssText) {
147
+ return NextResponse.json(
148
+ { error: 'Invalid payload: cssText is required.' },
149
+ { status: 400, headers: CORS }
150
+ );
151
+ }
152
+
153
+ const patchedCss = patchCssVariables(cssText, updatesByTheme);
154
+ return NextResponse.json({ patchedCss }, { headers: CORS });
155
+ } catch (error) {
156
+ return NextResponse.json(
157
+ { error: 'Failed to patch CSS tokens', message: error instanceof Error ? error.message : String(error) },
158
+ { status: 500, headers: CORS }
159
+ );
160
+ }
161
+ }
162
+
163
+ export async function OPTIONS() {
164
+ return new NextResponse(null, { headers: CORS });
165
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Figma Component Scanner API Route
3
+ *
4
+ * Generated by `inkhouse setup`. Do not move this file.
5
+ * The Figma plugin calls GET /api/figma/scan-components when the user
6
+ * clicks "Generate Design System Page".
7
+ *
8
+ * To re-generate: pnpm exec inkhouse setup
9
+ */
10
+
11
+ import { NextResponse } from 'next/server';
12
+ import * as path from 'path';
13
+ import * as fs from 'fs';
14
+ import { spawnSync } from 'child_process';
15
+
16
+ const CWD = process.cwd();
17
+ const TSX = path.resolve(CWD, 'node_modules/.bin/tsx');
18
+ const CLI = path.resolve(CWD, 'node_modules/inkhouse/scanner/cli.ts');
19
+ const OUTPUT = path.resolve(CWD, '.inkhouse/component-definitions.json');
20
+
21
+ const CORS = {
22
+ 'Access-Control-Allow-Origin': '*',
23
+ 'Access-Control-Allow-Methods': 'GET, OPTIONS',
24
+ 'Access-Control-Allow-Headers': 'Content-Type',
25
+ };
26
+
27
+ export async function GET(request: Request) {
28
+ try {
29
+ const url = new URL(request.url);
30
+ const tokenSourceMode = url.searchParams.get('tokenSourceMode') || 'auto';
31
+ const cssTokenPath = url.searchParams.get('cssTokenPath') || undefined;
32
+ const dtcgTokenPath = url.searchParams.get('dtcgTokenPath') || undefined;
33
+
34
+ fs.mkdirSync(path.dirname(OUTPUT), { recursive: true });
35
+
36
+ const args = [CLI, '--output', OUTPUT, '--include-tokens', '--token-mode', tokenSourceMode];
37
+ if (cssTokenPath) args.push('--css-token-path', cssTokenPath);
38
+ if (dtcgTokenPath) args.push('--dtcg-token-path', dtcgTokenPath);
39
+
40
+ const result = spawnSync(TSX, args, { cwd: CWD, encoding: 'utf-8' });
41
+ if (result.status !== 0) {
42
+ throw new Error(result.stderr || 'Scanner exited with non-zero status');
43
+ }
44
+
45
+ const output = JSON.parse(fs.readFileSync(OUTPUT, 'utf-8'));
46
+ return NextResponse.json(output, { headers: CORS });
47
+ } catch (error) {
48
+ return NextResponse.json(
49
+ { error: 'Failed to scan components', message: error instanceof Error ? error.message : String(error) },
50
+ { status: 500, headers: CORS }
51
+ );
52
+ }
53
+ }
54
+
55
+ export async function OPTIONS() {
56
+ return new NextResponse(null, { headers: CORS });
57
+ }