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.
- package/README.md +201 -0
- package/bin/inkhouse.mjs +171 -0
- package/code.js +11802 -0
- package/manifest.json +30 -0
- package/package.json +45 -0
- package/scanner/blob-placement-regression.ts +132 -0
- package/scanner/class-collector.ts +69 -0
- package/scanner/cli.ts +336 -0
- package/scanner/component-scanner.ts +2876 -0
- package/scanner/css-patch-regression.ts +112 -0
- package/scanner/css-token-reader-regression.ts +92 -0
- package/scanner/css-token-reader.ts +477 -0
- package/scanner/font-style-resolver-regression.ts +32 -0
- package/scanner/index.ts +9 -0
- package/scanner/radial-gradient-regression.ts +53 -0
- package/scanner/style-map.ts +145 -0
- package/scanner/tailwind-parser.ts +644 -0
- package/scanner/transform-math-regression.ts +42 -0
- package/scanner/types.ts +298 -0
- package/src/blob-placement.ts +111 -0
- package/src/change-detection.ts +204 -0
- package/src/class-utils.ts +105 -0
- package/src/clip-path-decorative.ts +194 -0
- package/src/color-resolver.ts +98 -0
- package/src/colors.ts +196 -0
- package/src/component-defs.ts +54 -0
- package/src/component-gen.ts +561 -0
- package/src/component-lookup.ts +82 -0
- package/src/config.ts +115 -0
- package/src/design-system.ts +59 -0
- package/src/dev-server.ts +173 -0
- package/src/figma-globals.d.ts +3 -0
- package/src/font-style-resolver.ts +171 -0
- package/src/github.ts +1465 -0
- package/src/icon-builder.ts +607 -0
- package/src/image-cache.ts +22 -0
- package/src/inline-text.ts +271 -0
- package/src/layout-parser.ts +667 -0
- package/src/layout-utils.ts +155 -0
- package/src/main.ts +687 -0
- package/src/node-ir.ts +595 -0
- package/src/pack-provider.ts +148 -0
- package/src/packs.ts +126 -0
- package/src/radial-gradient.ts +84 -0
- package/src/render-context.ts +138 -0
- package/src/responsive-analyzer.ts +139 -0
- package/src/state-analyzer.ts +143 -0
- package/src/story-builder.ts +1706 -0
- package/src/story-layout.ts +38 -0
- package/src/tailwind.ts +2379 -0
- package/src/text-builder.ts +116 -0
- package/src/text-line.ts +42 -0
- package/src/token-source.ts +43 -0
- package/src/tokens.ts +717 -0
- package/src/transform-math.ts +44 -0
- package/src/ui-builder.ts +1996 -0
- package/src/utility-resolver.ts +125 -0
- package/src/variables.ts +1042 -0
- package/src/width-solver.ts +466 -0
- package/templates/patch-tokens-route.ts +165 -0
- package/templates/scan-components-route.ts +57 -0
- 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
|
+
}
|