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
package/src/github.ts ADDED
@@ -0,0 +1,1465 @@
1
+ // --- GitHub API Integration ---
2
+ // All GitHub operations are relayed through the UI iframe because the
3
+ // Figma plugin sandbox has no direct network access.
4
+
5
+ import { loadConfig, GITHUB_CONFIG } from './config';
6
+ import {
7
+ TOKENS,
8
+ getVariableTokenDiffWithOptions,
9
+ tokensToCSS,
10
+ tokensToDTCG,
11
+ type ThemeTokens,
12
+ type TokenGroup,
13
+ type Tokens,
14
+ } from './tokens';
15
+ import { colorToLabel, debug, parseColor, mergeTokens as deepMergeTokens } from './colors';
16
+ import { waitForUIReady } from './dev-server';
17
+ import type { ResolvedTokenSourceMode, TokenSourceMode } from './token-source';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Types
21
+ // ---------------------------------------------------------------------------
22
+
23
+ interface GitHubFetchOptions {
24
+ method?: string;
25
+ body?: string | null;
26
+ }
27
+
28
+ interface GitHubFetchResult {
29
+ error?: string;
30
+ sha?: string;
31
+ object?: { sha: string };
32
+ content?: string;
33
+ tree?: { sha: string };
34
+ html_url?: string;
35
+ data?: GitHubFetchResult;
36
+ [key: string]: unknown;
37
+ }
38
+
39
+ interface PatchCssResult {
40
+ requestId: string;
41
+ patchedCss?: string | null;
42
+ source?: string;
43
+ error?: string | null;
44
+ }
45
+
46
+ interface FileToCommit {
47
+ path: string;
48
+ content: string;
49
+ }
50
+
51
+ interface FileContent {
52
+ content: string;
53
+ sha: string;
54
+ }
55
+
56
+ interface ClassChange {
57
+ property: string;
58
+ code?: string;
59
+ figma?: string;
60
+ newClasses?: string[];
61
+ }
62
+
63
+ interface ComponentChange {
64
+ name: string;
65
+ file?: string;
66
+ changes: ClassChange[];
67
+ }
68
+
69
+ interface TokenSourceInfo {
70
+ source: string;
71
+ mode: ResolvedTokenSourceMode;
72
+ requestedMode?: TokenSourceMode;
73
+ }
74
+
75
+ interface ResolvedCssFile {
76
+ path: string;
77
+ content: string;
78
+ }
79
+
80
+ interface TokenCommitPlan {
81
+ filesToCommit: FileToCommit[];
82
+ filesChangedLabels: string[];
83
+ sourceSummary: string;
84
+ dtcgSummary: string;
85
+ }
86
+
87
+ export interface TokenChangePreview {
88
+ hasChanges: boolean;
89
+ filesChangedLabels: string[];
90
+ sourceSummary: string;
91
+ dtcgSummary: string;
92
+ }
93
+
94
+ const TOKEN_SOURCE_INFO_KEY = 'token_source_info';
95
+ const DEFAULT_DTCG_PATH = 'design-tokens/tokens.dtcg.json';
96
+ const LEGACY_GENERATED_CSS_PATH = 'src/app/tokens.css';
97
+ const CSS_DISCOVERY_PATHS = [
98
+ 'src/app/tokens.css',
99
+ 'src/app/globals.css',
100
+ 'app/globals.css',
101
+ 'styles/globals.css',
102
+ ];
103
+ const PATCH_ENDPOINT_PORTS = [4000, 3000, 5173];
104
+ const PATCH_ENDPOINT_PATH = '/api/figma/patch-tokens';
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Module-level state for the GitHub fetch relay
108
+ // ---------------------------------------------------------------------------
109
+
110
+ let _pendingGitHubCallbacks: Record<string, (result: GitHubFetchResult) => void> = {};
111
+ let _githubRequestCounter = 0;
112
+ let _pendingPatchCallbacks: Record<string, (result: PatchCssResult) => void> = {};
113
+ let _patchRequestCounter = 0;
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // GitHub fetch relay
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * Relay a GitHub API request through the UI iframe.
121
+ * The plugin sandbox cannot make network requests directly, so we post a
122
+ * message to the UI which performs the actual fetch and posts the result back.
123
+ */
124
+ export async function githubFetch(
125
+ endpoint: string,
126
+ token: string,
127
+ options?: GitHubFetchOptions
128
+ ): Promise<GitHubFetchResult> {
129
+ const opts = options || {};
130
+ await waitForUIReady();
131
+
132
+ return new Promise<GitHubFetchResult>(function (resolve, reject) {
133
+ const requestId = 'github-' + ++_githubRequestCounter;
134
+ _pendingGitHubCallbacks[requestId] = function (result: GitHubFetchResult) {
135
+ if (result && result.error) {
136
+ reject(new Error(result.error));
137
+ } else {
138
+ resolve(result);
139
+ }
140
+ };
141
+
142
+ figma.ui.postMessage({
143
+ type: 'github-fetch',
144
+ requestId: requestId,
145
+ endpoint: endpoint,
146
+ token: token,
147
+ method: opts.method || 'GET',
148
+ body: opts.body || null,
149
+ });
150
+
151
+ // Timeout after 12s so UI gets feedback quickly when bridge/network is stuck.
152
+ setTimeout(function () {
153
+ if (_pendingGitHubCallbacks[requestId]) {
154
+ delete _pendingGitHubCallbacks[requestId];
155
+ reject(new Error('GitHub API request timed out for endpoint: ' + endpoint));
156
+ }
157
+ }, 12000);
158
+ });
159
+ }
160
+
161
+ /**
162
+ * Handle a GitHub fetch result message from the UI iframe.
163
+ * Called by the main message handler when it receives a 'github-fetch-result'.
164
+ */
165
+ export function handleGitHubFetchResult(msg: { requestId: string } & GitHubFetchResult): void {
166
+ const cb = _pendingGitHubCallbacks[msg.requestId];
167
+ if (cb) {
168
+ delete _pendingGitHubCallbacks[msg.requestId];
169
+ if (msg && msg.data && typeof msg.data === 'object') {
170
+ cb(msg.data);
171
+ return;
172
+ }
173
+ cb(msg);
174
+ }
175
+ }
176
+
177
+ function requireResponseString(value: unknown, context: string): string {
178
+ if (typeof value === 'string' && value.trim().length > 0) return value;
179
+ throw new Error('GitHub API response missing ' + context);
180
+ }
181
+
182
+ function buildPatchEndpointCandidates(): string[] {
183
+ const out: string[] = [];
184
+ for (let i = 0; i < PATCH_ENDPOINT_PORTS.length; i++) {
185
+ out.push('http://localhost:' + PATCH_ENDPOINT_PORTS[i] + PATCH_ENDPOINT_PATH);
186
+ }
187
+ return out;
188
+ }
189
+
190
+ function serializeCssUpdates(updatesByTheme: Map<string, Map<string, string>>): Record<string, Record<string, string>> {
191
+ const out: Record<string, Record<string, string>> = {};
192
+ for (const [themeName, updates] of updatesByTheme.entries()) {
193
+ const bucket: Record<string, string> = {};
194
+ for (const [prop, value] of updates.entries()) {
195
+ bucket[prop] = value;
196
+ }
197
+ out[themeName] = bucket;
198
+ }
199
+ return out;
200
+ }
201
+
202
+ async function patchCssViaDevServer(
203
+ cssPath: string,
204
+ cssText: string,
205
+ updatesByTheme: Map<string, Map<string, string>>
206
+ ): Promise<string | null> {
207
+ await waitForUIReady();
208
+
209
+ return new Promise<string | null>((resolve) => {
210
+ const requestId = 'patch-css-' + ++_patchRequestCounter;
211
+ _pendingPatchCallbacks[requestId] = (result: PatchCssResult) => {
212
+ if (result && typeof result.patchedCss === 'string') {
213
+ resolve(result.patchedCss);
214
+ return;
215
+ }
216
+ resolve(null);
217
+ };
218
+
219
+ figma.ui.postMessage({
220
+ type: 'patch-css',
221
+ requestId,
222
+ candidates: buildPatchEndpointCandidates(),
223
+ payload: {
224
+ filePath: cssPath,
225
+ cssText,
226
+ updatesByTheme: serializeCssUpdates(updatesByTheme),
227
+ },
228
+ });
229
+
230
+ setTimeout(() => {
231
+ if (_pendingPatchCallbacks[requestId]) {
232
+ delete _pendingPatchCallbacks[requestId];
233
+ resolve(null);
234
+ }
235
+ }, 30000);
236
+ });
237
+ }
238
+
239
+ /**
240
+ * Handle patch-css result message from UI iframe.
241
+ */
242
+ export function handlePatchCssResult(msg: PatchCssResult): void {
243
+ const cb = _pendingPatchCallbacks[msg.requestId];
244
+ if (cb) {
245
+ delete _pendingPatchCallbacks[msg.requestId];
246
+ cb(msg);
247
+ }
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // Base64 encoding / decoding (works in the Figma plugin environment)
252
+ // ---------------------------------------------------------------------------
253
+
254
+ /**
255
+ * Encode string to base64.
256
+ * Handles UTF-8 by encoding to bytes first.
257
+ */
258
+ export function encodeBase64(str: string): string {
259
+ const bytes: number[] = [];
260
+ for (let i = 0; i < str.length; i++) {
261
+ const code = str.charCodeAt(i);
262
+ if (code < 0x80) {
263
+ bytes.push(code);
264
+ } else if (code < 0x800) {
265
+ bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
266
+ } else {
267
+ bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
268
+ }
269
+ }
270
+
271
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
272
+ let output = '';
273
+ for (let j = 0; j < bytes.length; j += 3) {
274
+ const b1 = bytes[j];
275
+ const b2 = j + 1 < bytes.length ? bytes[j + 1] : 0;
276
+ const b3 = j + 2 < bytes.length ? bytes[j + 2] : 0;
277
+ output += chars.charAt(b1 >> 2);
278
+ output += chars.charAt(((b1 & 3) << 4) | (b2 >> 4));
279
+ output += j + 1 < bytes.length ? chars.charAt(((b2 & 15) << 2) | (b3 >> 6)) : '=';
280
+ output += j + 2 < bytes.length ? chars.charAt(b3 & 63) : '=';
281
+ }
282
+ return output;
283
+ }
284
+
285
+ /**
286
+ * Decode base64 string.
287
+ */
288
+ export function decodeBase64(str: string): string {
289
+ // Remove newlines that GitHub adds
290
+ const cleaned = str.replace(/\n/g, '');
291
+
292
+ // Use built-in atob if available, otherwise manual decode
293
+ if (typeof atob === 'function') {
294
+ return atob(cleaned);
295
+ }
296
+
297
+ // Fallback: manual base64 decode
298
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
299
+ let output = '';
300
+ let i = 0;
301
+
302
+ while (i < cleaned.length) {
303
+ const enc1 = chars.indexOf(cleaned.charAt(i++));
304
+ const enc2 = chars.indexOf(cleaned.charAt(i++));
305
+ const enc3 = chars.indexOf(cleaned.charAt(i++));
306
+ const enc4 = chars.indexOf(cleaned.charAt(i++));
307
+
308
+ const chr1 = (enc1 << 2) | (enc2 >> 4);
309
+ const chr2 = ((enc2 & 15) << 4) | (enc3 >> 2);
310
+ const chr3 = ((enc3 & 3) << 6) | enc4;
311
+
312
+ output += String.fromCharCode(chr1);
313
+ if (enc3 !== 64) output += String.fromCharCode(chr2);
314
+ if (enc4 !== 64) output += String.fromCharCode(chr3);
315
+ }
316
+
317
+ return output;
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // File content helpers
322
+ // ---------------------------------------------------------------------------
323
+
324
+ /**
325
+ * Fetch file content from GitHub.
326
+ */
327
+ export async function fetchFileContent(
328
+ token: string,
329
+ filePath: string,
330
+ branch?: string
331
+ ): Promise<FileContent | null> {
332
+ try {
333
+ const ref = branch || GITHUB_CONFIG!.baseBranch;
334
+ const data = await githubFetch(
335
+ `/repos/${GITHUB_CONFIG!.owner}/${GITHUB_CONFIG!.repo}/contents/${filePath}?ref=${ref}`,
336
+ token
337
+ );
338
+
339
+ // GitHub returns content as base64
340
+ let content = '';
341
+ if (data.content) {
342
+ content = decodeBase64(data.content as string);
343
+ }
344
+
345
+ return {
346
+ content: content,
347
+ sha: data.sha as string,
348
+ };
349
+ } catch (e: unknown) {
350
+ const message = e instanceof Error ? e.message : String(e);
351
+ debug('Failed to fetch file', filePath, message);
352
+ return null;
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Apply class changes to a component file.
358
+ * Uses regex-based replacement for Tailwind classes.
359
+ */
360
+ export function applyClassChangesToFile(content: string, componentChange: ComponentChange): string {
361
+ let updatedContent = content;
362
+
363
+ for (let i = 0; i < componentChange.changes.length; i++) {
364
+ const change = componentChange.changes[i];
365
+
366
+ if (change.property === 'padding') {
367
+ // Replace padding classes (p-X, px-X, py-X, etc.)
368
+ if (change.code && change.code.trim()) {
369
+ const oldClasses = change.code.split(' ').filter(Boolean);
370
+ for (let j = 0; j < oldClasses.length; j++) {
371
+ const oldClass = oldClasses[j];
372
+ const escapedOld = oldClass.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
373
+ const regex = new RegExp('\\b' + escapedOld + '\\b', 'g');
374
+ if (change.newClasses && change.newClasses.length > j) {
375
+ updatedContent = updatedContent.replace(regex, change.newClasses[j]);
376
+ }
377
+ }
378
+ }
379
+ }
380
+
381
+ if (change.property === 'borderRadius') {
382
+ // Replace rounded classes
383
+ if (change.code && change.code.trim()) {
384
+ const oldRadiusClasses = change.code.split(' ').filter(Boolean);
385
+ for (let k = 0; k < oldRadiusClasses.length; k++) {
386
+ const oldRadius = oldRadiusClasses[k];
387
+ const escapedRadius = oldRadius.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
388
+ const radiusRegex = new RegExp('\\b' + escapedRadius + '\\b', 'g');
389
+ if (change.newClasses && change.newClasses.length > k) {
390
+ updatedContent = updatedContent.replace(radiusRegex, change.newClasses[k]);
391
+ }
392
+ }
393
+ }
394
+ }
395
+ }
396
+
397
+ return updatedContent;
398
+ }
399
+
400
+ function normalizeTokenSourceInfo(raw: unknown): TokenSourceInfo {
401
+ const source =
402
+ raw && typeof raw === 'object' && typeof (raw as any).source === 'string' && (raw as any).source.trim()
403
+ ? (raw as any).source.trim()
404
+ : 'embedded:tokens.ts';
405
+ const modeRaw = raw && typeof raw === 'object' ? (raw as any).mode : null;
406
+ const mode: ResolvedTokenSourceMode =
407
+ modeRaw === 'css' || modeRaw === 'dtcg' || modeRaw === 'embedded' ? modeRaw : 'embedded';
408
+ const requestedRaw = raw && typeof raw === 'object' ? (raw as any).requestedMode : null;
409
+ const requestedMode: TokenSourceMode | undefined =
410
+ requestedRaw === 'auto' || requestedRaw === 'css' || requestedRaw === 'dtcg' ? requestedRaw : undefined;
411
+ return { source, mode, requestedMode };
412
+ }
413
+
414
+ async function loadTokenSourceInfo(): Promise<TokenSourceInfo> {
415
+ const stored = await figma.clientStorage.getAsync(TOKEN_SOURCE_INFO_KEY);
416
+ return normalizeTokenSourceInfo(stored);
417
+ }
418
+
419
+ function stringifyTokenValue(value: unknown): string {
420
+ if (typeof value === 'string') return value.trim();
421
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
422
+ if (value && typeof value === 'object') return colorToLabel(value).trim();
423
+ return '';
424
+ }
425
+
426
+ function parseDimensionToPx(value: unknown): number | null {
427
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
428
+ const raw = String(value || '').trim().toLowerCase();
429
+ if (!raw) return null;
430
+ if (raw.endsWith('px')) {
431
+ const n = parseFloat(raw.slice(0, -2));
432
+ return Number.isFinite(n) ? n : null;
433
+ }
434
+ if (raw.endsWith('rem')) {
435
+ const n = parseFloat(raw.slice(0, -3));
436
+ return Number.isFinite(n) ? n * 16 : null;
437
+ }
438
+ if (/^-?[0-9.]+$/.test(raw)) {
439
+ const n = parseFloat(raw);
440
+ return Number.isFinite(n) ? n : null;
441
+ }
442
+ return null;
443
+ }
444
+
445
+ function formatNumber(num: number, precision: number): string {
446
+ const fixed = num.toFixed(precision);
447
+ return fixed.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1');
448
+ }
449
+
450
+ function rgbToHex(value: { r: number; g: number; b: number }): string {
451
+ const toHex = (n: number) => {
452
+ const c = Math.max(0, Math.min(255, Math.round(n * 255)));
453
+ return c.toString(16).padStart(2, '0');
454
+ };
455
+ return '#' + toHex(value.r) + toHex(value.g) + toHex(value.b);
456
+ }
457
+
458
+ function srgbToLinear(c: number): number {
459
+ if (c <= 0.04045) return c / 12.92;
460
+ return Math.pow((c + 0.055) / 1.055, 2.4);
461
+ }
462
+
463
+ function rgbToOklchString(value: { r: number; g: number; b: number; a?: number }, template: string): string {
464
+ const r = srgbToLinear(Math.max(0, Math.min(1, value.r)));
465
+ const g = srgbToLinear(Math.max(0, Math.min(1, value.g)));
466
+ const b = srgbToLinear(Math.max(0, Math.min(1, value.b)));
467
+
468
+ const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
469
+ const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
470
+ const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
471
+
472
+ const l_ = Math.cbrt(l);
473
+ const m_ = Math.cbrt(m);
474
+ const s_ = Math.cbrt(s);
475
+
476
+ const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;
477
+ const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;
478
+ const bb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;
479
+
480
+ const C = Math.sqrt(a * a + bb * bb);
481
+ let H = Math.atan2(bb, a) * (180 / Math.PI);
482
+ if (H < 0) H += 360;
483
+
484
+ const usesPercentL = /oklch\(\s*[0-9.]+%/i.test(template);
485
+ const alpha = value.a == null ? 1 : Math.max(0, Math.min(1, value.a));
486
+ const includeAlpha = /\/\s*[0-9.%]+\s*\)/.test(template) || alpha < 0.999;
487
+
488
+ const lText = usesPercentL ? formatNumber(L * 100, 2) + '%' : formatNumber(L, 3);
489
+ const cText = formatNumber(C, 3);
490
+ const hText = formatNumber(H, 3);
491
+ if (includeAlpha) {
492
+ return 'oklch(' + lText + ' ' + cText + ' ' + hText + ' / ' + formatNumber(alpha, 3) + ')';
493
+ }
494
+ return 'oklch(' + lText + ' ' + cText + ' ' + hText + ')';
495
+ }
496
+
497
+ function stringifyTokenValueWithFormat(value: unknown, previousValue: unknown): string {
498
+ if (value && typeof value === 'object') {
499
+ const rgb = parseColor(value);
500
+ const prev = String(previousValue || '').trim();
501
+ if (/^oklch\(/i.test(prev)) return rgbToOklchString(rgb, prev);
502
+ if (/^#([0-9a-f]{3,8})$/i.test(prev)) return rgbToHex(rgb);
503
+ return colorToLabel(rgb).trim();
504
+ }
505
+
506
+ const serialized = stringifyTokenValue(value);
507
+ if (!serialized) return serialized;
508
+ const prev = String(previousValue || '').trim().toLowerCase();
509
+ if (!prev) return serialized;
510
+
511
+ const nextPx = parseDimensionToPx(serialized);
512
+ if (nextPx == null) return serialized;
513
+
514
+ if (prev.endsWith('rem')) {
515
+ return formatNumber(nextPx / 16, 3) + 'rem';
516
+ }
517
+ if (prev.endsWith('px')) {
518
+ return formatNumber(nextPx, 3) + 'px';
519
+ }
520
+ return serialized;
521
+ }
522
+
523
+ function setCssVariable(out: Map<string, string>, name: string, value: unknown, previousValue?: unknown): void {
524
+ const serialized = stringifyTokenValueWithFormat(value, previousValue);
525
+ if (!serialized) return;
526
+ out.set(name.startsWith('--') ? name : '--' + name, serialized);
527
+ }
528
+
529
+ function setPrefixedGroupVariables(
530
+ out: Map<string, string>,
531
+ group: TokenGroup | undefined,
532
+ prefix: string,
533
+ previousGroup?: TokenGroup
534
+ ): void {
535
+ if (!group) return;
536
+ for (const key in group) {
537
+ const previousValue = previousGroup ? previousGroup[key] : undefined;
538
+ setCssVariable(out, '--' + prefix + key, group[key], previousValue);
539
+ }
540
+ }
541
+
542
+ function buildThemeVariableMap(theme: ThemeTokens, previousTheme?: ThemeTokens): Map<string, string> {
543
+ const out = new Map<string, string>();
544
+ if (theme.radius) {
545
+ if (theme.radius.base) {
546
+ setCssVariable(
547
+ out,
548
+ '--radius',
549
+ theme.radius.base,
550
+ previousTheme && previousTheme.radius ? previousTheme.radius.base : undefined
551
+ );
552
+ }
553
+ for (const key in theme.radius) {
554
+ if (key === 'base') continue;
555
+ const prev = previousTheme && previousTheme.radius ? previousTheme.radius[key] : undefined;
556
+ setCssVariable(out, '--radius-' + key, theme.radius[key], prev);
557
+ }
558
+ }
559
+ setPrefixedGroupVariables(
560
+ out,
561
+ theme.spacing,
562
+ 'spacing-',
563
+ previousTheme && previousTheme.spacing ? previousTheme.spacing : undefined
564
+ );
565
+ setPrefixedGroupVariables(
566
+ out,
567
+ theme.fontSize,
568
+ 'text-',
569
+ previousTheme && previousTheme.fontSize ? previousTheme.fontSize : undefined
570
+ );
571
+ if (theme.shadow) {
572
+ for (const key in theme.shadow) {
573
+ if (key === 'DEFAULT') {
574
+ const prev = previousTheme && previousTheme.shadow ? previousTheme.shadow.DEFAULT : undefined;
575
+ setCssVariable(out, '--shadow', theme.shadow[key], prev);
576
+ continue;
577
+ }
578
+ const prev = previousTheme && previousTheme.shadow ? previousTheme.shadow[key] : undefined;
579
+ setCssVariable(out, '--shadow-' + key, theme.shadow[key], prev);
580
+ }
581
+ }
582
+ if (theme.font) {
583
+ for (const key in theme.font) {
584
+ const prev = previousTheme && previousTheme.font ? previousTheme.font[key] : undefined;
585
+ setCssVariable(out, '--font-' + key, theme.font[key], prev);
586
+ }
587
+ }
588
+ if (theme.color) {
589
+ for (const key in theme.color) {
590
+ const prev = previousTheme && previousTheme.color ? previousTheme.color[key] : undefined;
591
+ setCssVariable(out, '--' + key, theme.color[key], prev);
592
+ }
593
+ }
594
+ return out;
595
+ }
596
+
597
+ function buildCssVariableUpdates(tokens: Tokens, previousTokens?: Tokens): Map<string, Map<string, string>> {
598
+ const updates = new Map<string, Map<string, string>>();
599
+ const primaryTheme = (tokens.primary || {}) as ThemeTokens;
600
+ const previousPrimaryTheme = previousTokens && previousTokens.primary ? (previousTokens.primary as ThemeTokens) : undefined;
601
+ const primaryMap = buildThemeVariableMap(primaryTheme, previousPrimaryTheme);
602
+ if (tokens.core && tokens.core.font) {
603
+ for (const key in tokens.core.font) {
604
+ const prev = previousTokens && previousTokens.core && previousTokens.core.font ? previousTokens.core.font[key] : undefined;
605
+ setCssVariable(primaryMap, '--font-' + key, tokens.core.font[key], prev);
606
+ }
607
+ }
608
+ updates.set('primary', primaryMap);
609
+
610
+ const themeNames = Object.keys(tokens).filter((key) => key !== 'core' && key !== 'primary');
611
+ for (let i = 0; i < themeNames.length; i++) {
612
+ const themeName = themeNames[i];
613
+ const normalizedThemeName = String(themeName || '').trim().toLowerCase();
614
+ if (!normalizedThemeName || normalizedThemeName === 'primary') continue;
615
+ const block = tokens[themeName];
616
+ if (!block || typeof block !== 'object') continue;
617
+ const previousTheme = previousTokens && previousTokens[themeName] && typeof previousTokens[themeName] === 'object'
618
+ ? (previousTokens[themeName] as ThemeTokens)
619
+ : undefined;
620
+ const themeMap = buildThemeVariableMap(block as ThemeTokens, previousTheme);
621
+ if (themeMap.size > 0) updates.set(normalizedThemeName, themeMap);
622
+ }
623
+
624
+ return updates;
625
+ }
626
+
627
+ function hasTokenGroupEntries(group: unknown): boolean {
628
+ return !!group && typeof group === 'object' && Object.keys(group as Record<string, unknown>).length > 0;
629
+ }
630
+
631
+ function getActiveThemeNames(tokens: Tokens): Set<string> {
632
+ const out = new Set<string>();
633
+ out.add('primary');
634
+ const names = Object.keys(tokens || {});
635
+ for (let i = 0; i < names.length; i++) {
636
+ const name = names[i];
637
+ if (!name || name === 'core') continue;
638
+ const block = tokens[name];
639
+ if (!block || typeof block !== 'object') continue;
640
+ const theme = block as ThemeTokens;
641
+ if (
642
+ hasTokenGroupEntries(theme.color) ||
643
+ hasTokenGroupEntries(theme.radius) ||
644
+ hasTokenGroupEntries(theme.font) ||
645
+ hasTokenGroupEntries(theme.spacing) ||
646
+ hasTokenGroupEntries(theme.fontSize) ||
647
+ hasTokenGroupEntries(theme.shadow)
648
+ ) {
649
+ out.add(name.toLowerCase());
650
+ }
651
+ }
652
+ return out;
653
+ }
654
+
655
+ function parseRuleThemeNames(selector: string): string[] {
656
+ const themes = new Set<string>();
657
+ const normalized = String(selector || '').trim().toLowerCase();
658
+ if (!normalized) return [];
659
+
660
+ const dataThemeMatches = normalized.matchAll(/\[data-theme\s*=\s*["']?([^"'\\\]]+)["']?\]/g);
661
+ for (const match of dataThemeMatches) {
662
+ const name = String(match[1] || '').trim();
663
+ if (name) themes.add(name);
664
+ }
665
+
666
+ if (/(\.|:)(dark)\b/.test(normalized)) themes.add('dark');
667
+ if (normalized.includes(':root') && themes.size === 0) themes.add('primary');
668
+ return Array.from(themes);
669
+ }
670
+
671
+ function cloneCssVariableUpdates(
672
+ updatesByTheme: Map<string, Map<string, string>>
673
+ ): Map<string, Map<string, string>> {
674
+ const out = new Map<string, Map<string, string>>();
675
+ for (const [theme, updates] of updatesByTheme.entries()) {
676
+ const normalizedTheme = String(theme || '').trim().toLowerCase();
677
+ if (!normalizedTheme) continue;
678
+ const existing = out.get(normalizedTheme) || new Map<string, string>();
679
+ for (const [prop, value] of updates.entries()) {
680
+ existing.set(prop, value);
681
+ }
682
+ out.set(normalizedTheme, existing);
683
+ }
684
+ return out;
685
+ }
686
+
687
+ interface CssRuleBlock {
688
+ selector: string;
689
+ body: string;
690
+ fullStart: number;
691
+ fullEnd: number;
692
+ bodyStart: number;
693
+ bodyEnd: number;
694
+ }
695
+
696
+ function escapeRegex(input: string): string {
697
+ return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
698
+ }
699
+
700
+ function findRuleBlocks(cssText: string): CssRuleBlock[] {
701
+ const blocks: CssRuleBlock[] = [];
702
+ const ruleRegex = /([^{}]+)\{([^{}]*)\}/g;
703
+ let match: RegExpExecArray | null = null;
704
+ while ((match = ruleRegex.exec(cssText)) != null) {
705
+ const selectorRaw = match[1] || '';
706
+ const bodyRaw = match[2] || '';
707
+ const selector = selectorRaw.trim();
708
+ if (!selector) continue;
709
+ const fullStart = match.index;
710
+ const bodyStart = fullStart + selectorRaw.length + 1;
711
+ blocks.push({
712
+ selector,
713
+ body: bodyRaw,
714
+ fullStart,
715
+ fullEnd: fullStart + match[0].length,
716
+ bodyStart,
717
+ bodyEnd: bodyStart + bodyRaw.length,
718
+ });
719
+ }
720
+ return blocks;
721
+ }
722
+
723
+ function resolveThemesForSelector(selector: string): string[] {
724
+ if (/^@theme\b/i.test(selector.trim())) return ['primary'];
725
+ return parseRuleThemeNames(selector);
726
+ }
727
+
728
+ function patchDeclarationsInBody(
729
+ body: string,
730
+ updates: Map<string, string>
731
+ ): { nextBody: string; replacedProps: string[] } {
732
+ if (updates.size === 0) return { nextBody: body, replacedProps: [] };
733
+ let nextBody = body;
734
+ const replacedProps: string[] = [];
735
+ for (const [prop, value] of updates.entries()) {
736
+ // Match complete custom-property declarations in a block body.
737
+ // Using a declaration boundary (line start / semicolon) avoids partial matches
738
+ // like `--primary` inside `--primary-foreground`.
739
+ const declarationRegex = new RegExp(
740
+ '(^|[;\\n\\r])([ \\t]*' + escapeRegex(prop) + '(?![a-zA-Z0-9_-])\\s*:\\s*)([^;]*)(;)',
741
+ 'g'
742
+ );
743
+ let replaced = false;
744
+ nextBody = nextBody.replace(declarationRegex, function (_match, boundary, prefix, _oldValue, suffix) {
745
+ replaced = true;
746
+ return boundary + prefix + value + suffix;
747
+ });
748
+ if (replaced) replacedProps.push(prop);
749
+ }
750
+ return { nextBody, replacedProps };
751
+ }
752
+
753
+ function appendDeclarationsToBody(body: string, updates: Map<string, string>): string {
754
+ if (updates.size === 0) return body;
755
+ const indentMatch = body.match(/\n([ \t]*)--[a-z0-9-]+\s*:/i);
756
+ const indent = indentMatch ? indentMatch[1] : ' ';
757
+ let nextBody = body;
758
+ if (nextBody.length > 0 && !nextBody.endsWith('\n')) nextBody += '\n';
759
+ for (const [prop, value] of updates.entries()) {
760
+ nextBody += indent + prop + ': ' + value + ';\n';
761
+ }
762
+ return nextBody;
763
+ }
764
+
765
+ function replaceRange(text: string, start: number, end: number, replacement: string): string {
766
+ return text.slice(0, start) + replacement + text.slice(end);
767
+ }
768
+
769
+ function findFirstBlockForTheme(cssText: string, themeName: string): CssRuleBlock | null {
770
+ const blocks = findRuleBlocks(cssText);
771
+ for (let i = 0; i < blocks.length; i++) {
772
+ const themes = resolveThemesForSelector(blocks[i].selector);
773
+ for (let t = 0; t < themes.length; t++) {
774
+ if (themes[t] === themeName) return blocks[i];
775
+ }
776
+ }
777
+ return null;
778
+ }
779
+
780
+ /**
781
+ * Patch CSS custom properties in-place while preserving existing file structure.
782
+ */
783
+ export function patchCssVariables(
784
+ cssText: string,
785
+ updatesByTheme: Map<string, Map<string, string>>,
786
+ activeThemeNames?: Set<string>
787
+ ,
788
+ fullThemeUpdatesByTheme?: Map<string, Map<string, string>>
789
+ ): string {
790
+ let nextCss = cssText;
791
+ const pending = cloneCssVariableUpdates(updatesByTheme);
792
+ const fullThemeUpdates = fullThemeUpdatesByTheme
793
+ ? cloneCssVariableUpdates(fullThemeUpdatesByTheme)
794
+ : null;
795
+ const blocks = findRuleBlocks(nextCss);
796
+
797
+ // Remove stale theme blocks first so renamed themes do not accumulate.
798
+ if (activeThemeNames && activeThemeNames.size > 0) {
799
+ for (let i = blocks.length - 1; i >= 0; i--) {
800
+ const block = blocks[i];
801
+ const themes = resolveThemesForSelector(block.selector);
802
+ if (themes.length !== 1) continue;
803
+ const themeName = String(themes[0] || '').toLowerCase();
804
+ if (!themeName || themeName === 'primary') continue;
805
+ if (activeThemeNames.has(themeName)) continue;
806
+ nextCss = replaceRange(nextCss, block.fullStart, block.fullEnd, '');
807
+ }
808
+ }
809
+
810
+ const patchedBlocks = findRuleBlocks(nextCss);
811
+
812
+ for (let i = patchedBlocks.length - 1; i >= 0; i--) {
813
+ const block = patchedBlocks[i];
814
+ const themes = resolveThemesForSelector(block.selector);
815
+ if (themes.length === 0) continue;
816
+
817
+ let body = block.body;
818
+ let changed = false;
819
+ for (let t = 0; t < themes.length; t++) {
820
+ const updates = pending.get(String(themes[t] || '').toLowerCase());
821
+ if (!updates || updates.size === 0) continue;
822
+ const patched = patchDeclarationsInBody(body, updates);
823
+ if (patched.replacedProps.length > 0) {
824
+ body = patched.nextBody;
825
+ changed = true;
826
+ for (let p = 0; p < patched.replacedProps.length; p++) {
827
+ updates.delete(patched.replacedProps[p]);
828
+ }
829
+ }
830
+ }
831
+ if (!changed) continue;
832
+ nextCss = replaceRange(nextCss, block.bodyStart, block.bodyEnd, body);
833
+ }
834
+
835
+ for (const [themeName, updates] of pending.entries()) {
836
+ if (updates.size === 0) continue;
837
+
838
+ const target = findFirstBlockForTheme(nextCss, themeName);
839
+ if (target) {
840
+ const bodyWithNewDeclarations = appendDeclarationsToBody(target.body, updates);
841
+ nextCss = replaceRange(nextCss, target.bodyStart, target.bodyEnd, bodyWithNewDeclarations);
842
+ updates.clear();
843
+ continue;
844
+ }
845
+
846
+ const selector = themeName === 'primary' ? ':root' : ':root[data-theme="' + themeName + '"]';
847
+ const completeThemeUpdates =
848
+ fullThemeUpdates && fullThemeUpdates.get(themeName)
849
+ ? new Map(fullThemeUpdates.get(themeName) as Map<string, string>)
850
+ : null;
851
+ const blockUpdates = completeThemeUpdates && completeThemeUpdates.size > 0 ? completeThemeUpdates : updates;
852
+ const newBlock = appendDeclarationsToBody('', blockUpdates);
853
+ const suffix = nextCss.endsWith('\n') ? '' : '\n';
854
+ nextCss += suffix + selector + ' {\n' + newBlock + '}\n';
855
+ updates.clear();
856
+ }
857
+
858
+ return nextCss;
859
+ }
860
+
861
+ function normalizePath(pathValue: string | undefined, fallback: string): string {
862
+ if (!pathValue || !pathValue.trim()) return fallback;
863
+ return pathValue.trim();
864
+ }
865
+
866
+ function normalizeTextForCompare(content: string): string {
867
+ return content.replace(/\r\n/g, '\n');
868
+ }
869
+
870
+ function uniquePaths(paths: string[]): string[] {
871
+ const seen = new Set<string>();
872
+ const out: string[] = [];
873
+ for (let i = 0; i < paths.length; i++) {
874
+ const candidate = String(paths[i] || '').trim();
875
+ if (!candidate || seen.has(candidate)) continue;
876
+ seen.add(candidate);
877
+ out.push(candidate);
878
+ }
879
+ return out;
880
+ }
881
+
882
+ function buildCssPathCandidates(sourceInfo: TokenSourceInfo): string[] {
883
+ const candidates: string[] = [];
884
+ if (GITHUB_CONFIG && GITHUB_CONFIG.cssTokenPath) candidates.push(GITHUB_CONFIG.cssTokenPath);
885
+ if (sourceInfo.source && sourceInfo.source.endsWith('.css') && !sourceInfo.source.startsWith('embedded:')) {
886
+ candidates.push(sourceInfo.source);
887
+ }
888
+ for (let i = 0; i < CSS_DISCOVERY_PATHS.length; i++) {
889
+ candidates.push(CSS_DISCOVERY_PATHS[i]);
890
+ }
891
+ return uniquePaths(candidates);
892
+ }
893
+
894
+ async function resolveExistingCssFile(token: string, sourceInfo: TokenSourceInfo): Promise<ResolvedCssFile | null> {
895
+ const candidates = buildCssPathCandidates(sourceInfo);
896
+ for (let i = 0; i < candidates.length; i++) {
897
+ const path = candidates[i];
898
+ const content = await fetchFileContent(token, path, GITHUB_CONFIG!.baseBranch);
899
+ if (!content) continue;
900
+ return { path, content: content.content };
901
+ }
902
+ return null;
903
+ }
904
+
905
+ function queueFileIfChanged(
906
+ path: string,
907
+ nextContent: string,
908
+ previousContent: string | null,
909
+ filesToCommit: FileToCommit[],
910
+ filesChangedLabels: string[]
911
+ ): boolean {
912
+ if (previousContent != null) {
913
+ const same = normalizeTextForCompare(previousContent) === normalizeTextForCompare(nextContent);
914
+ if (same) return false;
915
+ }
916
+ filesToCommit.push({ path, content: nextContent });
917
+ filesChangedLabels.push('`' + path + '`');
918
+ return true;
919
+ }
920
+
921
+ function resolveConfiguredMode(mode: unknown): TokenSourceMode {
922
+ if (mode === 'css' || mode === 'dtcg' || mode === 'auto') return mode;
923
+ return 'auto';
924
+ }
925
+
926
+ async function buildTokenCommitPlan(token: string): Promise<TokenCommitPlan> {
927
+ const tokensBeforeVariablePatch = JSON.parse(JSON.stringify(TOKENS)) as Tokens;
928
+
929
+ // Build a minimal diff patch from current Figma Variables first.
930
+ // This avoids rewriting untouched tokens and keeps push diffs focused.
931
+ const variableDiffOptions = {
932
+ allowNewTokensFromFigma: GITHUB_CONFIG!.allowNewTokensFromFigma === true,
933
+ newTokenPrefixes: Array.isArray((GITHUB_CONFIG as any).newTokenPrefixes)
934
+ ? ((GITHUB_CONFIG as any).newTokenPrefixes as string[])
935
+ : [],
936
+ };
937
+ const variableTokenPatch = getVariableTokenDiffWithOptions(variableDiffOptions);
938
+ const workingTokens = variableTokenPatch
939
+ ? (deepMergeTokens(tokensBeforeVariablePatch as unknown, variableTokenPatch as unknown) as Tokens)
940
+ : tokensBeforeVariablePatch;
941
+
942
+ const sourceInfo = await loadTokenSourceInfo();
943
+ const configuredMode = resolveConfiguredMode(GITHUB_CONFIG!.tokenSourceMode);
944
+ const dtcgPath = normalizePath(GITHUB_CONFIG!.tokenPath, DEFAULT_DTCG_PATH);
945
+ const dtcgExisting = await fetchFileContent(token, dtcgPath, GITHUB_CONFIG!.baseBranch);
946
+
947
+ const preferDtcg =
948
+ configuredMode === 'dtcg' ||
949
+ (configuredMode === 'auto' && sourceInfo.mode === 'dtcg');
950
+
951
+ if (preferDtcg) {
952
+ const dtcg = tokensToDTCG(workingTokens);
953
+ const dtcgContent = JSON.stringify(dtcg, null, 2) + '\n';
954
+ const cssContent = tokensToCSS(workingTokens);
955
+ const cssExisting = await fetchFileContent(token, LEGACY_GENERATED_CSS_PATH, GITHUB_CONFIG!.baseBranch);
956
+ const filesToCommit: FileToCommit[] = [];
957
+ const filesChangedLabels: string[] = [];
958
+ queueFileIfChanged(dtcgPath, dtcgContent, dtcgExisting ? dtcgExisting.content : null, filesToCommit, filesChangedLabels);
959
+ queueFileIfChanged(
960
+ LEGACY_GENERATED_CSS_PATH,
961
+ cssContent,
962
+ cssExisting ? cssExisting.content : null,
963
+ filesToCommit,
964
+ filesChangedLabels
965
+ );
966
+ return {
967
+ filesToCommit,
968
+ filesChangedLabels,
969
+ sourceSummary: 'Token source: ' + dtcgPath + ' (' + configuredMode + ', resolved dtcg)',
970
+ dtcgSummary: 'DTCG sync: included (dtcg mode)',
971
+ };
972
+ }
973
+
974
+ const cssFile = await resolveExistingCssFile(token, sourceInfo);
975
+ if (!cssFile) {
976
+ if (configuredMode === 'css') {
977
+ throw new Error(
978
+ 'CSS token source not found. Set "CSS Token Path" in settings or add one of: ' +
979
+ CSS_DISCOVERY_PATHS.join(', ')
980
+ );
981
+ }
982
+ // auto fallback when CSS file cannot be resolved but DTCG exists
983
+ if (dtcgExisting) {
984
+ const fallbackDtcg = tokensToDTCG(workingTokens);
985
+ const fallbackDtcgContent = JSON.stringify(fallbackDtcg, null, 2) + '\n';
986
+ const fallbackCssContent = tokensToCSS(workingTokens);
987
+ const cssExisting = await fetchFileContent(token, LEGACY_GENERATED_CSS_PATH, GITHUB_CONFIG!.baseBranch);
988
+ const filesToCommit: FileToCommit[] = [];
989
+ const filesChangedLabels: string[] = [];
990
+ queueFileIfChanged(
991
+ dtcgPath,
992
+ fallbackDtcgContent,
993
+ dtcgExisting.content,
994
+ filesToCommit,
995
+ filesChangedLabels
996
+ );
997
+ queueFileIfChanged(
998
+ LEGACY_GENERATED_CSS_PATH,
999
+ fallbackCssContent,
1000
+ cssExisting ? cssExisting.content : null,
1001
+ filesToCommit,
1002
+ filesChangedLabels
1003
+ );
1004
+ return {
1005
+ filesToCommit,
1006
+ filesChangedLabels,
1007
+ sourceSummary: 'Token source: ' + dtcgPath + ' (' + configuredMode + ', resolved dtcg)',
1008
+ dtcgSummary: 'DTCG sync: included (auto fallback to dtcg)',
1009
+ };
1010
+ }
1011
+ throw new Error('No token source file found in repository for current settings.');
1012
+ }
1013
+
1014
+ const updates = buildCssVariableUpdates(variableTokenPatch || ({} as Tokens), tokensBeforeVariablePatch);
1015
+ const fullThemeUpdates = buildCssVariableUpdates(workingTokens, tokensBeforeVariablePatch);
1016
+ const activeThemeNames = getActiveThemeNames(workingTokens);
1017
+ let patchedCss = await patchCssViaDevServer(cssFile.path, cssFile.content, updates);
1018
+ if (typeof patchedCss !== 'string') {
1019
+ // Dev server route unavailable or invalid response: fallback to in-plugin patcher.
1020
+ patchedCss = patchCssVariables(cssFile.content, updates, activeThemeNames, fullThemeUpdates);
1021
+ } else {
1022
+ // Keep stale-theme pruning/add-missing-theme deterministic even when server route handled declaration patching.
1023
+ patchedCss = patchCssVariables(patchedCss, updates, activeThemeNames, fullThemeUpdates);
1024
+ }
1025
+ const filesToCommit: FileToCommit[] = [];
1026
+ const filesChangedLabels: string[] = [];
1027
+ queueFileIfChanged(cssFile.path, patchedCss, cssFile.content, filesToCommit, filesChangedLabels);
1028
+
1029
+ let dtcgSummary = 'DTCG sync: skipped (syncDtcgOnPush=false)';
1030
+ if (GITHUB_CONFIG!.syncDtcgOnPush) {
1031
+ if (!dtcgExisting) {
1032
+ dtcgSummary = 'DTCG sync: skipped (DTCG file not found)';
1033
+ } else {
1034
+ const dtcg = tokensToDTCG(workingTokens);
1035
+ const dtcgContent = JSON.stringify(dtcg, null, 2) + '\n';
1036
+ const changed = queueFileIfChanged(
1037
+ dtcgPath,
1038
+ dtcgContent,
1039
+ dtcgExisting.content,
1040
+ filesToCommit,
1041
+ filesChangedLabels
1042
+ );
1043
+ dtcgSummary = changed
1044
+ ? 'DTCG sync: included (' + dtcgPath + ')'
1045
+ : 'DTCG sync: no changes (' + dtcgPath + ')';
1046
+ }
1047
+ }
1048
+
1049
+ return {
1050
+ filesToCommit,
1051
+ filesChangedLabels,
1052
+ sourceSummary: 'Token source: ' + cssFile.path + ' (' + configuredMode + ', resolved css)',
1053
+ dtcgSummary,
1054
+ };
1055
+ }
1056
+
1057
+ export async function previewTokenChanges(token: string): Promise<TokenChangePreview> {
1058
+ await loadConfig();
1059
+ const plan = await buildTokenCommitPlan(token);
1060
+ return {
1061
+ hasChanges: plan.filesToCommit.length > 0,
1062
+ filesChangedLabels: plan.filesChangedLabels.slice(),
1063
+ sourceSummary: plan.sourceSummary,
1064
+ dtcgSummary: plan.dtcgSummary,
1065
+ };
1066
+ }
1067
+
1068
+ // ---------------------------------------------------------------------------
1069
+ // GitHub API operations
1070
+ // ---------------------------------------------------------------------------
1071
+
1072
+ /**
1073
+ * Get the SHA of a file on a given branch. Returns null if the file does not exist.
1074
+ */
1075
+ export async function getFileSha(
1076
+ token: string,
1077
+ path: string,
1078
+ branch?: string
1079
+ ): Promise<string | null> {
1080
+ try {
1081
+ const ref = branch || GITHUB_CONFIG!.baseBranch;
1082
+ const data = await githubFetch(
1083
+ `/repos/${GITHUB_CONFIG!.owner}/${GITHUB_CONFIG!.repo}/contents/${path}?ref=${ref}`,
1084
+ token
1085
+ );
1086
+ if (typeof data.sha === 'string' && data.sha.trim().length > 0) return data.sha;
1087
+ return null;
1088
+ } catch (_e) {
1089
+ // File doesn't exist yet
1090
+ return null;
1091
+ }
1092
+ }
1093
+
1094
+ /**
1095
+ * Get the latest commit SHA on the base branch.
1096
+ */
1097
+ export async function getLatestCommitSha(token: string): Promise<string> {
1098
+ const data = await githubFetch(
1099
+ `/repos/${GITHUB_CONFIG!.owner}/${GITHUB_CONFIG!.repo}/git/ref/heads/${GITHUB_CONFIG!.baseBranch}`,
1100
+ token
1101
+ );
1102
+ return requireResponseString(data.object && data.object.sha, 'object.sha from branch ref');
1103
+ }
1104
+
1105
+ /**
1106
+ * Create a new branch from a given SHA.
1107
+ */
1108
+ export async function createBranch(
1109
+ token: string,
1110
+ branchName: string,
1111
+ fromSha: string
1112
+ ): Promise<void> {
1113
+ await githubFetch(
1114
+ `/repos/${GITHUB_CONFIG!.owner}/${GITHUB_CONFIG!.repo}/git/refs`,
1115
+ token,
1116
+ {
1117
+ method: 'POST',
1118
+ body: JSON.stringify({
1119
+ ref: `refs/heads/${branchName}`,
1120
+ sha: fromSha,
1121
+ }),
1122
+ }
1123
+ );
1124
+ }
1125
+
1126
+ /**
1127
+ * Create or update a single file via the Contents API.
1128
+ */
1129
+ export async function createOrUpdateFile(
1130
+ token: string,
1131
+ path: string,
1132
+ content: string,
1133
+ message: string,
1134
+ branch: string,
1135
+ sha?: string | null
1136
+ ): Promise<void> {
1137
+ const body: Record<string, string> = {
1138
+ message,
1139
+ content: encodeBase64(content),
1140
+ branch,
1141
+ };
1142
+ if (sha) body.sha = sha;
1143
+
1144
+ await githubFetch(
1145
+ `/repos/${GITHUB_CONFIG!.owner}/${GITHUB_CONFIG!.repo}/contents/${path}`,
1146
+ token,
1147
+ {
1148
+ method: 'PUT',
1149
+ body: JSON.stringify(body),
1150
+ }
1151
+ );
1152
+ }
1153
+
1154
+ /**
1155
+ * Commit multiple files in a single commit using the Git Data API.
1156
+ * Avoids SHA race conditions when committing files sequentially via the Contents API.
1157
+ *
1158
+ * @param token - GitHub token
1159
+ * @param files - files to commit
1160
+ * @param message - commit message
1161
+ * @param branchRef - full ref e.g. 'heads/figma/tokens-...'
1162
+ * @param baseSha - commit SHA to base the new commit on
1163
+ * @returns the new commit SHA
1164
+ */
1165
+ export async function commitMultipleFiles(
1166
+ token: string,
1167
+ files: FileToCommit[],
1168
+ message: string,
1169
+ branchRef: string,
1170
+ baseSha: string
1171
+ ): Promise<string> {
1172
+ const repoBase = '/repos/' + GITHUB_CONFIG!.owner + '/' + GITHUB_CONFIG!.repo;
1173
+
1174
+ // 1. Create a blob for each file
1175
+ const treeItems: Array<{ path: string; mode: string; type: string; sha: string }> = [];
1176
+ for (let i = 0; i < files.length; i++) {
1177
+ const blob = await githubFetch(repoBase + '/git/blobs', token, {
1178
+ method: 'POST',
1179
+ body: JSON.stringify({
1180
+ content: files[i].content,
1181
+ encoding: 'utf-8',
1182
+ }),
1183
+ });
1184
+ const blobSha = requireResponseString(blob.sha, 'sha from blob creation for ' + files[i].path);
1185
+ treeItems.push({
1186
+ path: files[i].path,
1187
+ mode: '100644',
1188
+ type: 'blob',
1189
+ sha: blobSha,
1190
+ });
1191
+ }
1192
+
1193
+ // 2. Create a new tree based on the base commit's tree
1194
+ const baseCommit = await githubFetch(repoBase + '/git/commits/' + baseSha, token);
1195
+ const baseTreeSha = requireResponseString(baseCommit.tree && baseCommit.tree.sha, 'tree.sha from base commit');
1196
+ const newTree = await githubFetch(repoBase + '/git/trees', token, {
1197
+ method: 'POST',
1198
+ body: JSON.stringify({
1199
+ base_tree: baseTreeSha,
1200
+ tree: treeItems,
1201
+ }),
1202
+ });
1203
+ const newTreeSha = requireResponseString(newTree.sha, 'sha from tree creation');
1204
+
1205
+ // 3. Create a new commit
1206
+ const newCommit = await githubFetch(repoBase + '/git/commits', token, {
1207
+ method: 'POST',
1208
+ body: JSON.stringify({
1209
+ message: message,
1210
+ tree: newTreeSha,
1211
+ parents: [baseSha],
1212
+ }),
1213
+ });
1214
+ const newCommitSha = requireResponseString(newCommit.sha, 'sha from commit creation');
1215
+
1216
+ // 4. Update the branch ref to point to the new commit
1217
+ await githubFetch(repoBase + '/git/refs/' + branchRef, token, {
1218
+ method: 'PATCH',
1219
+ body: JSON.stringify({
1220
+ sha: newCommitSha,
1221
+ }),
1222
+ });
1223
+
1224
+ return newCommitSha;
1225
+ }
1226
+
1227
+ /**
1228
+ * Create a pull request and return its URL.
1229
+ */
1230
+ export async function createPullRequest(
1231
+ token: string,
1232
+ branchName: string,
1233
+ title: string,
1234
+ body: string
1235
+ ): Promise<string> {
1236
+ const data = await githubFetch(
1237
+ `/repos/${GITHUB_CONFIG!.owner}/${GITHUB_CONFIG!.repo}/pulls`,
1238
+ token,
1239
+ {
1240
+ method: 'POST',
1241
+ body: JSON.stringify({
1242
+ title,
1243
+ body,
1244
+ head: branchName,
1245
+ base: GITHUB_CONFIG!.baseBranch,
1246
+ }),
1247
+ }
1248
+ );
1249
+ return requireResponseString(data.html_url, 'html_url from pull request creation');
1250
+ }
1251
+
1252
+ // ---------------------------------------------------------------------------
1253
+ // High-level push flows
1254
+ // ---------------------------------------------------------------------------
1255
+
1256
+ /**
1257
+ * Main token push flow: extract tokens, commit all derived files, and open a PR.
1258
+ */
1259
+ export async function pushToGitHub(
1260
+ commitMessage: string,
1261
+ prDescription?: string
1262
+ ): Promise<string> {
1263
+ // Load config
1264
+ await loadConfig();
1265
+
1266
+ // Validate config
1267
+ if (!GITHUB_CONFIG!.owner || !GITHUB_CONFIG!.repo) {
1268
+ throw new Error('Repository not configured. Go to Settings to configure owner/repo.');
1269
+ }
1270
+
1271
+ const token = await figma.clientStorage.getAsync('github_token') as string;
1272
+ if (!token) {
1273
+ throw new Error('GitHub token not configured. Please configure your token first.');
1274
+ }
1275
+
1276
+ const tokenPlan = await buildTokenCommitPlan(token);
1277
+ if (tokenPlan.filesToCommit.length === 0) {
1278
+ throw new Error('No token changes detected for the configured token source.');
1279
+ }
1280
+
1281
+ // Generate branch name
1282
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
1283
+ const branchName = 'figma/tokens-' + timestamp;
1284
+
1285
+ // Get latest commit SHA
1286
+ const latestSha = await getLatestCommitSha(token);
1287
+
1288
+ // Create new branch
1289
+ await createBranch(token, branchName, latestSha);
1290
+
1291
+ await commitMultipleFiles(token, tokenPlan.filesToCommit, commitMessage, 'heads/' + branchName, latestSha);
1292
+
1293
+ // Create PR
1294
+ const projectLabel = GITHUB_CONFIG!.projectName || (GITHUB_CONFIG!.owner + '/' + GITHUB_CONFIG!.repo);
1295
+ const changedLines =
1296
+ tokenPlan.filesChangedLabels.length > 0
1297
+ ? tokenPlan.filesChangedLabels.map((label) => '- Updated ' + label).join('\n')
1298
+ : '- No token files changed';
1299
+ const prBody =
1300
+ '## Design Token Update\n\n' +
1301
+ (prDescription || 'Updated design tokens from Figma.') +
1302
+ '\n\n' +
1303
+ '### Changes\n' +
1304
+ changedLines +
1305
+ '\n\n' +
1306
+ '### Token Source\n' +
1307
+ '- ' + tokenPlan.sourceSummary + '\n' +
1308
+ '- ' + tokenPlan.dtcgSummary + '\n\n' +
1309
+ '---\n' +
1310
+ '*Auto-generated from ' + projectLabel + ' Figma plugin*';
1311
+
1312
+ const prUrl = await createPullRequest(token, branchName, commitMessage, prBody);
1313
+ return prUrl;
1314
+ }
1315
+
1316
+ /**
1317
+ * Extended push flow with component file changes.
1318
+ * Commits token updates and component style patches in separate commits on the same branch.
1319
+ */
1320
+ export async function pushToGitHubWithComponents(
1321
+ commitMessage: string,
1322
+ prDescription: string | undefined,
1323
+ includeTokens: boolean,
1324
+ componentChanges: ComponentChange[],
1325
+ onProgress?: (message: string) => void
1326
+ ): Promise<string> {
1327
+ const progress = typeof onProgress === 'function' ? onProgress : (_message: string) => {};
1328
+
1329
+ // Load config
1330
+ progress('Loading configuration...');
1331
+ await loadConfig();
1332
+
1333
+ // Validate config
1334
+ progress('Validating repository settings...');
1335
+ if (!GITHUB_CONFIG!.owner || !GITHUB_CONFIG!.repo) {
1336
+ throw new Error('Repository not configured. Go to Settings to configure owner/repo.');
1337
+ }
1338
+
1339
+ const token = await figma.clientStorage.getAsync('github_token') as string;
1340
+ if (!token) {
1341
+ throw new Error('GitHub token not configured. Please configure your token first.');
1342
+ }
1343
+
1344
+ // Generate branch name
1345
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
1346
+ const branchName = 'figma/design-update-' + timestamp;
1347
+
1348
+ // Get latest commit SHA
1349
+ progress('Fetching latest commit...');
1350
+ let latestSha = await getLatestCommitSha(token);
1351
+
1352
+ // Create new branch
1353
+ progress('Creating branch...');
1354
+ await createBranch(token, branchName, latestSha);
1355
+
1356
+ const filesChanged: string[] = [];
1357
+ let tokenPlan: TokenCommitPlan | null = null;
1358
+
1359
+ // 1. Update tokens if requested
1360
+ if (includeTokens) {
1361
+ progress('Preparing token updates...');
1362
+ tokenPlan = await buildTokenCommitPlan(token);
1363
+ if (tokenPlan.filesToCommit.length > 0) {
1364
+ progress('Committing token updates...');
1365
+ const newCommitSha = await commitMultipleFiles(
1366
+ token,
1367
+ tokenPlan.filesToCommit,
1368
+ commitMessage,
1369
+ 'heads/' + branchName,
1370
+ latestSha
1371
+ );
1372
+ latestSha = newCommitSha;
1373
+ for (let i = 0; i < tokenPlan.filesChangedLabels.length; i++) {
1374
+ filesChanged.push(tokenPlan.filesChangedLabels[i]);
1375
+ }
1376
+ }
1377
+ }
1378
+
1379
+ // 2. Update component files (batch into a single commit)
1380
+ const componentUpdates: Array<{ name: string; changes: ClassChange[] }> = [];
1381
+ const componentFiles: FileToCommit[] = [];
1382
+
1383
+ for (let i = 0; i < componentChanges.length; i++) {
1384
+ const change = componentChanges[i];
1385
+ if (!change.file) continue;
1386
+
1387
+ try {
1388
+ // Fetch current file content from base branch (unchanged by token commit)
1389
+ const fileData = await fetchFileContent(token, change.file, GITHUB_CONFIG!.baseBranch);
1390
+ if (!fileData) continue;
1391
+
1392
+ const content = fileData.content;
1393
+
1394
+ // Apply class changes
1395
+ const updatedContent = applyClassChangesToFile(content, change);
1396
+
1397
+ if (updatedContent !== content) {
1398
+ componentFiles.push({ path: change.file, content: updatedContent });
1399
+ filesChanged.push('`' + change.file + '`');
1400
+ componentUpdates.push({
1401
+ name: change.name,
1402
+ changes: change.changes,
1403
+ });
1404
+ }
1405
+ } catch (e: unknown) {
1406
+ const message = e instanceof Error ? e.message : String(e);
1407
+ debug('Failed to update component file', change.file, message);
1408
+ // Continue with other files
1409
+ }
1410
+ }
1411
+
1412
+ if (componentFiles.length > 0) {
1413
+ progress('Committing component updates...');
1414
+ const compCommitSha = await commitMultipleFiles(
1415
+ token,
1416
+ componentFiles,
1417
+ 'Update component styles from Figma',
1418
+ 'heads/' + branchName,
1419
+ latestSha
1420
+ );
1421
+ latestSha = compCommitSha;
1422
+ }
1423
+
1424
+ if (filesChanged.length === 0) {
1425
+ throw new Error('No token or component changes detected to commit.');
1426
+ }
1427
+
1428
+ // 3. Create PR with detailed description
1429
+ const projectLabel = GITHUB_CONFIG!.projectName || (GITHUB_CONFIG!.owner + '/' + GITHUB_CONFIG!.repo);
1430
+
1431
+ const prBodyParts: string[] = ['## Design System Update\n'];
1432
+ prBodyParts.push((prDescription || 'Updated design system from Figma.') + '\n');
1433
+
1434
+ prBodyParts.push('### Files Changed\n');
1435
+ for (let j = 0; j < filesChanged.length; j++) {
1436
+ prBodyParts.push('- ' + filesChanged[j] + '\n');
1437
+ }
1438
+
1439
+ if (componentUpdates.length > 0) {
1440
+ prBodyParts.push('\n### Component Updates\n');
1441
+ for (let k = 0; k < componentUpdates.length; k++) {
1442
+ const update = componentUpdates[k];
1443
+ prBodyParts.push('\n**' + update.name + '**\n');
1444
+ for (let l = 0; l < update.changes.length; l++) {
1445
+ const ch = update.changes[l];
1446
+ prBodyParts.push('- ' + ch.property + ': `' + ch.code + '` → `' + ch.figma + '`\n');
1447
+ }
1448
+ }
1449
+ }
1450
+
1451
+ if (includeTokens && tokenPlan) {
1452
+ prBodyParts.push('\n### Token Source\n');
1453
+ prBodyParts.push('- ' + tokenPlan.sourceSummary + '\n');
1454
+ prBodyParts.push('- ' + tokenPlan.dtcgSummary + '\n');
1455
+ }
1456
+
1457
+ prBodyParts.push('\n---\n');
1458
+ prBodyParts.push('*Auto-generated from ' + projectLabel + ' Figma plugin*');
1459
+
1460
+ const prBody = prBodyParts.join('');
1461
+ progress('Creating pull request...');
1462
+ const prUrl = await createPullRequest(token, branchName, commitMessage, prBody);
1463
+ progress('Pull request created.');
1464
+ return prUrl;
1465
+ }