@frame12/lint 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +225 -2
- package/dist/cli.mjs +4 -0
- package/dist/index.d.ts +68 -0
- package/dist/index.js +2771 -0
- package/package.json +46 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,2771 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @frame12/lint — Automated design-grammar checker for FRAME/12.
|
|
3
|
+
// Enforces:
|
|
4
|
+
// 1. color-literal — No hard-coded hex/rgb/hsl colors, CSS gradients, or font stacks outside token files
|
|
5
|
+
// 2. illegal-gutter — Only 12px (pair), 20px (scene), and 32px (change) gutters
|
|
6
|
+
// 3. marker-misuse — --f-marker / #ffd60a only as background paired with --f-marker-ink
|
|
7
|
+
// 4. cut-order — Sequential C-01, C-02, C-03... in DOM/reading order without gaps or duplicates
|
|
8
|
+
// 5. explicit-cut-in-autocut — No explicit cut/topCut/bottomCut on Panel/PanelPair inside <Sheet autoCut>
|
|
9
|
+
// 6. raw-shadcn-import — No raw @/components/ui/*, @radix-ui/*, or external chart/animation imports
|
|
10
|
+
// 7. unsafe-html — No external scripts/resources, iframes/embeds, inline on*= handlers, or javascript: URLs in HTML
|
|
11
|
+
|
|
12
|
+
import { readdirSync, readFileSync, realpathSync, statSync, existsSync } from 'node:fs';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { resolve, relative, basename, extname } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
export const RULES = Object.freeze({
|
|
18
|
+
'color-literal':
|
|
19
|
+
'Forbid hard-coded color literals (#hex, rgb/hsl/oklch, CSS gradients, font names) outside token definitions; use var(--f-*) tokens.',
|
|
20
|
+
'illegal-gutter':
|
|
21
|
+
'Permit only 0, 12px (--f-gutter-pair), 20px (--f-gutter-scene), or 32px (--f-gutter-change) gutters on gap, grid-gap, row-gap, and column-gap.',
|
|
22
|
+
'marker-misuse':
|
|
23
|
+
'Permit --f-marker (#ffd60a) only as a surface background paired with color: var(--f-marker-ink) in the same rule; never on text, borders, outlines, shadows, or SVG strokes/fills.',
|
|
24
|
+
'cut-order':
|
|
25
|
+
'Require panel cut labels (C-01, C-02, …) to use two-digit formatting and appear in strict sequential reading order starting at C-01 without gaps or duplicates.',
|
|
26
|
+
'explicit-cut-in-autocut':
|
|
27
|
+
'Forbid explicit cut props on Panel/PanelPair inside <Sheet autoCut> where cuts are numbered automatically.',
|
|
28
|
+
'raw-shadcn-import':
|
|
29
|
+
'Forbid raw @/components/ui/*, @radix-ui/*, or third-party chart/motion imports where a FRAME/12 component or adapter exists.',
|
|
30
|
+
'unsafe-html':
|
|
31
|
+
'Forbid external resource loads (<script src>, <link href>, <img src>, CSS @import/url()), embedded frames (<iframe>, <object>, <embed>, srcdoc), <meta http-equiv="refresh">, <base href>, inline on*= handlers, and javascript: URLs in generated HTML.',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const CODE_EXTENSIONS = new Set(['.tsx', '.ts', '.jsx', '.js', '.css', '.html', '.htm']);
|
|
35
|
+
const TOKEN_BASENAMES = new Set([
|
|
36
|
+
'frame12.css',
|
|
37
|
+
'frame12-tokens.css',
|
|
38
|
+
'frame12-tailwind.css',
|
|
39
|
+
'og-image.tsx',
|
|
40
|
+
'apple-icon.tsx',
|
|
41
|
+
]);
|
|
42
|
+
const TOKEN_EXEMPT_PATHS = ['packages/tokens/dist/tokens.css'];
|
|
43
|
+
const IGNORED_DIRS = new Set([
|
|
44
|
+
'node_modules',
|
|
45
|
+
'.git',
|
|
46
|
+
'dist',
|
|
47
|
+
'.next',
|
|
48
|
+
'.f12-run',
|
|
49
|
+
'test-results',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const LEGAL_GUTTER_NAMES = new Set(['pair', 'scene', 'change']);
|
|
53
|
+
const LEGAL_GUTTER_TOKENS = new Set([
|
|
54
|
+
'0',
|
|
55
|
+
'0px',
|
|
56
|
+
'12',
|
|
57
|
+
'12px',
|
|
58
|
+
'20',
|
|
59
|
+
'20px',
|
|
60
|
+
'32',
|
|
61
|
+
'32px',
|
|
62
|
+
'var(--f-gutter-pair)',
|
|
63
|
+
'var(--f-gutter-scene)',
|
|
64
|
+
'var(--f-gutter-change)',
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
const LEGAL_TAILWIND_GAP_CLASSES = new Set([
|
|
68
|
+
'gap-0',
|
|
69
|
+
'gap-x-0',
|
|
70
|
+
'gap-y-0',
|
|
71
|
+
'gap-gutter-pair',
|
|
72
|
+
'gap-gutter-scene',
|
|
73
|
+
'gap-gutter-change',
|
|
74
|
+
'gap-[12px]',
|
|
75
|
+
'gap-[20px]',
|
|
76
|
+
'gap-[32px]',
|
|
77
|
+
'gap-[var(--f-gutter-pair)]',
|
|
78
|
+
'gap-[var(--f-gutter-scene)]',
|
|
79
|
+
'gap-[var(--f-gutter-change)]',
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
export const FORBIDDEN_SHADCN_MODULES = new Set([
|
|
83
|
+
'button',
|
|
84
|
+
'input',
|
|
85
|
+
'textarea',
|
|
86
|
+
'checkbox',
|
|
87
|
+
'radio-group',
|
|
88
|
+
'switch',
|
|
89
|
+
'field',
|
|
90
|
+
'label',
|
|
91
|
+
'badge',
|
|
92
|
+
'tag',
|
|
93
|
+
'alert',
|
|
94
|
+
'toast',
|
|
95
|
+
'toaster',
|
|
96
|
+
'sonner',
|
|
97
|
+
'skeleton',
|
|
98
|
+
'empty-state',
|
|
99
|
+
'breadcrumb',
|
|
100
|
+
'pagination',
|
|
101
|
+
'card',
|
|
102
|
+
'tabs',
|
|
103
|
+
'dialog',
|
|
104
|
+
'select',
|
|
105
|
+
'tooltip',
|
|
106
|
+
'dropdown-menu',
|
|
107
|
+
'table',
|
|
108
|
+
'sheet',
|
|
109
|
+
'panel',
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
export const FORBIDDEN_EXTERNAL_PACKAGES = new Set([
|
|
113
|
+
'recharts',
|
|
114
|
+
'chart.js',
|
|
115
|
+
'react-chartjs-2',
|
|
116
|
+
'd3',
|
|
117
|
+
'framer-motion',
|
|
118
|
+
'gsap',
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
const CUT_PROP_NAMES = new Set(['cut', 'topCut', 'bottomCut']);
|
|
122
|
+
const CUT_OWNER_TAGS = new Set(['Panel', 'PanelPair', 'DialogContent']);
|
|
123
|
+
|
|
124
|
+
const HTML_VOID_ELEMENTS = new Set([
|
|
125
|
+
'area',
|
|
126
|
+
'base',
|
|
127
|
+
'br',
|
|
128
|
+
'col',
|
|
129
|
+
'embed',
|
|
130
|
+
'hr',
|
|
131
|
+
'img',
|
|
132
|
+
'input',
|
|
133
|
+
'link',
|
|
134
|
+
'meta',
|
|
135
|
+
'param',
|
|
136
|
+
'source',
|
|
137
|
+
'track',
|
|
138
|
+
'wbr',
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
const FORBIDDEN_EMBED_TAGS = new Set(['iframe', 'object', 'embed']);
|
|
142
|
+
const EXTERNAL_SUBRESOURCE_TAGS = new Set(['link', 'img', 'source', 'video', 'audio', 'track', 'image']);
|
|
143
|
+
const EXTERNAL_SUBRESOURCE_ATTRS = new Set(['src', 'srcset', 'href', 'xlink:href', 'poster']);
|
|
144
|
+
|
|
145
|
+
let cachedTs = undefined;
|
|
146
|
+
let warnedTypeScriptFallback = false;
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const tsMod = await import('typescript');
|
|
150
|
+
cachedTs = tsMod.default ?? tsMod;
|
|
151
|
+
} catch {
|
|
152
|
+
cachedTs = undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function loadTypeScript() {
|
|
156
|
+
if (cachedTs !== undefined) return cachedTs;
|
|
157
|
+
const searchAnchors = [import.meta.url, resolve(process.cwd(), 'package.json')];
|
|
158
|
+
for (const anchor of searchAnchors) {
|
|
159
|
+
try {
|
|
160
|
+
const req = createRequire(anchor);
|
|
161
|
+
cachedTs = req('typescript');
|
|
162
|
+
return cachedTs;
|
|
163
|
+
} catch {
|
|
164
|
+
// try next anchor
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
cachedTs = null;
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Replaces JS/CSS comments with spaces while preserving newlines and string literals
|
|
173
|
+
* so URLs like "https://example.com" inside strings are never treated as // comments.
|
|
174
|
+
*/
|
|
175
|
+
function stripCommentsPreservingLines(src) {
|
|
176
|
+
let out = '';
|
|
177
|
+
let i = 0;
|
|
178
|
+
const len = src.length;
|
|
179
|
+
let state = 'code'; // 'code' | 'single' | 'double' | 'template' | 'line-comment' | 'block-comment'
|
|
180
|
+
|
|
181
|
+
while (i < len) {
|
|
182
|
+
const ch = src[i];
|
|
183
|
+
const next = i + 1 < len ? src[i + 1] : '';
|
|
184
|
+
|
|
185
|
+
if (state === 'code') {
|
|
186
|
+
if (ch === '/' && next === '/') {
|
|
187
|
+
state = 'line-comment';
|
|
188
|
+
out += ' ';
|
|
189
|
+
i += 2;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (ch === '/' && next === '*') {
|
|
193
|
+
state = 'block-comment';
|
|
194
|
+
out += ' ';
|
|
195
|
+
i += 2;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const prev = i > 0 ? src[i - 1] : '';
|
|
199
|
+
const precededByWord = /[a-zA-Z0-9_$]/.test(prev);
|
|
200
|
+
if (ch === "'" && !precededByWord) {
|
|
201
|
+
state = 'single';
|
|
202
|
+
out += ch;
|
|
203
|
+
i++;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (ch === '"' && !precededByWord) {
|
|
207
|
+
state = 'double';
|
|
208
|
+
out += ch;
|
|
209
|
+
i++;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (ch === '`') {
|
|
213
|
+
state = 'template';
|
|
214
|
+
out += ch;
|
|
215
|
+
i++;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
out += ch;
|
|
219
|
+
i++;
|
|
220
|
+
} else if (state === 'single') {
|
|
221
|
+
if (ch === '\\' && i + 1 < len) {
|
|
222
|
+
out += ch + next;
|
|
223
|
+
i += 2;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (ch === "'" || ch === '\n') {
|
|
227
|
+
state = 'code';
|
|
228
|
+
}
|
|
229
|
+
out += ch;
|
|
230
|
+
i++;
|
|
231
|
+
} else if (state === 'double') {
|
|
232
|
+
if (ch === '\\' && i + 1 < len) {
|
|
233
|
+
out += ch + next;
|
|
234
|
+
i += 2;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (ch === '"' || ch === '\n') {
|
|
238
|
+
state = 'code';
|
|
239
|
+
}
|
|
240
|
+
out += ch;
|
|
241
|
+
i++;
|
|
242
|
+
} else if (state === 'template') {
|
|
243
|
+
if (ch === '\\' && i + 1 < len) {
|
|
244
|
+
out += ch + next;
|
|
245
|
+
i += 2;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (ch === '`') {
|
|
249
|
+
state = 'code';
|
|
250
|
+
}
|
|
251
|
+
out += ch;
|
|
252
|
+
i++;
|
|
253
|
+
} else if (state === 'line-comment') {
|
|
254
|
+
if (ch === '\n') {
|
|
255
|
+
state = 'code';
|
|
256
|
+
out += '\n';
|
|
257
|
+
} else {
|
|
258
|
+
out += ' ';
|
|
259
|
+
}
|
|
260
|
+
i++;
|
|
261
|
+
} else if (state === 'block-comment') {
|
|
262
|
+
if (ch === '*' && next === '/') {
|
|
263
|
+
state = 'code';
|
|
264
|
+
out += ' ';
|
|
265
|
+
i += 2;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
out += ch === '\n' ? '\n' : ' ';
|
|
269
|
+
i++;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Replaces CSS block comments (`/* ... * /`) with spaces while preserving newlines
|
|
278
|
+
* without treating `//` inside URLs or selectors as line comments.
|
|
279
|
+
*/
|
|
280
|
+
function stripCssCommentsPreservingLines(src) {
|
|
281
|
+
let out = '';
|
|
282
|
+
let i = 0;
|
|
283
|
+
const len = src.length;
|
|
284
|
+
let state = 'code'; // 'code' | 'single' | 'double' | 'block-comment'
|
|
285
|
+
|
|
286
|
+
while (i < len) {
|
|
287
|
+
const ch = src[i];
|
|
288
|
+
const next = i + 1 < len ? src[i + 1] : '';
|
|
289
|
+
|
|
290
|
+
if (state === 'code') {
|
|
291
|
+
if (ch === '/' && next === '*') {
|
|
292
|
+
state = 'block-comment';
|
|
293
|
+
out += ' ';
|
|
294
|
+
i += 2;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (ch === "'") {
|
|
298
|
+
state = 'single';
|
|
299
|
+
out += ch;
|
|
300
|
+
i++;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (ch === '"') {
|
|
304
|
+
state = 'double';
|
|
305
|
+
out += ch;
|
|
306
|
+
i++;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
out += ch;
|
|
310
|
+
i++;
|
|
311
|
+
} else if (state === 'single') {
|
|
312
|
+
if (ch === '\\' && i + 1 < len) {
|
|
313
|
+
out += ch + next;
|
|
314
|
+
i += 2;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (ch === "'" || ch === '\n') state = 'code';
|
|
318
|
+
out += ch;
|
|
319
|
+
i++;
|
|
320
|
+
} else if (state === 'double') {
|
|
321
|
+
if (ch === '\\' && i + 1 < len) {
|
|
322
|
+
out += ch + next;
|
|
323
|
+
i += 2;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (ch === '"' || ch === '\n') state = 'code';
|
|
327
|
+
out += ch;
|
|
328
|
+
i++;
|
|
329
|
+
} else if (state === 'block-comment') {
|
|
330
|
+
if (ch === '*' && next === '/') {
|
|
331
|
+
state = 'code';
|
|
332
|
+
out += ' ';
|
|
333
|
+
i += 2;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
out += ch === '\n' ? '\n' : ' ';
|
|
337
|
+
i++;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function lineNumberAt(src, index) {
|
|
345
|
+
let line = 1;
|
|
346
|
+
for (let i = 0; i < index && i < src.length; i++) {
|
|
347
|
+
if (src.charCodeAt(i) === 10) line++;
|
|
348
|
+
}
|
|
349
|
+
return line;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function columnNumberAt(src, index) {
|
|
353
|
+
const clamped = Math.max(0, Math.min(index, src.length));
|
|
354
|
+
const lastNl = src.lastIndexOf('\n', clamped - 1);
|
|
355
|
+
return clamped - lastNl;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function isLegalGapValue(rawVal) {
|
|
359
|
+
const cleaned = rawVal
|
|
360
|
+
.trim()
|
|
361
|
+
.replace(/^['"`]|['"`]$/g, '')
|
|
362
|
+
.replace(/!important$/i, '')
|
|
363
|
+
.trim();
|
|
364
|
+
if (!cleaned) return true;
|
|
365
|
+
if (LEGAL_GUTTER_TOKENS.has(cleaned)) return true;
|
|
366
|
+
// Support 2-token shorthand like "12px 20px" or "var(--f-gutter-pair) var(--f-gutter-scene)"
|
|
367
|
+
const parts = cleaned.match(/var\([^)]+\)|\S+/g) || [];
|
|
368
|
+
if (parts.length >= 1 && parts.length <= 2 && parts.every((p) => LEGAL_GUTTER_TOKENS.has(p))) {
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Extracts balanced {...} content starting at the opening '{' at openIdx.
|
|
376
|
+
*/
|
|
377
|
+
function extractBalancedBraces(src, openIdx) {
|
|
378
|
+
let depth = 0;
|
|
379
|
+
for (let i = openIdx; i < src.length; i++) {
|
|
380
|
+
const ch = src[i];
|
|
381
|
+
if (ch === '{') depth++;
|
|
382
|
+
else if (ch === '}') {
|
|
383
|
+
depth--;
|
|
384
|
+
if (depth === 0) {
|
|
385
|
+
return src.slice(openIdx + 1, i);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return src.slice(openIdx + 1);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Validates sequential C-01, C-02, ... cut matches and appends any violations.
|
|
394
|
+
*/
|
|
395
|
+
function validateCutSequence(cutMatches, filePath, violations) {
|
|
396
|
+
if (cutMatches.length === 0) return;
|
|
397
|
+
const seen = new Set();
|
|
398
|
+
for (let i = 0; i < cutMatches.length; i++) {
|
|
399
|
+
const cur = cutMatches[i];
|
|
400
|
+
const expectedNum = i + 1;
|
|
401
|
+
const expectedLabel = `C-${String(expectedNum).padStart(2, '0')}`;
|
|
402
|
+
|
|
403
|
+
if (i === 0 && cur.num !== 1) {
|
|
404
|
+
violations.push({
|
|
405
|
+
rule: 'cut-order',
|
|
406
|
+
file: filePath,
|
|
407
|
+
line: cur.line,
|
|
408
|
+
column: cur.column,
|
|
409
|
+
message: `First cut in file is "${cur.raw}", but cuts must start at "C-01".`,
|
|
410
|
+
snippet: cur.snippet,
|
|
411
|
+
});
|
|
412
|
+
} else if (seen.has(cur.num)) {
|
|
413
|
+
violations.push({
|
|
414
|
+
rule: 'cut-order',
|
|
415
|
+
file: filePath,
|
|
416
|
+
line: cur.line,
|
|
417
|
+
column: cur.column,
|
|
418
|
+
message: `Duplicate cut number "${cur.raw}". Expected "${expectedLabel}" in reading/DOM order.`,
|
|
419
|
+
snippet: cur.snippet,
|
|
420
|
+
});
|
|
421
|
+
} else if (cur.num !== expectedNum) {
|
|
422
|
+
const prevLabel = i > 0 ? cutMatches[i - 1].raw : 'start';
|
|
423
|
+
violations.push({
|
|
424
|
+
rule: 'cut-order',
|
|
425
|
+
file: filePath,
|
|
426
|
+
line: cur.line,
|
|
427
|
+
column: cur.column,
|
|
428
|
+
message: `Out-of-order or skipped cut "${cur.raw}" after "${prevLabel}". Expected sequential "${expectedLabel}".`,
|
|
429
|
+
snippet: cur.snippet,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
seen.add(cur.num);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Uses the TypeScript compiler AST (`ts.createSourceFile(..., ScriptKind.TSX)`)
|
|
438
|
+
* to validate `cut-order` and `explicit-cut-in-autocut` across JSX elements,
|
|
439
|
+
* attributes, spread objects, and nested `<Sheet autoCut>` boundaries.
|
|
440
|
+
*/
|
|
441
|
+
function lintCutsWithTypeScript(ts, content, filePath, violations) {
|
|
442
|
+
const sourceFile = ts.createSourceFile(
|
|
443
|
+
filePath,
|
|
444
|
+
content,
|
|
445
|
+
ts.ScriptTarget.Latest,
|
|
446
|
+
/* setParentNodes */ true,
|
|
447
|
+
ts.ScriptKind.TSX,
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
const UNSET = Symbol('unset');
|
|
451
|
+
const deferredVarInitializers = new Set();
|
|
452
|
+
const visitedDeferredInitializers = new Set();
|
|
453
|
+
const activeInitializers = new Set();
|
|
454
|
+
let trackDeferredInitializers = false;
|
|
455
|
+
let inTabsContent = false;
|
|
456
|
+
|
|
457
|
+
function resolveLocalIdentifier(idNode, seenIds = new Set()) {
|
|
458
|
+
const name = idNode.text;
|
|
459
|
+
if (name === 'undefined') return idNode;
|
|
460
|
+
if (seenIds.has(name)) return null;
|
|
461
|
+
seenIds.add(name);
|
|
462
|
+
|
|
463
|
+
for (let cur = idNode.parent; cur; cur = cur.parent) {
|
|
464
|
+
if (
|
|
465
|
+
(ts.isFunctionDeclaration(cur) ||
|
|
466
|
+
ts.isFunctionExpression(cur) ||
|
|
467
|
+
ts.isArrowFunction(cur) ||
|
|
468
|
+
ts.isMethodDeclaration(cur)) &&
|
|
469
|
+
cur.parameters
|
|
470
|
+
) {
|
|
471
|
+
for (const param of cur.parameters) {
|
|
472
|
+
if (ts.isIdentifier(param.name) && param.name.text === name) {
|
|
473
|
+
if (param.initializer) return param.initializer;
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const statements = ts.isSourceFile(cur)
|
|
480
|
+
? cur.statements
|
|
481
|
+
: ts.isBlock(cur) || ts.isModuleBlock(cur)
|
|
482
|
+
? cur.statements
|
|
483
|
+
: null;
|
|
484
|
+
if (!statements) continue;
|
|
485
|
+
|
|
486
|
+
for (const stmt of statements) {
|
|
487
|
+
if (!ts.isVariableStatement(stmt)) continue;
|
|
488
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
489
|
+
if (ts.isIdentifier(decl.name) && decl.name.text === name && decl.initializer) {
|
|
490
|
+
if (trackDeferredInitializers) {
|
|
491
|
+
deferredVarInitializers.add(decl.initializer);
|
|
492
|
+
}
|
|
493
|
+
if (ts.isIdentifier(decl.initializer)) {
|
|
494
|
+
if (decl.initializer.text === 'undefined') return decl.initializer;
|
|
495
|
+
return resolveLocalIdentifier(decl.initializer, seenIds) ?? decl.initializer;
|
|
496
|
+
}
|
|
497
|
+
return decl.initializer;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function unwrapExpression(node) {
|
|
506
|
+
let cur = node;
|
|
507
|
+
const seen = new Set();
|
|
508
|
+
while (cur && !seen.has(cur)) {
|
|
509
|
+
seen.add(cur);
|
|
510
|
+
if (ts.isJsxExpression(cur)) {
|
|
511
|
+
cur = cur.expression;
|
|
512
|
+
} else if (
|
|
513
|
+
ts.isParenthesizedExpression(cur) ||
|
|
514
|
+
ts.isAsExpression(cur) ||
|
|
515
|
+
ts.isTypeAssertionExpression?.(cur) ||
|
|
516
|
+
ts.isSatisfiesExpression?.(cur) ||
|
|
517
|
+
ts.isNonNullExpression(cur)
|
|
518
|
+
) {
|
|
519
|
+
cur = cur.expression;
|
|
520
|
+
} else if (ts.isIdentifier(cur)) {
|
|
521
|
+
if (cur.text === 'undefined') break;
|
|
522
|
+
const resolved = resolveLocalIdentifier(cur);
|
|
523
|
+
if (!resolved) break;
|
|
524
|
+
cur = resolved;
|
|
525
|
+
} else {
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return cur || null;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function getCanonicalTagName(tagNameNode) {
|
|
533
|
+
const raw = tagNameNode.getText(sourceFile);
|
|
534
|
+
if (CUT_OWNER_TAGS.has(raw) || raw === 'Sheet' || raw === 'TabsContent') return raw;
|
|
535
|
+
if (ts.isIdentifier(tagNameNode)) {
|
|
536
|
+
const resolved = unwrapExpression(tagNameNode);
|
|
537
|
+
if (resolved && ts.isIdentifier(resolved)) {
|
|
538
|
+
const targetName = resolved.text;
|
|
539
|
+
if (
|
|
540
|
+
CUT_OWNER_TAGS.has(targetName) ||
|
|
541
|
+
targetName === 'Sheet' ||
|
|
542
|
+
targetName === 'TabsContent'
|
|
543
|
+
) {
|
|
544
|
+
return targetName;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return raw;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function isExplicitUndefined(node) {
|
|
552
|
+
if (!node) return false;
|
|
553
|
+
const expr = unwrapExpression(node);
|
|
554
|
+
if (!expr) return false;
|
|
555
|
+
if (ts.isIdentifier(expr) && expr.text === 'undefined') return true;
|
|
556
|
+
if (ts.isVoidExpression(expr)) return true;
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function getStringValue(node) {
|
|
561
|
+
const expr = unwrapExpression(node);
|
|
562
|
+
if (!expr) return null;
|
|
563
|
+
if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) {
|
|
564
|
+
return expr.text;
|
|
565
|
+
}
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function getBooleanValue(node) {
|
|
570
|
+
const expr = unwrapExpression(node);
|
|
571
|
+
if (!expr) return null;
|
|
572
|
+
if (expr.kind === ts.SyntaxKind.TrueKeyword) return true;
|
|
573
|
+
if (expr.kind === ts.SyntaxKind.FalseKeyword) return false;
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const cutMatches = [];
|
|
578
|
+
|
|
579
|
+
function resolveObjectProps(rawExpr, seenObjs = new Set()) {
|
|
580
|
+
const obj = unwrapExpression(rawExpr);
|
|
581
|
+
const resolved = {
|
|
582
|
+
variant: UNSET,
|
|
583
|
+
cut: UNSET,
|
|
584
|
+
topCut: UNSET,
|
|
585
|
+
bottomCut: UNSET,
|
|
586
|
+
autoCut: UNSET,
|
|
587
|
+
top: UNSET,
|
|
588
|
+
bottom: UNSET,
|
|
589
|
+
children: UNSET,
|
|
590
|
+
extraNodes: [],
|
|
591
|
+
};
|
|
592
|
+
if (!obj || !ts.isObjectLiteralExpression(obj) || seenObjs.has(obj)) return resolved;
|
|
593
|
+
if (!trackDeferredInitializers) {
|
|
594
|
+
visitedDeferredInitializers.add(obj);
|
|
595
|
+
}
|
|
596
|
+
const nextSeen = new Set(seenObjs);
|
|
597
|
+
nextSeen.add(obj);
|
|
598
|
+
|
|
599
|
+
for (const prop of obj.properties) {
|
|
600
|
+
if (ts.isSpreadAssignment(prop)) {
|
|
601
|
+
const sub = resolveObjectProps(prop.expression, nextSeen);
|
|
602
|
+
if (sub.variant !== UNSET) resolved.variant = sub.variant;
|
|
603
|
+
if (sub.cut !== UNSET) resolved.cut = sub.cut;
|
|
604
|
+
if (sub.topCut !== UNSET) resolved.topCut = sub.topCut;
|
|
605
|
+
if (sub.bottomCut !== UNSET) resolved.bottomCut = sub.bottomCut;
|
|
606
|
+
if (sub.autoCut !== UNSET) resolved.autoCut = sub.autoCut;
|
|
607
|
+
if (sub.top !== UNSET) resolved.top = sub.top;
|
|
608
|
+
if (sub.bottom !== UNSET) resolved.bottom = sub.bottom;
|
|
609
|
+
if (sub.children !== UNSET) resolved.children = sub.children;
|
|
610
|
+
if (sub.extraNodes.length > 0) resolved.extraNodes.push(...sub.extraNodes);
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
let propName = null;
|
|
615
|
+
let valExpr = null;
|
|
616
|
+
if (ts.isPropertyAssignment(prop)) {
|
|
617
|
+
if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) {
|
|
618
|
+
propName = prop.name.text;
|
|
619
|
+
valExpr = prop.initializer;
|
|
620
|
+
}
|
|
621
|
+
} else if (ts.isShorthandPropertyAssignment(prop)) {
|
|
622
|
+
propName = prop.name.text;
|
|
623
|
+
valExpr = prop.name;
|
|
624
|
+
}
|
|
625
|
+
if (!propName || !valExpr) continue;
|
|
626
|
+
|
|
627
|
+
if (propName === 'variant') {
|
|
628
|
+
const val = getStringValue(valExpr);
|
|
629
|
+
if (val !== null) {
|
|
630
|
+
resolved.variant = val;
|
|
631
|
+
} else if (isExplicitUndefined(valExpr)) {
|
|
632
|
+
resolved.variant = null;
|
|
633
|
+
}
|
|
634
|
+
} else if (CUT_PROP_NAMES.has(propName)) {
|
|
635
|
+
const strVal = getStringValue(valExpr);
|
|
636
|
+
if (strVal !== null) {
|
|
637
|
+
resolved[propName] = strVal === '' ? false : { raw: strVal, node: prop };
|
|
638
|
+
} else if (getBooleanValue(valExpr) === false) {
|
|
639
|
+
resolved[propName] = false;
|
|
640
|
+
} else if (isExplicitUndefined(valExpr)) {
|
|
641
|
+
resolved[propName] = null;
|
|
642
|
+
}
|
|
643
|
+
} else if (propName === 'autoCut') {
|
|
644
|
+
const boolVal = getBooleanValue(valExpr);
|
|
645
|
+
if (boolVal !== null) {
|
|
646
|
+
resolved.autoCut = boolVal;
|
|
647
|
+
} else if (isExplicitUndefined(valExpr)) {
|
|
648
|
+
resolved.autoCut = null;
|
|
649
|
+
}
|
|
650
|
+
} else if (propName === 'top' || propName === 'bottom') {
|
|
651
|
+
if (isExplicitUndefined(valExpr) || unwrapExpression(valExpr)?.kind === ts.SyntaxKind.NullKeyword) {
|
|
652
|
+
resolved[propName] = null;
|
|
653
|
+
} else {
|
|
654
|
+
resolved[propName] = resolveObjectProps(valExpr, nextSeen);
|
|
655
|
+
}
|
|
656
|
+
} else if (propName === 'children') {
|
|
657
|
+
if (isExplicitUndefined(valExpr) || unwrapExpression(valExpr)?.kind === ts.SyntaxKind.NullKeyword) {
|
|
658
|
+
resolved.children = null;
|
|
659
|
+
} else {
|
|
660
|
+
resolved.children = valExpr;
|
|
661
|
+
}
|
|
662
|
+
} else {
|
|
663
|
+
resolved.extraNodes.push(valExpr);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return resolved;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function resolveElementProps(attributes) {
|
|
670
|
+
const resolved = {
|
|
671
|
+
variant: UNSET,
|
|
672
|
+
cut: UNSET,
|
|
673
|
+
topCut: UNSET,
|
|
674
|
+
bottomCut: UNSET,
|
|
675
|
+
autoCut: UNSET,
|
|
676
|
+
top: UNSET,
|
|
677
|
+
bottom: UNSET,
|
|
678
|
+
children: UNSET,
|
|
679
|
+
extraNodes: [],
|
|
680
|
+
};
|
|
681
|
+
for (const prop of attributes.properties) {
|
|
682
|
+
if (ts.isJsxSpreadAttribute(prop)) {
|
|
683
|
+
const sub = resolveObjectProps(prop.expression);
|
|
684
|
+
if (sub.variant !== UNSET) resolved.variant = sub.variant;
|
|
685
|
+
if (sub.cut !== UNSET) resolved.cut = sub.cut;
|
|
686
|
+
if (sub.topCut !== UNSET) resolved.topCut = sub.topCut;
|
|
687
|
+
if (sub.bottomCut !== UNSET) resolved.bottomCut = sub.bottomCut;
|
|
688
|
+
if (sub.autoCut !== UNSET) resolved.autoCut = sub.autoCut;
|
|
689
|
+
if (sub.top !== UNSET) resolved.top = sub.top;
|
|
690
|
+
if (sub.bottom !== UNSET) resolved.bottom = sub.bottom;
|
|
691
|
+
if (sub.children !== UNSET) resolved.children = sub.children;
|
|
692
|
+
if (sub.extraNodes.length > 0) resolved.extraNodes.push(...sub.extraNodes);
|
|
693
|
+
} else if (ts.isJsxAttribute(prop)) {
|
|
694
|
+
const attrName = prop.name.getText(sourceFile);
|
|
695
|
+
if (attrName === 'variant') {
|
|
696
|
+
if (!prop.initializer) continue;
|
|
697
|
+
const val = getStringValue(prop.initializer);
|
|
698
|
+
if (val !== null) {
|
|
699
|
+
resolved.variant = val;
|
|
700
|
+
} else if (isExplicitUndefined(prop.initializer)) {
|
|
701
|
+
resolved.variant = null;
|
|
702
|
+
}
|
|
703
|
+
} else if (CUT_PROP_NAMES.has(attrName)) {
|
|
704
|
+
if (!prop.initializer) continue;
|
|
705
|
+
const strVal = getStringValue(prop.initializer);
|
|
706
|
+
if (strVal !== null) {
|
|
707
|
+
resolved[attrName] = strVal === '' ? false : { raw: strVal, node: prop };
|
|
708
|
+
} else if (getBooleanValue(prop.initializer) === false) {
|
|
709
|
+
resolved[attrName] = false;
|
|
710
|
+
} else if (isExplicitUndefined(prop.initializer)) {
|
|
711
|
+
resolved[attrName] = null;
|
|
712
|
+
}
|
|
713
|
+
} else if (attrName === 'autoCut') {
|
|
714
|
+
if (!prop.initializer) {
|
|
715
|
+
resolved.autoCut = true;
|
|
716
|
+
} else {
|
|
717
|
+
const boolVal = getBooleanValue(prop.initializer);
|
|
718
|
+
if (boolVal !== null) {
|
|
719
|
+
resolved.autoCut = boolVal;
|
|
720
|
+
} else if (isExplicitUndefined(prop.initializer)) {
|
|
721
|
+
resolved.autoCut = null;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
} else if (attrName === 'top' || attrName === 'bottom') {
|
|
725
|
+
if (
|
|
726
|
+
prop.initializer &&
|
|
727
|
+
ts.isJsxExpression(prop.initializer) &&
|
|
728
|
+
prop.initializer.expression
|
|
729
|
+
) {
|
|
730
|
+
const expr = prop.initializer.expression;
|
|
731
|
+
if (isExplicitUndefined(expr) || unwrapExpression(expr)?.kind === ts.SyntaxKind.NullKeyword) {
|
|
732
|
+
resolved[attrName] = null;
|
|
733
|
+
} else {
|
|
734
|
+
resolved[attrName] = resolveObjectProps(expr);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
} else if (attrName === 'children') {
|
|
738
|
+
if (!prop.initializer) {
|
|
739
|
+
resolved.children = null;
|
|
740
|
+
} else if (ts.isJsxExpression(prop.initializer)) {
|
|
741
|
+
const expr = prop.initializer.expression;
|
|
742
|
+
if (
|
|
743
|
+
!expr ||
|
|
744
|
+
isExplicitUndefined(expr) ||
|
|
745
|
+
unwrapExpression(expr)?.kind === ts.SyntaxKind.NullKeyword
|
|
746
|
+
) {
|
|
747
|
+
resolved.children = null;
|
|
748
|
+
} else {
|
|
749
|
+
resolved.children = expr;
|
|
750
|
+
}
|
|
751
|
+
} else {
|
|
752
|
+
resolved.children = prop.initializer;
|
|
753
|
+
}
|
|
754
|
+
} else if (prop.initializer) {
|
|
755
|
+
resolved.extraNodes.push(prop.initializer);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return resolved;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function getSheetAutoCut(attributes) {
|
|
763
|
+
const props = resolveElementProps(attributes);
|
|
764
|
+
return props.autoCut === true;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function extractReactChildrenFromExpr(rawExpr, out) {
|
|
768
|
+
const expr = unwrapExpression(rawExpr);
|
|
769
|
+
if (!expr) return out;
|
|
770
|
+
if (
|
|
771
|
+
expr.kind === ts.SyntaxKind.NullKeyword ||
|
|
772
|
+
expr.kind === ts.SyntaxKind.TrueKeyword ||
|
|
773
|
+
expr.kind === ts.SyntaxKind.FalseKeyword ||
|
|
774
|
+
isExplicitUndefined(expr)
|
|
775
|
+
) {
|
|
776
|
+
return out;
|
|
777
|
+
}
|
|
778
|
+
if (ts.isArrayLiteralExpression(expr)) {
|
|
779
|
+
for (const el of expr.elements) {
|
|
780
|
+
if (ts.isSpreadElement(el)) {
|
|
781
|
+
extractReactChildrenFromExpr(el.expression, out);
|
|
782
|
+
} else {
|
|
783
|
+
extractReactChildrenFromExpr(el, out);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return out;
|
|
787
|
+
}
|
|
788
|
+
out.push(expr);
|
|
789
|
+
return out;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function extractReactChildrenArray(nodes, out = []) {
|
|
793
|
+
for (const child of nodes) {
|
|
794
|
+
if (!child) continue;
|
|
795
|
+
if (ts.isJsxText(child)) {
|
|
796
|
+
if (child.getText(sourceFile).trim() !== '') {
|
|
797
|
+
out.push(child);
|
|
798
|
+
}
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
if (ts.isJsxExpression(child)) {
|
|
802
|
+
if (!child.expression) continue;
|
|
803
|
+
extractReactChildrenFromExpr(child.expression, out);
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
out.push(child);
|
|
807
|
+
}
|
|
808
|
+
return out;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function recordCut(rawCut, node, ownerTag, inAutoCut, isQuiet = false) {
|
|
812
|
+
const start = node.getStart(sourceFile);
|
|
813
|
+
const pos = sourceFile.getLineAndCharacterOfPosition(start);
|
|
814
|
+
const line = pos.line + 1;
|
|
815
|
+
const column = pos.character + 1;
|
|
816
|
+
const snippet = node.getText(sourceFile);
|
|
817
|
+
const isDialogContentCut = ownerTag === 'DialogContent';
|
|
818
|
+
|
|
819
|
+
if (inAutoCut && !isDialogContentCut) {
|
|
820
|
+
violations.push({
|
|
821
|
+
rule: 'explicit-cut-in-autocut',
|
|
822
|
+
file: filePath,
|
|
823
|
+
line,
|
|
824
|
+
column,
|
|
825
|
+
message: 'Cut numbers are automatic inside <Sheet autoCut>; remove the cut prop.',
|
|
826
|
+
snippet,
|
|
827
|
+
});
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (isQuiet && !isDialogContentCut) {
|
|
832
|
+
violations.push({
|
|
833
|
+
rule: 'cut-order',
|
|
834
|
+
file: filePath,
|
|
835
|
+
line,
|
|
836
|
+
column,
|
|
837
|
+
message:
|
|
838
|
+
'Quiet panels (<Panel variant="quiet">) are subordinate operational groups and should not carry storyboard cut numbers; remove the cut prop or use variant="expressive".',
|
|
839
|
+
snippet,
|
|
840
|
+
});
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const parsed = rawCut.match(/^C-(\d{2,})$/);
|
|
845
|
+
if (!parsed) {
|
|
846
|
+
violations.push({
|
|
847
|
+
rule: 'cut-order',
|
|
848
|
+
file: filePath,
|
|
849
|
+
line,
|
|
850
|
+
column,
|
|
851
|
+
message: `Malformed cut label "${rawCut}". Cut numbers must use two-digit format "C-01", "C-02", etc.`,
|
|
852
|
+
snippet,
|
|
853
|
+
});
|
|
854
|
+
} else if (!inAutoCut) {
|
|
855
|
+
cutMatches.push({
|
|
856
|
+
raw: rawCut,
|
|
857
|
+
num: parseInt(parsed[1], 10),
|
|
858
|
+
line,
|
|
859
|
+
column,
|
|
860
|
+
snippet,
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const panelSlotInheritedMap = new Map();
|
|
866
|
+
|
|
867
|
+
function processPairDescriptorSlot(slotObj, fallbackCut, pairVariant, inAutoCut) {
|
|
868
|
+
const slotVariant =
|
|
869
|
+
(slotObj && slotObj.variant !== UNSET && slotObj.variant !== null
|
|
870
|
+
? slotObj.variant
|
|
871
|
+
: undefined) ?? pairVariant;
|
|
872
|
+
const slotCutProp =
|
|
873
|
+
slotObj && slotObj.cut !== UNSET && slotObj.cut !== null ? slotObj.cut : undefined;
|
|
874
|
+
const validFallback =
|
|
875
|
+
fallbackCut && fallbackCut !== UNSET && fallbackCut !== false ? fallbackCut : null;
|
|
876
|
+
|
|
877
|
+
if (inAutoCut && slotCutProp && slotCutProp !== false && validFallback) {
|
|
878
|
+
recordCut(
|
|
879
|
+
validFallback.raw,
|
|
880
|
+
validFallback.node,
|
|
881
|
+
'PanelPair',
|
|
882
|
+
inAutoCut,
|
|
883
|
+
slotVariant === 'quiet',
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const effectiveCut =
|
|
888
|
+
slotCutProp === false ? null : (slotCutProp ?? validFallback);
|
|
889
|
+
if (effectiveCut) {
|
|
890
|
+
recordCut(
|
|
891
|
+
effectiveCut.raw,
|
|
892
|
+
effectiveCut.node,
|
|
893
|
+
'PanelPair',
|
|
894
|
+
inAutoCut,
|
|
895
|
+
slotVariant === 'quiet',
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (slotObj) {
|
|
900
|
+
for (const extra of slotObj.extraNodes) {
|
|
901
|
+
visit(extra, inAutoCut);
|
|
902
|
+
}
|
|
903
|
+
if (slotObj.children !== UNSET && slotObj.children !== null) {
|
|
904
|
+
visitRenderedExpr(slotObj.children, inAutoCut);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function slotHasDirectPanel(slotNode, seen = new Set()) {
|
|
910
|
+
if (!slotNode || seen.has(slotNode)) return false;
|
|
911
|
+
seen.add(slotNode);
|
|
912
|
+
const unwrapped = unwrapExpression(slotNode) || slotNode;
|
|
913
|
+
if (ts.isBinaryExpression(unwrapped)) {
|
|
914
|
+
const op = unwrapped.operatorToken.kind;
|
|
915
|
+
if (op === ts.SyntaxKind.AmpersandAmpersandToken) {
|
|
916
|
+
return slotHasDirectPanel(unwrapped.right, seen);
|
|
917
|
+
}
|
|
918
|
+
if (op === ts.SyntaxKind.BarBarToken || op === ts.SyntaxKind.QuestionQuestionToken) {
|
|
919
|
+
return (
|
|
920
|
+
slotHasDirectPanel(unwrapped.left, seen) ||
|
|
921
|
+
slotHasDirectPanel(unwrapped.right, seen)
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
if (ts.isConditionalExpression(unwrapped)) {
|
|
926
|
+
return (
|
|
927
|
+
slotHasDirectPanel(unwrapped.whenTrue, seen) ||
|
|
928
|
+
slotHasDirectPanel(unwrapped.whenFalse, seen)
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
return (
|
|
932
|
+
(ts.isJsxElement(unwrapped) &&
|
|
933
|
+
getCanonicalTagName(unwrapped.openingElement.tagName) === 'Panel') ||
|
|
934
|
+
(ts.isJsxSelfClosingElement(unwrapped) &&
|
|
935
|
+
getCanonicalTagName(unwrapped.tagName) === 'Panel')
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function processPairChildSlot(
|
|
940
|
+
slotNode,
|
|
941
|
+
fallbackCut,
|
|
942
|
+
pairVariant,
|
|
943
|
+
inAutoCut,
|
|
944
|
+
slotState = {
|
|
945
|
+
fallbackRecorded: false,
|
|
946
|
+
quietFallbackChecked: false,
|
|
947
|
+
hasDirectPanel: slotHasDirectPanel(slotNode),
|
|
948
|
+
},
|
|
949
|
+
) {
|
|
950
|
+
const unwrapped = unwrapExpression(slotNode) || slotNode;
|
|
951
|
+
if (ts.isBinaryExpression(unwrapped)) {
|
|
952
|
+
const op = unwrapped.operatorToken.kind;
|
|
953
|
+
if (op === ts.SyntaxKind.AmpersandAmpersandToken) {
|
|
954
|
+
visit(unwrapped.left, inAutoCut);
|
|
955
|
+
processPairChildSlot(unwrapped.right, fallbackCut, pairVariant, inAutoCut, slotState);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
if (op === ts.SyntaxKind.BarBarToken || op === ts.SyntaxKind.QuestionQuestionToken) {
|
|
959
|
+
processPairChildSlot(unwrapped.left, fallbackCut, pairVariant, inAutoCut, slotState);
|
|
960
|
+
processPairChildSlot(unwrapped.right, fallbackCut, pairVariant, inAutoCut, slotState);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
if (ts.isConditionalExpression(unwrapped)) {
|
|
965
|
+
visit(unwrapped.condition, inAutoCut);
|
|
966
|
+
processPairChildSlot(unwrapped.whenTrue, fallbackCut, pairVariant, inAutoCut, slotState);
|
|
967
|
+
processPairChildSlot(unwrapped.whenFalse, fallbackCut, pairVariant, inAutoCut, slotState);
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const isPanelElement =
|
|
972
|
+
(ts.isJsxElement(unwrapped) &&
|
|
973
|
+
getCanonicalTagName(unwrapped.openingElement.tagName) === 'Panel') ||
|
|
974
|
+
(ts.isJsxSelfClosingElement(unwrapped) &&
|
|
975
|
+
getCanonicalTagName(unwrapped.tagName) === 'Panel');
|
|
976
|
+
|
|
977
|
+
if (isPanelElement) {
|
|
978
|
+
visitedDeferredInitializers.add(unwrapped);
|
|
979
|
+
panelSlotInheritedMap.set(unwrapped, {
|
|
980
|
+
variant: pairVariant,
|
|
981
|
+
fallbackCut,
|
|
982
|
+
slotState,
|
|
983
|
+
});
|
|
984
|
+
visit(unwrapped, inAutoCut);
|
|
985
|
+
} else {
|
|
986
|
+
const isNonJsxBranch =
|
|
987
|
+
!ts.isJsxElement(unwrapped) &&
|
|
988
|
+
!ts.isJsxSelfClosingElement(unwrapped) &&
|
|
989
|
+
!ts.isJsxFragment(unwrapped);
|
|
990
|
+
if (
|
|
991
|
+
!(isNonJsxBranch && slotState.hasDirectPanel) &&
|
|
992
|
+
!slotState.fallbackRecorded &&
|
|
993
|
+
fallbackCut &&
|
|
994
|
+
fallbackCut !== UNSET &&
|
|
995
|
+
fallbackCut !== false
|
|
996
|
+
) {
|
|
997
|
+
slotState.fallbackRecorded = true;
|
|
998
|
+
recordCut(
|
|
999
|
+
fallbackCut.raw,
|
|
1000
|
+
fallbackCut.node,
|
|
1001
|
+
'PanelPair',
|
|
1002
|
+
inAutoCut,
|
|
1003
|
+
pairVariant === 'quiet',
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
visitRenderedExpr(slotNode, inAutoCut);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function preScanRenderedExpr(expr, seenInits = new Set()) {
|
|
1011
|
+
if (!expr) return;
|
|
1012
|
+
if (
|
|
1013
|
+
ts.isParenthesizedExpression(expr) ||
|
|
1014
|
+
ts.isAsExpression(expr) ||
|
|
1015
|
+
ts.isTypeAssertionExpression?.(expr) ||
|
|
1016
|
+
ts.isSatisfiesExpression?.(expr) ||
|
|
1017
|
+
ts.isNonNullExpression(expr)
|
|
1018
|
+
) {
|
|
1019
|
+
preScanRenderedExpr(expr.expression, seenInits);
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
if (ts.isIdentifier(expr)) {
|
|
1023
|
+
if (expr.text === 'undefined') return;
|
|
1024
|
+
const init = resolveLocalIdentifier(expr);
|
|
1025
|
+
if (init && !seenInits.has(init)) {
|
|
1026
|
+
seenInits.add(init);
|
|
1027
|
+
preScanRenderedExpr(init, seenInits);
|
|
1028
|
+
}
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
if (ts.isBinaryExpression(expr)) {
|
|
1032
|
+
preScanRenderedExpr(expr.left, seenInits);
|
|
1033
|
+
preScanRenderedExpr(expr.right, seenInits);
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
if (ts.isConditionalExpression(expr)) {
|
|
1037
|
+
preScanRenderedExpr(expr.condition, seenInits);
|
|
1038
|
+
preScanRenderedExpr(expr.whenTrue, seenInits);
|
|
1039
|
+
preScanRenderedExpr(expr.whenFalse, seenInits);
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
if (ts.isArrayLiteralExpression(expr)) {
|
|
1043
|
+
for (const el of expr.elements) {
|
|
1044
|
+
if (ts.isSpreadElement(el)) {
|
|
1045
|
+
preScanRenderedExpr(el.expression, seenInits);
|
|
1046
|
+
} else {
|
|
1047
|
+
preScanRenderedExpr(el, seenInits);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function visitRenderedExpr(expr, inAutoCut) {
|
|
1054
|
+
if (!expr) return;
|
|
1055
|
+
if (
|
|
1056
|
+
ts.isParenthesizedExpression(expr) ||
|
|
1057
|
+
ts.isAsExpression(expr) ||
|
|
1058
|
+
ts.isTypeAssertionExpression?.(expr) ||
|
|
1059
|
+
ts.isSatisfiesExpression?.(expr) ||
|
|
1060
|
+
ts.isNonNullExpression(expr)
|
|
1061
|
+
) {
|
|
1062
|
+
visitRenderedExpr(expr.expression, inAutoCut);
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
if (ts.isIdentifier(expr)) {
|
|
1066
|
+
if (expr.text === 'undefined') return;
|
|
1067
|
+
const init = resolveLocalIdentifier(expr);
|
|
1068
|
+
if (init && !activeInitializers.has(init)) {
|
|
1069
|
+
visitedDeferredInitializers.add(init);
|
|
1070
|
+
activeInitializers.add(init);
|
|
1071
|
+
visitRenderedExpr(init, inAutoCut);
|
|
1072
|
+
activeInitializers.delete(init);
|
|
1073
|
+
}
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
if (ts.isBinaryExpression(expr)) {
|
|
1077
|
+
const op = expr.operatorToken.kind;
|
|
1078
|
+
if (op === ts.SyntaxKind.AmpersandAmpersandToken) {
|
|
1079
|
+
visit(expr.left, inAutoCut);
|
|
1080
|
+
visitRenderedExpr(expr.right, inAutoCut);
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
if (op === ts.SyntaxKind.BarBarToken || op === ts.SyntaxKind.QuestionQuestionToken) {
|
|
1084
|
+
visitRenderedExpr(expr.left, inAutoCut);
|
|
1085
|
+
visitRenderedExpr(expr.right, inAutoCut);
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
if (ts.isConditionalExpression(expr)) {
|
|
1090
|
+
visit(expr.condition, inAutoCut);
|
|
1091
|
+
visitRenderedExpr(expr.whenTrue, inAutoCut);
|
|
1092
|
+
visitRenderedExpr(expr.whenFalse, inAutoCut);
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
if (ts.isArrayLiteralExpression(expr)) {
|
|
1096
|
+
for (const el of expr.elements) {
|
|
1097
|
+
if (ts.isSpreadElement(el)) {
|
|
1098
|
+
visitRenderedExpr(el.expression, inAutoCut);
|
|
1099
|
+
} else {
|
|
1100
|
+
visitRenderedExpr(el, inAutoCut);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
visit(expr, inAutoCut);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function visitJsxChild(child, inAutoCut) {
|
|
1109
|
+
if (!child) return;
|
|
1110
|
+
if (ts.isJsxExpression(child)) {
|
|
1111
|
+
if (child.expression) {
|
|
1112
|
+
visitRenderedExpr(child.expression, inAutoCut);
|
|
1113
|
+
}
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
visit(child, inAutoCut);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function visitCutOwnerOrElement(node, tagName, attributes, inAutoCut) {
|
|
1120
|
+
const isCutOwner = CUT_OWNER_TAGS.has(tagName);
|
|
1121
|
+
if (!isCutOwner && tagName !== 'Sheet' && tagName !== 'TabsContent') {
|
|
1122
|
+
for (const prop of attributes.properties) {
|
|
1123
|
+
if (ts.isJsxAttribute(prop) && prop.name.getText(sourceFile) === 'children') {
|
|
1124
|
+
if (prop.initializer) {
|
|
1125
|
+
if (ts.isJsxExpression(prop.initializer) && prop.initializer.expression) {
|
|
1126
|
+
visitRenderedExpr(prop.initializer.expression, inAutoCut);
|
|
1127
|
+
} else {
|
|
1128
|
+
visit(prop.initializer, inAutoCut);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
} else {
|
|
1132
|
+
ts.forEachChild(prop, (child) => visit(child, inAutoCut));
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
if (ts.isJsxElement(node)) {
|
|
1136
|
+
for (const child of node.children) {
|
|
1137
|
+
visitJsxChild(child, inAutoCut);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const props = resolveElementProps(attributes);
|
|
1144
|
+
|
|
1145
|
+
if (tagName === 'Sheet' || tagName === 'TabsContent') {
|
|
1146
|
+
for (const extra of props.extraNodes) {
|
|
1147
|
+
visit(extra, inAutoCut);
|
|
1148
|
+
}
|
|
1149
|
+
if (ts.isJsxElement(node) && node.children.length > 0) {
|
|
1150
|
+
for (const child of node.children) {
|
|
1151
|
+
visitJsxChild(child, inAutoCut);
|
|
1152
|
+
}
|
|
1153
|
+
} else if (props.children !== UNSET && props.children !== null) {
|
|
1154
|
+
visitRenderedExpr(props.children, inAutoCut);
|
|
1155
|
+
}
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
if (tagName === 'Panel' || tagName === 'DialogContent') {
|
|
1160
|
+
const inheritedSlot = tagName === 'Panel' ? panelSlotInheritedMap.get(node) : null;
|
|
1161
|
+
const slotState = inheritedSlot?.slotState;
|
|
1162
|
+
const propVariant =
|
|
1163
|
+
props.variant !== UNSET && props.variant !== null ? props.variant : undefined;
|
|
1164
|
+
const defaultVariant = tagName === 'Panel' && inTabsContent ? 'quiet' : 'expressive';
|
|
1165
|
+
const effectiveVariant = propVariant ?? inheritedSlot?.variant ?? defaultVariant;
|
|
1166
|
+
const childCut = props.cut !== UNSET && props.cut !== null ? props.cut : undefined;
|
|
1167
|
+
const fallbackCut =
|
|
1168
|
+
inheritedSlot?.fallbackCut &&
|
|
1169
|
+
inheritedSlot.fallbackCut !== UNSET &&
|
|
1170
|
+
inheritedSlot.fallbackCut !== false
|
|
1171
|
+
? inheritedSlot.fallbackCut
|
|
1172
|
+
: null;
|
|
1173
|
+
|
|
1174
|
+
if (inAutoCut && tagName === 'Panel' && childCut && childCut !== false && fallbackCut) {
|
|
1175
|
+
if (!slotState || !slotState.fallbackRecorded) {
|
|
1176
|
+
if (slotState) slotState.fallbackRecorded = true;
|
|
1177
|
+
recordCut(
|
|
1178
|
+
fallbackCut.raw,
|
|
1179
|
+
fallbackCut.node,
|
|
1180
|
+
'PanelPair',
|
|
1181
|
+
inAutoCut,
|
|
1182
|
+
effectiveVariant === 'quiet',
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
if (childCut !== false) {
|
|
1188
|
+
if (childCut) {
|
|
1189
|
+
recordCut(
|
|
1190
|
+
childCut.raw,
|
|
1191
|
+
childCut.node,
|
|
1192
|
+
tagName,
|
|
1193
|
+
inAutoCut,
|
|
1194
|
+
effectiveVariant === 'quiet',
|
|
1195
|
+
);
|
|
1196
|
+
} else if (fallbackCut) {
|
|
1197
|
+
const isQuiet = effectiveVariant === 'quiet';
|
|
1198
|
+
if (isQuiet) {
|
|
1199
|
+
if (!slotState || !slotState.quietFallbackChecked) {
|
|
1200
|
+
if (slotState) slotState.quietFallbackChecked = true;
|
|
1201
|
+
recordCut(fallbackCut.raw, fallbackCut.node, tagName, inAutoCut, true);
|
|
1202
|
+
}
|
|
1203
|
+
} else if (!slotState || !slotState.fallbackRecorded) {
|
|
1204
|
+
if (slotState) slotState.fallbackRecorded = true;
|
|
1205
|
+
recordCut(fallbackCut.raw, fallbackCut.node, tagName, inAutoCut, false);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
for (const extra of props.extraNodes) {
|
|
1211
|
+
visit(extra, inAutoCut);
|
|
1212
|
+
}
|
|
1213
|
+
if (ts.isJsxElement(node) && node.children.length > 0) {
|
|
1214
|
+
for (const child of node.children) {
|
|
1215
|
+
visitJsxChild(child, inAutoCut);
|
|
1216
|
+
}
|
|
1217
|
+
} else if (props.children !== UNSET && props.children !== null) {
|
|
1218
|
+
visitRenderedExpr(props.children, inAutoCut);
|
|
1219
|
+
}
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// tagName === 'PanelPair'
|
|
1224
|
+
const defaultPairVariant = inTabsContent ? 'quiet' : 'expressive';
|
|
1225
|
+
const pairVariant =
|
|
1226
|
+
(props.variant !== UNSET && props.variant !== null ? props.variant : undefined) ??
|
|
1227
|
+
defaultPairVariant;
|
|
1228
|
+
const pairTopCut =
|
|
1229
|
+
props.topCut !== UNSET && props.topCut !== null ? props.topCut : undefined;
|
|
1230
|
+
const pairBottomCut =
|
|
1231
|
+
props.bottomCut !== UNSET && props.bottomCut !== null ? props.bottomCut : undefined;
|
|
1232
|
+
|
|
1233
|
+
if (props.cut && props.cut !== UNSET && props.cut !== false) {
|
|
1234
|
+
recordCut(props.cut.raw, props.cut.node, 'PanelPair', inAutoCut, pairVariant === 'quiet');
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
for (const extra of props.extraNodes) {
|
|
1238
|
+
visit(extra, inAutoCut);
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
const topObj = props.top !== UNSET && props.top !== null ? props.top : undefined;
|
|
1242
|
+
const bottomObj = props.bottom !== UNSET && props.bottom !== null ? props.bottom : undefined;
|
|
1243
|
+
|
|
1244
|
+
if (topObj && bottomObj) {
|
|
1245
|
+
processPairDescriptorSlot(topObj, pairTopCut, pairVariant, inAutoCut);
|
|
1246
|
+
processPairDescriptorSlot(bottomObj, pairBottomCut, pairVariant, inAutoCut);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
let childSlots = [];
|
|
1251
|
+
if (ts.isJsxElement(node)) {
|
|
1252
|
+
childSlots = extractReactChildrenArray(node.children);
|
|
1253
|
+
}
|
|
1254
|
+
if (childSlots.length === 0 && props.children !== UNSET && props.children !== null) {
|
|
1255
|
+
childSlots = extractReactChildrenFromExpr(props.children, []);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
if (childSlots.length > 0) {
|
|
1259
|
+
if (inAutoCut && topObj?.cut && topObj.cut !== UNSET && topObj.cut !== false) {
|
|
1260
|
+
recordCut(topObj.cut.raw, topObj.cut.node, 'PanelPair', inAutoCut, pairVariant === 'quiet');
|
|
1261
|
+
}
|
|
1262
|
+
processPairChildSlot(childSlots[0], pairTopCut, pairVariant, inAutoCut);
|
|
1263
|
+
if (inAutoCut && bottomObj?.cut && bottomObj.cut !== UNSET && bottomObj.cut !== false) {
|
|
1264
|
+
recordCut(
|
|
1265
|
+
bottomObj.cut.raw,
|
|
1266
|
+
bottomObj.cut.node,
|
|
1267
|
+
'PanelPair',
|
|
1268
|
+
inAutoCut,
|
|
1269
|
+
pairVariant === 'quiet',
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
if (childSlots[1] !== undefined) {
|
|
1273
|
+
processPairChildSlot(childSlots[1], pairBottomCut, pairVariant, inAutoCut);
|
|
1274
|
+
} else if (inAutoCut && pairBottomCut && pairBottomCut !== false) {
|
|
1275
|
+
recordCut(
|
|
1276
|
+
pairBottomCut.raw,
|
|
1277
|
+
pairBottomCut.node,
|
|
1278
|
+
'PanelPair',
|
|
1279
|
+
inAutoCut,
|
|
1280
|
+
pairVariant === 'quiet',
|
|
1281
|
+
);
|
|
1282
|
+
}
|
|
1283
|
+
if (inAutoCut) {
|
|
1284
|
+
for (let i = 2; i < childSlots.length; i++) {
|
|
1285
|
+
visitRenderedExpr(childSlots[i], inAutoCut);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
} else if (inAutoCut) {
|
|
1289
|
+
processPairDescriptorSlot(topObj, pairTopCut, pairVariant, inAutoCut);
|
|
1290
|
+
processPairDescriptorSlot(bottomObj, pairBottomCut, pairVariant, inAutoCut);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
function preScanDeferrals(node) {
|
|
1295
|
+
if (ts.isJsxElement(node)) {
|
|
1296
|
+
const tagName = getCanonicalTagName(node.openingElement.tagName);
|
|
1297
|
+
if (CUT_OWNER_TAGS.has(tagName) || tagName === 'Sheet' || tagName === 'TabsContent') {
|
|
1298
|
+
const props = resolveElementProps(node.openingElement.attributes);
|
|
1299
|
+
if (props.children !== UNSET && props.children !== null) {
|
|
1300
|
+
preScanRenderedExpr(props.children);
|
|
1301
|
+
}
|
|
1302
|
+
if (props.top && props.top !== UNSET && props.top.children !== UNSET && props.top.children !== null) {
|
|
1303
|
+
preScanRenderedExpr(props.top.children);
|
|
1304
|
+
}
|
|
1305
|
+
if (props.bottom && props.bottom !== UNSET && props.bottom.children !== UNSET && props.bottom.children !== null) {
|
|
1306
|
+
preScanRenderedExpr(props.bottom.children);
|
|
1307
|
+
}
|
|
1308
|
+
} else {
|
|
1309
|
+
for (const prop of node.openingElement.attributes.properties) {
|
|
1310
|
+
if (
|
|
1311
|
+
ts.isJsxAttribute(prop) &&
|
|
1312
|
+
prop.name.getText(sourceFile) === 'children' &&
|
|
1313
|
+
prop.initializer &&
|
|
1314
|
+
ts.isJsxExpression(prop.initializer) &&
|
|
1315
|
+
prop.initializer.expression
|
|
1316
|
+
) {
|
|
1317
|
+
preScanRenderedExpr(prop.initializer.expression);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
if (tagName === 'PanelPair') {
|
|
1322
|
+
extractReactChildrenArray(node.children);
|
|
1323
|
+
}
|
|
1324
|
+
for (const child of node.children) {
|
|
1325
|
+
if (ts.isJsxExpression(child) && child.expression) {
|
|
1326
|
+
preScanRenderedExpr(child.expression);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
} else if (ts.isJsxSelfClosingElement(node)) {
|
|
1330
|
+
const tagName = getCanonicalTagName(node.tagName);
|
|
1331
|
+
if (CUT_OWNER_TAGS.has(tagName) || tagName === 'Sheet' || tagName === 'TabsContent') {
|
|
1332
|
+
const props = resolveElementProps(node.attributes);
|
|
1333
|
+
if (props.children !== UNSET && props.children !== null) {
|
|
1334
|
+
preScanRenderedExpr(props.children);
|
|
1335
|
+
}
|
|
1336
|
+
if (props.top && props.top !== UNSET && props.top.children !== UNSET && props.top.children !== null) {
|
|
1337
|
+
preScanRenderedExpr(props.top.children);
|
|
1338
|
+
}
|
|
1339
|
+
if (props.bottom && props.bottom !== UNSET && props.bottom.children !== UNSET && props.bottom.children !== null) {
|
|
1340
|
+
preScanRenderedExpr(props.bottom.children);
|
|
1341
|
+
}
|
|
1342
|
+
} else {
|
|
1343
|
+
for (const prop of node.attributes.properties) {
|
|
1344
|
+
if (
|
|
1345
|
+
ts.isJsxAttribute(prop) &&
|
|
1346
|
+
prop.name.getText(sourceFile) === 'children' &&
|
|
1347
|
+
prop.initializer &&
|
|
1348
|
+
ts.isJsxExpression(prop.initializer) &&
|
|
1349
|
+
prop.initializer.expression
|
|
1350
|
+
) {
|
|
1351
|
+
preScanRenderedExpr(prop.initializer.expression);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
} else if (ts.isJsxFragment(node)) {
|
|
1356
|
+
for (const child of node.children) {
|
|
1357
|
+
if (ts.isJsxExpression(child) && child.expression) {
|
|
1358
|
+
preScanRenderedExpr(child.expression);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
ts.forEachChild(node, preScanDeferrals);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
trackDeferredInitializers = true;
|
|
1366
|
+
preScanDeferrals(sourceFile);
|
|
1367
|
+
trackDeferredInitializers = false;
|
|
1368
|
+
|
|
1369
|
+
function visit(node, inAutoCut) {
|
|
1370
|
+
if (!node) return;
|
|
1371
|
+
|
|
1372
|
+
if (
|
|
1373
|
+
ts.isVariableDeclaration(node) &&
|
|
1374
|
+
node.initializer &&
|
|
1375
|
+
deferredVarInitializers.has(node.initializer)
|
|
1376
|
+
) {
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
if (ts.isJsxElement(node)) {
|
|
1381
|
+
const tagName = getCanonicalTagName(node.openingElement.tagName);
|
|
1382
|
+
const nextAutoCut =
|
|
1383
|
+
tagName === 'Sheet' ? getSheetAutoCut(node.openingElement.attributes) : inAutoCut;
|
|
1384
|
+
const prevTabs = inTabsContent;
|
|
1385
|
+
if (tagName === 'TabsContent') inTabsContent = true;
|
|
1386
|
+
try {
|
|
1387
|
+
visitCutOwnerOrElement(node, tagName, node.openingElement.attributes, nextAutoCut);
|
|
1388
|
+
} finally {
|
|
1389
|
+
inTabsContent = prevTabs;
|
|
1390
|
+
}
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
if (ts.isJsxSelfClosingElement(node)) {
|
|
1395
|
+
const tagName = getCanonicalTagName(node.tagName);
|
|
1396
|
+
const nextAutoCut = tagName === 'Sheet' ? getSheetAutoCut(node.attributes) : inAutoCut;
|
|
1397
|
+
const prevTabs = inTabsContent;
|
|
1398
|
+
if (tagName === 'TabsContent') inTabsContent = true;
|
|
1399
|
+
try {
|
|
1400
|
+
visitCutOwnerOrElement(node, tagName, node.attributes, nextAutoCut);
|
|
1401
|
+
} finally {
|
|
1402
|
+
inTabsContent = prevTabs;
|
|
1403
|
+
}
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
if (ts.isJsxFragment(node)) {
|
|
1408
|
+
for (const child of node.children) {
|
|
1409
|
+
visitJsxChild(child, inAutoCut);
|
|
1410
|
+
}
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
ts.forEachChild(node, (child) => visit(child, inAutoCut));
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
visit(sourceFile, false);
|
|
1418
|
+
|
|
1419
|
+
for (const init of deferredVarInitializers) {
|
|
1420
|
+
if (!visitedDeferredInitializers.has(init)) {
|
|
1421
|
+
visit(init, false);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
validateCutSequence(cutMatches, filePath, violations);
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* Regex fallback for `cut-order` and `explicit-cut-in-autocut` when the optional
|
|
1430
|
+
* `typescript` peer dependency is not installed.
|
|
1431
|
+
*/
|
|
1432
|
+
function lintCutsFallback(stripped, rawContent, filePath, violations) {
|
|
1433
|
+
const autoCutRanges = [];
|
|
1434
|
+
const autoCutSheetRe =
|
|
1435
|
+
/<Sheet\b[^>]*?\bautoCut(?:\s*=\s*(?:\{\s*true\s*\}|["']true["']))?(?![\w-])[^>]*>([\s\S]*?)<\/Sheet>/g;
|
|
1436
|
+
let m;
|
|
1437
|
+
while ((m = autoCutSheetRe.exec(stripped)) !== null) {
|
|
1438
|
+
const start = m.index;
|
|
1439
|
+
const end = m.index + m[0].length;
|
|
1440
|
+
autoCutRanges.push([start, end]);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
const isInsideAutoCut = (idx) =>
|
|
1444
|
+
autoCutRanges.some(([start, end]) => idx > start && idx < end);
|
|
1445
|
+
|
|
1446
|
+
const cutMatches = [];
|
|
1447
|
+
const tagRe = /<(Panel|PanelPair|DialogContent)\b([^>]*?)\/?>/g;
|
|
1448
|
+
while ((m = tagRe.exec(stripped)) !== null) {
|
|
1449
|
+
const ownerTag = m[1];
|
|
1450
|
+
const attrs = m[2];
|
|
1451
|
+
const attrsStart = m.index + 1 + ownerTag.length;
|
|
1452
|
+
const inAutoCut = isInsideAutoCut(m.index);
|
|
1453
|
+
const isQuiet = /\bvariant\s*=\s*(?:['"]quiet['"]|\{\s*['"`]quiet['"`]\s*\})/.test(attrs);
|
|
1454
|
+
const propRe =
|
|
1455
|
+
/\b(cut|topCut|bottomCut)\s*=\s*(?:(['"])([^'"]*)\2|\{\s*(['"`])([^'"`]*)\4\s*\})/g;
|
|
1456
|
+
let pm;
|
|
1457
|
+
while ((pm = propRe.exec(attrs)) !== null) {
|
|
1458
|
+
const rawCut = pm[3] ?? pm[5] ?? '';
|
|
1459
|
+
if (rawCut === '') continue;
|
|
1460
|
+
const absIdx = attrsStart + pm.index;
|
|
1461
|
+
const line = lineNumberAt(rawContent, absIdx);
|
|
1462
|
+
const column = columnNumberAt(rawContent, absIdx);
|
|
1463
|
+
const snippet = pm[0];
|
|
1464
|
+
const isDialogContentCut = ownerTag === 'DialogContent';
|
|
1465
|
+
|
|
1466
|
+
if (inAutoCut && !isDialogContentCut) {
|
|
1467
|
+
violations.push({
|
|
1468
|
+
rule: 'explicit-cut-in-autocut',
|
|
1469
|
+
file: filePath,
|
|
1470
|
+
line,
|
|
1471
|
+
column,
|
|
1472
|
+
message: 'Cut numbers are automatic inside <Sheet autoCut>; remove the cut prop.',
|
|
1473
|
+
snippet,
|
|
1474
|
+
});
|
|
1475
|
+
continue;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
if (isQuiet && !isDialogContentCut) {
|
|
1479
|
+
violations.push({
|
|
1480
|
+
rule: 'cut-order',
|
|
1481
|
+
file: filePath,
|
|
1482
|
+
line,
|
|
1483
|
+
column,
|
|
1484
|
+
message:
|
|
1485
|
+
'Quiet panels (<Panel variant="quiet">) are subordinate operational groups and should not carry storyboard cut numbers; remove the cut prop or use variant="expressive".',
|
|
1486
|
+
snippet,
|
|
1487
|
+
});
|
|
1488
|
+
continue;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
const parsed = rawCut.match(/^C-(\d{2,})$/);
|
|
1492
|
+
if (!parsed) {
|
|
1493
|
+
violations.push({
|
|
1494
|
+
rule: 'cut-order',
|
|
1495
|
+
file: filePath,
|
|
1496
|
+
line,
|
|
1497
|
+
column,
|
|
1498
|
+
message: `Malformed cut label "${rawCut}". Cut numbers must use two-digit format "C-01", "C-02", etc.`,
|
|
1499
|
+
snippet,
|
|
1500
|
+
});
|
|
1501
|
+
} else if (!inAutoCut) {
|
|
1502
|
+
cutMatches.push({
|
|
1503
|
+
raw: rawCut,
|
|
1504
|
+
num: parseInt(parsed[1], 10),
|
|
1505
|
+
line,
|
|
1506
|
+
column,
|
|
1507
|
+
snippet,
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
validateCutSequence(cutMatches, filePath, violations);
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
/**
|
|
1517
|
+
* Lints a single source string and returns an array of violation objects:
|
|
1518
|
+
* { rule, file, line, column, message, snippet }
|
|
1519
|
+
*/
|
|
1520
|
+
export function lintSource(content, filePath = 'input.tsx', opts = {}) {
|
|
1521
|
+
const violations = [];
|
|
1522
|
+
const base = basename(filePath);
|
|
1523
|
+
const ext = extname(filePath);
|
|
1524
|
+
if (ext === '.html' || ext === '.htm') {
|
|
1525
|
+
return lintHtml(content, { file: filePath });
|
|
1526
|
+
}
|
|
1527
|
+
if (!CODE_EXTENSIONS.has(ext)) return violations;
|
|
1528
|
+
|
|
1529
|
+
const normalizedPath = filePath.replace(/\\/g, '/');
|
|
1530
|
+
const isTokenFile =
|
|
1531
|
+
TOKEN_BASENAMES.has(base) ||
|
|
1532
|
+
TOKEN_EXEMPT_PATHS.some(
|
|
1533
|
+
(p) => normalizedPath === p || normalizedPath.endsWith(`/${p}`),
|
|
1534
|
+
);
|
|
1535
|
+
const isRegistryImplementation =
|
|
1536
|
+
normalizedPath.includes('/registry/ui/') ||
|
|
1537
|
+
normalizedPath.startsWith('registry/ui/') ||
|
|
1538
|
+
normalizedPath.includes('/registry/hooks/') ||
|
|
1539
|
+
normalizedPath.startsWith('registry/hooks/') ||
|
|
1540
|
+
normalizedPath.includes('/components/frame12/') ||
|
|
1541
|
+
normalizedPath.startsWith('components/frame12/') ||
|
|
1542
|
+
normalizedPath.includes('/hooks/use-on-twos') ||
|
|
1543
|
+
normalizedPath.startsWith('hooks/use-on-twos');
|
|
1544
|
+
|
|
1545
|
+
const stripped = stripCommentsPreservingLines(content);
|
|
1546
|
+
|
|
1547
|
+
// =========================================================================
|
|
1548
|
+
// 1. color-literal (Hard-coded colors, CSS gradients & font stacks outside token files)
|
|
1549
|
+
// =========================================================================
|
|
1550
|
+
if (!isTokenFile) {
|
|
1551
|
+
const colorScan = stripped;
|
|
1552
|
+
|
|
1553
|
+
// Hex colors (#rgb, #rgba, #rrggbb, #rrggbbaa) not preceded by url( or href=
|
|
1554
|
+
const hexRegex = /#[0-9a-fA-F]{3,8}\b/g;
|
|
1555
|
+
let m;
|
|
1556
|
+
while ((m = hexRegex.exec(colorScan)) !== null) {
|
|
1557
|
+
const hex = m[0];
|
|
1558
|
+
if (![4, 5, 7, 9].includes(hex.length)) continue;
|
|
1559
|
+
const before = colorScan.slice(Math.max(0, m.index - 12), m.index);
|
|
1560
|
+
if (/url\(\s*$/i.test(before) || /href\s*=\s*['"]$/i.test(before)) continue;
|
|
1561
|
+
violations.push({
|
|
1562
|
+
rule: 'color-literal',
|
|
1563
|
+
file: filePath,
|
|
1564
|
+
line: lineNumberAt(content, m.index),
|
|
1565
|
+
column: columnNumberAt(content, m.index),
|
|
1566
|
+
message: `Hard-coded hex color "${hex}" is forbidden outside token files. Use a var(--f-*) token.`,
|
|
1567
|
+
snippet: hex,
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// rgb(), rgba(), hsl(), hsla(), oklch(), oklab()
|
|
1572
|
+
const fnColorRegex = /\b(?:rgb|rgba|hsl|hsla|oklch|oklab)\s*\([^)]*\)/gi;
|
|
1573
|
+
while ((m = fnColorRegex.exec(colorScan)) !== null) {
|
|
1574
|
+
violations.push({
|
|
1575
|
+
rule: 'color-literal',
|
|
1576
|
+
file: filePath,
|
|
1577
|
+
line: lineNumberAt(content, m.index),
|
|
1578
|
+
column: columnNumberAt(content, m.index),
|
|
1579
|
+
message: `Hard-coded color function "${m[0]}" is forbidden outside token files. Use a var(--f-*) token.`,
|
|
1580
|
+
snippet: m[0],
|
|
1581
|
+
});
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// CSS gradients (linear-gradient, radial-gradient, conic-gradient)
|
|
1585
|
+
// Exempts only the screentone dot pattern radial-gradient(circle, var(--f-tone)...)
|
|
1586
|
+
const gradientRegex =
|
|
1587
|
+
/\b(?:repeating-)?(?:linear|radial|conic)-gradient\s*\([^;}\n)]*\)/gi;
|
|
1588
|
+
while ((m = gradientRegex.exec(colorScan)) !== null) {
|
|
1589
|
+
const grad = m[0];
|
|
1590
|
+
if (/^radial-gradient\(\s*circle\s*,\s*var\(--f-tone\)/i.test(grad)) continue;
|
|
1591
|
+
violations.push({
|
|
1592
|
+
rule: 'color-literal',
|
|
1593
|
+
file: filePath,
|
|
1594
|
+
line: lineNumberAt(content, m.index),
|
|
1595
|
+
column: columnNumberAt(content, m.index),
|
|
1596
|
+
message: `CSS gradient "${grad}" is forbidden in FRAME/12. Use 45° SVG screentone patterns or flat var(--f-*) surfaces.`,
|
|
1597
|
+
snippet: grad,
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// Hard-coded font names
|
|
1602
|
+
const fontRegex = /\b(?:Dela Gothic One|Zen Kaku Gothic New|M PLUS 1 Code)\b/g;
|
|
1603
|
+
while ((m = fontRegex.exec(colorScan)) !== null) {
|
|
1604
|
+
violations.push({
|
|
1605
|
+
rule: 'color-literal',
|
|
1606
|
+
file: filePath,
|
|
1607
|
+
line: lineNumberAt(content, m.index),
|
|
1608
|
+
column: columnNumberAt(content, m.index),
|
|
1609
|
+
message: `Hard-coded font family "${m[0]}" is forbidden outside token files. Use var(--font-f-*).`,
|
|
1610
|
+
snippet: m[0],
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// =========================================================================
|
|
1616
|
+
// 2. illegal-gutter (Only 12px/pair, 20px/scene, 32px/change)
|
|
1617
|
+
// =========================================================================
|
|
1618
|
+
if (!isTokenFile) {
|
|
1619
|
+
let m;
|
|
1620
|
+
|
|
1621
|
+
// 2a. <Scene gutter="..." /> prop check
|
|
1622
|
+
const sceneGutterStrRegex =
|
|
1623
|
+
/<Scene\b[^>]*?\bgutter\s*=\s*(?:['"]([^'"]*)['"]|\{\s*['"`]([^'"`]*)['"`]\s*\})/g;
|
|
1624
|
+
while ((m = sceneGutterStrRegex.exec(stripped)) !== null) {
|
|
1625
|
+
const val = m[1] ?? m[2] ?? '';
|
|
1626
|
+
if (!LEGAL_GUTTER_NAMES.has(val)) {
|
|
1627
|
+
violations.push({
|
|
1628
|
+
rule: 'illegal-gutter',
|
|
1629
|
+
file: filePath,
|
|
1630
|
+
line: lineNumberAt(content, m.index),
|
|
1631
|
+
column: columnNumberAt(content, m.index),
|
|
1632
|
+
message: `Invalid Scene gutter "${val}". Must be "pair" (12px), "scene" (20px), or "change" (32px).`,
|
|
1633
|
+
snippet: m[0],
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// <Scene gutter={16} /> numeric check
|
|
1639
|
+
const sceneGutterNumRegex = /<Scene\b[^>]*?\bgutter\s*=\s*\{\s*(-?\d+(?:\.\d+)?)\s*\}/g;
|
|
1640
|
+
while ((m = sceneGutterNumRegex.exec(stripped)) !== null) {
|
|
1641
|
+
violations.push({
|
|
1642
|
+
rule: 'illegal-gutter',
|
|
1643
|
+
file: filePath,
|
|
1644
|
+
line: lineNumberAt(content, m.index),
|
|
1645
|
+
column: columnNumberAt(content, m.index),
|
|
1646
|
+
message: `Invalid numeric Scene gutter "{${m[1]}}". Must be "pair" (12px), "scene" (20px), or "change" (32px).`,
|
|
1647
|
+
snippet: m[0],
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// 2b. Inline style object or CSS gap / rowGap / columnGap / gridGap
|
|
1652
|
+
if (ext === '.css') {
|
|
1653
|
+
const cssGapRegex = /(?<![-\w])(?:gap|grid-gap|row-gap|column-gap)\s*:\s*([^;}\n]+)/gi;
|
|
1654
|
+
while ((m = cssGapRegex.exec(stripped)) !== null) {
|
|
1655
|
+
const val = m[1].trim();
|
|
1656
|
+
if (!isLegalGapValue(val)) {
|
|
1657
|
+
violations.push({
|
|
1658
|
+
rule: 'illegal-gutter',
|
|
1659
|
+
file: filePath,
|
|
1660
|
+
line: lineNumberAt(content, m.index),
|
|
1661
|
+
column: columnNumberAt(content, m.index),
|
|
1662
|
+
message: `Illegal CSS gutter "${m[0].trim()}". Only 12px (pair), 20px (scene), or 32px (change) are allowed.`,
|
|
1663
|
+
snippet: m[0].trim(),
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
} else {
|
|
1668
|
+
// JSX inline style object: gap: '16px', gap: 16, rowGap: '24px', columnGap: 8
|
|
1669
|
+
const jsxGapRegex =
|
|
1670
|
+
/\b(?:gap|gridGap|rowGap|columnGap)\s*:\s*(?:(['"`])([^'"`]*)\1|(-?\d+(?:\.\d+)?)\b)/g;
|
|
1671
|
+
while ((m = jsxGapRegex.exec(stripped)) !== null) {
|
|
1672
|
+
const raw = m[2] !== undefined ? m[2] : m[3];
|
|
1673
|
+
if (!isLegalGapValue(raw)) {
|
|
1674
|
+
violations.push({
|
|
1675
|
+
rule: 'illegal-gutter',
|
|
1676
|
+
file: filePath,
|
|
1677
|
+
line: lineNumberAt(content, m.index),
|
|
1678
|
+
column: columnNumberAt(content, m.index),
|
|
1679
|
+
message: `Illegal inline gutter "${m[0].trim()}". Use 12px (var(--f-gutter-pair)), 20px (var(--f-gutter-scene)), or 32px (var(--f-gutter-change)).`,
|
|
1680
|
+
snippet: m[0].trim(),
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// 2c. Tailwind gap-* classes in className (including string literals, template literals, cn(), clsx())
|
|
1686
|
+
const classStartRegex = /\bclassName\s*=\s*/g;
|
|
1687
|
+
while ((m = classStartRegex.exec(stripped)) !== null) {
|
|
1688
|
+
const valStart = m.index + m[0].length;
|
|
1689
|
+
const firstCh = stripped[valStart];
|
|
1690
|
+
const stringFragments = [];
|
|
1691
|
+
|
|
1692
|
+
if (firstCh === '"' || firstCh === "'") {
|
|
1693
|
+
const endIdx = stripped.indexOf(firstCh, valStart + 1);
|
|
1694
|
+
if (endIdx !== -1) {
|
|
1695
|
+
stringFragments.push(stripped.slice(valStart + 1, endIdx));
|
|
1696
|
+
}
|
|
1697
|
+
} else if (firstCh === '{') {
|
|
1698
|
+
const expr = extractBalancedBraces(stripped, valStart);
|
|
1699
|
+
const strLitRegex = /(['"`])((?:\\.|(?!\1)[\s\S])*)\1/g;
|
|
1700
|
+
let sm;
|
|
1701
|
+
while ((sm = strLitRegex.exec(expr)) !== null) {
|
|
1702
|
+
// Strip ${...} interpolation blocks inside template literals while keeping static class tokens
|
|
1703
|
+
const cleaned = sm[2].replace(/\$\{[^}]*\}/g, ' ');
|
|
1704
|
+
stringFragments.push(cleaned);
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
for (const clsStr of stringFragments) {
|
|
1709
|
+
const tokens = clsStr.split(/\s+/).filter(Boolean);
|
|
1710
|
+
for (const tok of tokens) {
|
|
1711
|
+
if (/^gap-(?:x-|y-)?/.test(tok) && !LEGAL_TAILWIND_GAP_CLASSES.has(tok)) {
|
|
1712
|
+
violations.push({
|
|
1713
|
+
rule: 'illegal-gutter',
|
|
1714
|
+
file: filePath,
|
|
1715
|
+
line: lineNumberAt(content, m.index),
|
|
1716
|
+
column: columnNumberAt(content, m.index),
|
|
1717
|
+
message: `Illegal Tailwind gutter class "${tok}". Use Scene gutter="pair|scene|change" or 12px/20px/32px tokens.`,
|
|
1718
|
+
snippet: tok,
|
|
1719
|
+
});
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
// =========================================================================
|
|
1728
|
+
// 3. marker-misuse (--f-marker / #ffd60a only as background under --f-marker-ink)
|
|
1729
|
+
// =========================================================================
|
|
1730
|
+
if (!isTokenFile) {
|
|
1731
|
+
let m;
|
|
1732
|
+
|
|
1733
|
+
// 3a. Forbidden CSS/inline properties using --f-marker or #ffd60a
|
|
1734
|
+
const forbiddenMarkerPropRegex =
|
|
1735
|
+
/(?<![-\w])(color|stroke|fill|border|borderColor|border-color|borderTop|borderBottom|borderLeft|borderRight|borderTopColor|borderBottomColor|borderLeftColor|borderRightColor|outline|outlineColor|outline-color|textDecorationColor|boxShadow|box-shadow)\s*:\s*([^;,\n}]*(?:--f-marker\b|#ffd60a\b)[^;,\n}]*)/gi;
|
|
1736
|
+
while ((m = forbiddenMarkerPropRegex.exec(stripped)) !== null) {
|
|
1737
|
+
const prop = m[1];
|
|
1738
|
+
const val = m[2].trim();
|
|
1739
|
+
// Make sure we didn't match --f-marker-ink on color
|
|
1740
|
+
if (
|
|
1741
|
+
/--f-marker-ink\b/.test(val) &&
|
|
1742
|
+
!/--f-marker(?!-ink)\b/i.test(val) &&
|
|
1743
|
+
!/#ffd60a\b/i.test(val)
|
|
1744
|
+
) {
|
|
1745
|
+
continue;
|
|
1746
|
+
}
|
|
1747
|
+
violations.push({
|
|
1748
|
+
rule: 'marker-misuse',
|
|
1749
|
+
file: filePath,
|
|
1750
|
+
line: lineNumberAt(content, m.index),
|
|
1751
|
+
column: columnNumberAt(content, m.index),
|
|
1752
|
+
message: `Marker yellow (--f-marker / #ffd60a) cannot be used on "${prop}". It may only be a background under --f-marker-ink text.`,
|
|
1753
|
+
snippet: m[0].trim(),
|
|
1754
|
+
});
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
// 3b. SVG JSX attributes stroke="var(--f-marker)" or fill="var(--f-marker)"
|
|
1758
|
+
const svgMarkerAttrRegex =
|
|
1759
|
+
/\b(stroke|fill|color)\s*=\s*['"][^'"]*(?:--f-marker(?!-ink)|#ffd60a)[^'"]*['"]/gi;
|
|
1760
|
+
while ((m = svgMarkerAttrRegex.exec(stripped)) !== null) {
|
|
1761
|
+
violations.push({
|
|
1762
|
+
rule: 'marker-misuse',
|
|
1763
|
+
file: filePath,
|
|
1764
|
+
line: lineNumberAt(content, m.index),
|
|
1765
|
+
column: columnNumberAt(content, m.index),
|
|
1766
|
+
message: `Marker yellow cannot be used as an SVG/JSX "${m[1]}" attribute. Use <Tag>, <Badge>, or <Balloon>.`,
|
|
1767
|
+
snippet: m[0],
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
// 3c. Tailwind marker classes on text/border/stroke/fill/ring
|
|
1772
|
+
const twMarkerMisuseRegex =
|
|
1773
|
+
/\b(?:text|border|stroke|fill|ring|outline)-(?:f-marker\b|\[[^\]]*(?:--f-marker(?!-ink)|#ffd60a)[^\]]*\])/g;
|
|
1774
|
+
while ((m = twMarkerMisuseRegex.exec(stripped)) !== null) {
|
|
1775
|
+
violations.push({
|
|
1776
|
+
rule: 'marker-misuse',
|
|
1777
|
+
file: filePath,
|
|
1778
|
+
line: lineNumberAt(content, m.index),
|
|
1779
|
+
column: columnNumberAt(content, m.index),
|
|
1780
|
+
message: `Marker yellow utility "${m[0]}" is forbidden. Marker yellow may only be used as a background paired with --f-marker-ink text.`,
|
|
1781
|
+
snippet: m[0],
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
// 3d. Inline style object or CSS rule setting background/backgroundColor to --f-marker without color: var(--f-marker-ink)
|
|
1786
|
+
const styleObjRegex = /style\s*=\s*\{\s*\{([\s\S]*?)\}\s*\}/g;
|
|
1787
|
+
while ((m = styleObjRegex.exec(stripped)) !== null) {
|
|
1788
|
+
const body = m[1];
|
|
1789
|
+
const hasMarkerBg =
|
|
1790
|
+
/\b(?:background|backgroundColor)\s*:\s*['"`][^'"`]*(?:--f-marker(?!-ink)|#ffd60a)[^'"`]*['"`]/i.test(
|
|
1791
|
+
body,
|
|
1792
|
+
);
|
|
1793
|
+
if (hasMarkerBg) {
|
|
1794
|
+
const hasMarkerInkColor = /\bcolor\s*:\s*['"`][^'"`]*--f-marker-ink\b/i.test(body);
|
|
1795
|
+
if (!hasMarkerInkColor) {
|
|
1796
|
+
violations.push({
|
|
1797
|
+
rule: 'marker-misuse',
|
|
1798
|
+
file: filePath,
|
|
1799
|
+
line: lineNumberAt(content, m.index),
|
|
1800
|
+
column: columnNumberAt(content, m.index),
|
|
1801
|
+
message:
|
|
1802
|
+
'Marker yellow background in inline style must be paired with color: "var(--f-marker-ink)".',
|
|
1803
|
+
snippet: m[0].replace(/\s+/g, ' ').trim(),
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
if (ext === '.css') {
|
|
1810
|
+
const cssBlockRegex = /\{([^{}]*)\}/g;
|
|
1811
|
+
while ((m = cssBlockRegex.exec(stripped)) !== null) {
|
|
1812
|
+
const body = m[1];
|
|
1813
|
+
const hasMarkerBg =
|
|
1814
|
+
/\bbackground(?:-color)?\s*:\s*[^;]*(?:--f-marker(?!-ink)|#ffd60a)/i.test(body);
|
|
1815
|
+
if (hasMarkerBg) {
|
|
1816
|
+
const hasMarkerInkColor = /\bcolor\s*:\s*[^;]*--f-marker-ink\b/i.test(body);
|
|
1817
|
+
if (!hasMarkerInkColor) {
|
|
1818
|
+
violations.push({
|
|
1819
|
+
rule: 'marker-misuse',
|
|
1820
|
+
file: filePath,
|
|
1821
|
+
line: lineNumberAt(content, m.index),
|
|
1822
|
+
column: columnNumberAt(content, m.index),
|
|
1823
|
+
message:
|
|
1824
|
+
'Marker yellow background in CSS rule must be paired with color: var(--f-marker-ink).',
|
|
1825
|
+
snippet: body.replace(/\s+/g, ' ').trim(),
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
// =========================================================================
|
|
1834
|
+
// 4. cut-order & explicit-cut-in-autocut (via TypeScript TSX AST or fallback)
|
|
1835
|
+
// =========================================================================
|
|
1836
|
+
if (ext !== '.css') {
|
|
1837
|
+
const ts = opts?.disableTypeScript ? null : loadTypeScript();
|
|
1838
|
+
if (!ts) {
|
|
1839
|
+
if (!warnedTypeScriptFallback && !opts?.quietFallbackWarning) {
|
|
1840
|
+
warnedTypeScriptFallback = true;
|
|
1841
|
+
console.warn(
|
|
1842
|
+
'WARN [@frame12/lint] Optional peer dependency "typescript" is not installed; falling back to regex cut-order checks for JSX/TSX files.',
|
|
1843
|
+
);
|
|
1844
|
+
}
|
|
1845
|
+
lintCutsFallback(stripped, content, filePath, violations);
|
|
1846
|
+
} else {
|
|
1847
|
+
lintCutsWithTypeScript(ts, content, filePath, violations);
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// =========================================================================
|
|
1852
|
+
// 5. raw-shadcn-import (Forbidden raw @/components/ui/*, @radix-ui/*, or charting libs)
|
|
1853
|
+
// =========================================================================
|
|
1854
|
+
if (ext !== '.css' && !isRegistryImplementation) {
|
|
1855
|
+
const importRegex =
|
|
1856
|
+
/\b(?:import|export)\b[\s\S]*?\bfrom\s*['"]([^'"]+)['"]|\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
1857
|
+
let m;
|
|
1858
|
+
while ((m = importRegex.exec(stripped)) !== null) {
|
|
1859
|
+
const spec = m[1] ?? m[2] ?? '';
|
|
1860
|
+
const line = lineNumberAt(content, m.index);
|
|
1861
|
+
const column = columnNumberAt(content, m.index);
|
|
1862
|
+
|
|
1863
|
+
if (spec.startsWith('@radix-ui/')) {
|
|
1864
|
+
violations.push({
|
|
1865
|
+
rule: 'raw-shadcn-import',
|
|
1866
|
+
file: filePath,
|
|
1867
|
+
line,
|
|
1868
|
+
column,
|
|
1869
|
+
message: `Raw Radix primitive import "${spec}" is forbidden. Import the FRAME/12 adapter from "@/components/frame12".`,
|
|
1870
|
+
snippet: m[0].trim(),
|
|
1871
|
+
});
|
|
1872
|
+
continue;
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
if (FORBIDDEN_EXTERNAL_PACKAGES.has(spec)) {
|
|
1876
|
+
violations.push({
|
|
1877
|
+
rule: 'raw-shadcn-import',
|
|
1878
|
+
file: filePath,
|
|
1879
|
+
line,
|
|
1880
|
+
column,
|
|
1881
|
+
message: `External library "${spec}" is forbidden in FRAME/12. Use ToneAreaChart, ToneBarList, ToneSpark from "@/components/frame12" and useOnTwos from "@/hooks/use-on-twos".`,
|
|
1882
|
+
snippet: m[0].trim(),
|
|
1883
|
+
});
|
|
1884
|
+
continue;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
const uiMatch = spec.match(/^(?:@\/|\.\.?\/.*)?components\/ui\/([a-z0-9-]+)$/i);
|
|
1888
|
+
if (uiMatch) {
|
|
1889
|
+
const modName = uiMatch[1].toLowerCase();
|
|
1890
|
+
if (FORBIDDEN_SHADCN_MODULES.has(modName)) {
|
|
1891
|
+
violations.push({
|
|
1892
|
+
rule: 'raw-shadcn-import',
|
|
1893
|
+
file: filePath,
|
|
1894
|
+
line,
|
|
1895
|
+
column,
|
|
1896
|
+
message: `Raw shadcn import "${spec}" is forbidden where a FRAME/12 component or adapter exists. Import from "@/components/frame12".`,
|
|
1897
|
+
snippet: m[0].trim(),
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
return violations;
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
/**
|
|
1908
|
+
* Lints a CSS text fragment (from a `<style>` element or an inline `style="..."` attribute)
|
|
1909
|
+
* and maps all violations back to 1-based line and column positions in `fullHtml`.
|
|
1910
|
+
*/
|
|
1911
|
+
function lintCssInHtml(cssText, baseOffset, fullHtml, filePath, violations, isInlineAttr) {
|
|
1912
|
+
const clean = stripCssCommentsPreservingLines(cssText);
|
|
1913
|
+
let m;
|
|
1914
|
+
|
|
1915
|
+
// unsafe-html in CSS: @import and external url(https://...) / url(//...)
|
|
1916
|
+
const cssImportRegex = /@import\b[^;\n]*/gi;
|
|
1917
|
+
while ((m = cssImportRegex.exec(clean)) !== null) {
|
|
1918
|
+
const absIdx = baseOffset + m.index;
|
|
1919
|
+
violations.push({
|
|
1920
|
+
rule: 'unsafe-html',
|
|
1921
|
+
file: filePath,
|
|
1922
|
+
line: lineNumberAt(fullHtml, absIdx),
|
|
1923
|
+
column: columnNumberAt(fullHtml, absIdx),
|
|
1924
|
+
message: 'CSS "@import" is forbidden in self-contained generated HTML.',
|
|
1925
|
+
snippet: m[0].trim(),
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
const externalCssUrlRegex = /\burl\(\s*(['"]?)\s*((?:https?:)?\/\/[^)'"\s]+)\s*\1\s*\)/gi;
|
|
1930
|
+
while ((m = externalCssUrlRegex.exec(clean)) !== null) {
|
|
1931
|
+
const absIdx = baseOffset + m.index;
|
|
1932
|
+
violations.push({
|
|
1933
|
+
rule: 'unsafe-html',
|
|
1934
|
+
file: filePath,
|
|
1935
|
+
line: lineNumberAt(fullHtml, absIdx),
|
|
1936
|
+
column: columnNumberAt(fullHtml, absIdx),
|
|
1937
|
+
message: `External CSS url("${m[2]}") is forbidden in self-contained generated HTML.`,
|
|
1938
|
+
snippet: m[0].trim(),
|
|
1939
|
+
});
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
// 1a. Hex colors (#rgb, #rgba, #rrggbb, #rrggbbaa)
|
|
1943
|
+
const hexRegex = /#[0-9a-fA-F]{3,8}\b/g;
|
|
1944
|
+
while ((m = hexRegex.exec(clean)) !== null) {
|
|
1945
|
+
const hex = m[0];
|
|
1946
|
+
if (![4, 5, 7, 9].includes(hex.length)) continue;
|
|
1947
|
+
const before = clean.slice(Math.max(0, m.index - 12), m.index);
|
|
1948
|
+
if (/url\(\s*['"]?$/i.test(before) || /href\s*=\s*['"]$/i.test(before)) continue;
|
|
1949
|
+
const absIdx = baseOffset + m.index;
|
|
1950
|
+
violations.push({
|
|
1951
|
+
rule: 'color-literal',
|
|
1952
|
+
file: filePath,
|
|
1953
|
+
line: lineNumberAt(fullHtml, absIdx),
|
|
1954
|
+
column: columnNumberAt(fullHtml, absIdx),
|
|
1955
|
+
message: `Hard-coded hex color "${hex}" is forbidden outside token files. Use a var(--f-*) token.`,
|
|
1956
|
+
snippet: hex,
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
// 1b. Color functions
|
|
1961
|
+
const fnColorRegex = /\b(?:rgb|rgba|hsl|hsla|oklch|oklab)\s*\([^)]*\)/gi;
|
|
1962
|
+
while ((m = fnColorRegex.exec(clean)) !== null) {
|
|
1963
|
+
const absIdx = baseOffset + m.index;
|
|
1964
|
+
violations.push({
|
|
1965
|
+
rule: 'color-literal',
|
|
1966
|
+
file: filePath,
|
|
1967
|
+
line: lineNumberAt(fullHtml, absIdx),
|
|
1968
|
+
column: columnNumberAt(fullHtml, absIdx),
|
|
1969
|
+
message: `Hard-coded color function "${m[0]}" is forbidden outside token files. Use a var(--f-*) token.`,
|
|
1970
|
+
snippet: m[0],
|
|
1971
|
+
});
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
// 1c. CSS gradients
|
|
1975
|
+
const gradientRegex =
|
|
1976
|
+
/\b(?:repeating-)?(?:linear|radial|conic)-gradient\s*\([^;}\n)]*\)/gi;
|
|
1977
|
+
while ((m = gradientRegex.exec(clean)) !== null) {
|
|
1978
|
+
const grad = m[0];
|
|
1979
|
+
if (/^radial-gradient\(\s*circle\s*,\s*var\(--f-tone\)/i.test(grad)) continue;
|
|
1980
|
+
const absIdx = baseOffset + m.index;
|
|
1981
|
+
violations.push({
|
|
1982
|
+
rule: 'color-literal',
|
|
1983
|
+
file: filePath,
|
|
1984
|
+
line: lineNumberAt(fullHtml, absIdx),
|
|
1985
|
+
column: columnNumberAt(fullHtml, absIdx),
|
|
1986
|
+
message: `CSS gradient "${grad}" is forbidden in FRAME/12. Use 45° SVG screentone patterns or flat var(--f-*) surfaces.`,
|
|
1987
|
+
snippet: grad,
|
|
1988
|
+
});
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
// 1d. Hard-coded font names
|
|
1992
|
+
const fontRegex = /\b(?:Dela Gothic One|Zen Kaku Gothic New|M PLUS 1 Code)\b/g;
|
|
1993
|
+
while ((m = fontRegex.exec(clean)) !== null) {
|
|
1994
|
+
const absIdx = baseOffset + m.index;
|
|
1995
|
+
violations.push({
|
|
1996
|
+
rule: 'color-literal',
|
|
1997
|
+
file: filePath,
|
|
1998
|
+
line: lineNumberAt(fullHtml, absIdx),
|
|
1999
|
+
column: columnNumberAt(fullHtml, absIdx),
|
|
2000
|
+
message: `Hard-coded font family "${m[0]}" is forbidden outside token files. Use var(--font-f-*).`,
|
|
2001
|
+
snippet: m[0],
|
|
2002
|
+
});
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
// 2. illegal-gutter
|
|
2006
|
+
const cssGapRegex = /(?<![-\w])(?:gap|grid-gap|row-gap|column-gap)\s*:\s*([^;}\n]+)/gi;
|
|
2007
|
+
while ((m = cssGapRegex.exec(clean)) !== null) {
|
|
2008
|
+
const val = m[1].trim();
|
|
2009
|
+
if (!isLegalGapValue(val)) {
|
|
2010
|
+
const absIdx = baseOffset + m.index;
|
|
2011
|
+
violations.push({
|
|
2012
|
+
rule: 'illegal-gutter',
|
|
2013
|
+
file: filePath,
|
|
2014
|
+
line: lineNumberAt(fullHtml, absIdx),
|
|
2015
|
+
column: columnNumberAt(fullHtml, absIdx),
|
|
2016
|
+
message: `Illegal CSS gutter "${m[0].trim()}". Only 12px (pair), 20px (scene), or 32px (change) are allowed.`,
|
|
2017
|
+
snippet: m[0].trim(),
|
|
2018
|
+
});
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
// 3a. marker-misuse on forbidden CSS properties
|
|
2023
|
+
const forbiddenMarkerPropRegex =
|
|
2024
|
+
/(?<![-\w])(color|stroke|fill|border|border-color|border-top|border-bottom|border-left|border-right|border-top-color|border-bottom-color|border-left-color|border-right-color|outline|outline-color|text-decoration-color|box-shadow)\s*:\s*([^;,\n}]*(?:--f-marker\b|#ffd60a\b)[^;,\n}]*)/gi;
|
|
2025
|
+
while ((m = forbiddenMarkerPropRegex.exec(clean)) !== null) {
|
|
2026
|
+
const prop = m[1];
|
|
2027
|
+
const val = m[2].trim();
|
|
2028
|
+
if (
|
|
2029
|
+
/--f-marker-ink\b/.test(val) &&
|
|
2030
|
+
!/--f-marker(?!-ink)\b/i.test(val) &&
|
|
2031
|
+
!/#ffd60a\b/i.test(val)
|
|
2032
|
+
) {
|
|
2033
|
+
continue;
|
|
2034
|
+
}
|
|
2035
|
+
const absIdx = baseOffset + m.index;
|
|
2036
|
+
violations.push({
|
|
2037
|
+
rule: 'marker-misuse',
|
|
2038
|
+
file: filePath,
|
|
2039
|
+
line: lineNumberAt(fullHtml, absIdx),
|
|
2040
|
+
column: columnNumberAt(fullHtml, absIdx),
|
|
2041
|
+
message: `Marker yellow (--f-marker / #ffd60a) cannot be used on "${prop}". It may only be a background under --f-marker-ink text.`,
|
|
2042
|
+
snippet: m[0].trim(),
|
|
2043
|
+
});
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
// 3b. marker-misuse on background without --f-marker-ink
|
|
2047
|
+
if (isInlineAttr) {
|
|
2048
|
+
const bgMatch =
|
|
2049
|
+
/(?<![-\w])background(?:-color)?\s*:\s*[^;]*(?:--f-marker(?!-ink)|#ffd60a)/i.exec(clean);
|
|
2050
|
+
if (bgMatch) {
|
|
2051
|
+
const hasMarkerInkColor = /(?<![-\w])color\s*:\s*[^;]*--f-marker-ink\b/i.test(clean);
|
|
2052
|
+
if (!hasMarkerInkColor) {
|
|
2053
|
+
const absIdx = baseOffset + bgMatch.index;
|
|
2054
|
+
violations.push({
|
|
2055
|
+
rule: 'marker-misuse',
|
|
2056
|
+
file: filePath,
|
|
2057
|
+
line: lineNumberAt(fullHtml, absIdx),
|
|
2058
|
+
column: columnNumberAt(fullHtml, absIdx),
|
|
2059
|
+
message:
|
|
2060
|
+
'Marker yellow background in inline style must be paired with color: "var(--f-marker-ink)".',
|
|
2061
|
+
snippet: clean.replace(/\s+/g, ' ').trim(),
|
|
2062
|
+
});
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
} else {
|
|
2066
|
+
const cssBlockRegex = /\{([^{}]*)\}/g;
|
|
2067
|
+
while ((m = cssBlockRegex.exec(clean)) !== null) {
|
|
2068
|
+
const body = m[1];
|
|
2069
|
+
const bgMatch =
|
|
2070
|
+
/(?<![-\w])background(?:-color)?\s*:\s*[^;]*(?:--f-marker(?!-ink)|#ffd60a)/i.exec(body);
|
|
2071
|
+
if (bgMatch) {
|
|
2072
|
+
const hasMarkerInkColor = /(?<![-\w])color\s*:\s*[^;]*--f-marker-ink\b/i.test(body);
|
|
2073
|
+
if (!hasMarkerInkColor) {
|
|
2074
|
+
const absIdx = baseOffset + m.index + 1 + bgMatch.index;
|
|
2075
|
+
violations.push({
|
|
2076
|
+
rule: 'marker-misuse',
|
|
2077
|
+
file: filePath,
|
|
2078
|
+
line: lineNumberAt(fullHtml, absIdx),
|
|
2079
|
+
column: columnNumberAt(fullHtml, absIdx),
|
|
2080
|
+
message:
|
|
2081
|
+
'Marker yellow background in CSS rule must be paired with color: var(--f-marker-ink).',
|
|
2082
|
+
snippet: body.replace(/\s+/g, ' ').trim(),
|
|
2083
|
+
});
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
/**
|
|
2091
|
+
* Decodes basic numeric/named HTML character references and strips control characters/whitespace
|
|
2092
|
+
* to detect obfuscated `javascript:` or external URLs in attribute values.
|
|
2093
|
+
*/
|
|
2094
|
+
function normalizeUrlValueForSchemeCheck(rawVal) {
|
|
2095
|
+
const decoded = rawVal
|
|
2096
|
+
.replace(/&#x([0-9a-fA-F]+);?/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
|
|
2097
|
+
.replace(/&#(\d+);?/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
|
|
2098
|
+
.replace(/:/gi, ':')
|
|
2099
|
+
.replace(/&tab;/gi, '\t')
|
|
2100
|
+
.replace(/&newline;/gi, '\n');
|
|
2101
|
+
// Strip ASCII whitespace and control characters
|
|
2102
|
+
return decoded.replace(/[\x00-\x20]+/g, '');
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
/**
|
|
2106
|
+
* Parses attributes of an opening HTML tag starting at `startIdx` (right after `<tagName`).
|
|
2107
|
+
* Returns `{ attrs, endIdx, selfClosing }`.
|
|
2108
|
+
*/
|
|
2109
|
+
function parseHtmlTagAttributes(html, startIdx) {
|
|
2110
|
+
const attrs = [];
|
|
2111
|
+
const len = html.length;
|
|
2112
|
+
let pos = startIdx;
|
|
2113
|
+
let selfClosing = false;
|
|
2114
|
+
|
|
2115
|
+
while (pos < len) {
|
|
2116
|
+
while (pos < len && /\s/.test(html[pos])) pos++;
|
|
2117
|
+
if (pos >= len) break;
|
|
2118
|
+
|
|
2119
|
+
if (html[pos] === '>') {
|
|
2120
|
+
pos++;
|
|
2121
|
+
break;
|
|
2122
|
+
}
|
|
2123
|
+
if (html[pos] === '/' && html[pos + 1] === '>') {
|
|
2124
|
+
selfClosing = true;
|
|
2125
|
+
pos += 2;
|
|
2126
|
+
break;
|
|
2127
|
+
}
|
|
2128
|
+
if (html[pos] === '/') {
|
|
2129
|
+
pos++;
|
|
2130
|
+
continue;
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
const nameOffset = pos;
|
|
2134
|
+
while (pos < len && !/[\s=/>"'<]/.test(html[pos])) pos++;
|
|
2135
|
+
if (pos === nameOffset) {
|
|
2136
|
+
pos++;
|
|
2137
|
+
continue;
|
|
2138
|
+
}
|
|
2139
|
+
const name = html.slice(nameOffset, pos);
|
|
2140
|
+
const lowerName = name.toLowerCase();
|
|
2141
|
+
|
|
2142
|
+
let afterName = pos;
|
|
2143
|
+
while (afterName < len && /\s/.test(html[afterName])) afterName++;
|
|
2144
|
+
|
|
2145
|
+
let hasValue = false;
|
|
2146
|
+
let value = '';
|
|
2147
|
+
let valueOffset = afterName;
|
|
2148
|
+
|
|
2149
|
+
if (html[afterName] === '=') {
|
|
2150
|
+
hasValue = true;
|
|
2151
|
+
pos = afterName + 1;
|
|
2152
|
+
while (pos < len && /\s/.test(html[pos])) pos++;
|
|
2153
|
+
const ch = html[pos];
|
|
2154
|
+
if (ch === '"' || ch === "'") {
|
|
2155
|
+
const quote = ch;
|
|
2156
|
+
pos++;
|
|
2157
|
+
valueOffset = pos;
|
|
2158
|
+
const closeQuote = html.indexOf(quote, pos);
|
|
2159
|
+
if (closeQuote === -1) {
|
|
2160
|
+
value = html.slice(pos);
|
|
2161
|
+
pos = len;
|
|
2162
|
+
} else {
|
|
2163
|
+
value = html.slice(pos, closeQuote);
|
|
2164
|
+
pos = closeQuote + 1;
|
|
2165
|
+
}
|
|
2166
|
+
} else {
|
|
2167
|
+
valueOffset = pos;
|
|
2168
|
+
while (pos < len && !/[\s>]/.test(html[pos]) && !(html[pos] === '/' && html[pos + 1] === '>')) {
|
|
2169
|
+
pos++;
|
|
2170
|
+
}
|
|
2171
|
+
value = html.slice(valueOffset, pos);
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
const raw = html.slice(nameOffset, pos);
|
|
2176
|
+
attrs.push({
|
|
2177
|
+
name,
|
|
2178
|
+
lowerName,
|
|
2179
|
+
hasValue,
|
|
2180
|
+
value,
|
|
2181
|
+
nameOffset,
|
|
2182
|
+
valueOffset,
|
|
2183
|
+
raw,
|
|
2184
|
+
});
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
return { attrs, endIdx: pos, selfClosing };
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
/**
|
|
2191
|
+
* Lints an HTML document or fragment against FRAME/12 design-grammar rules:
|
|
2192
|
+
* - `color-literal`, `illegal-gutter`, `marker-misuse` inside `<style>` elements
|
|
2193
|
+
* (excluding `<style data-f12-tokens>` and `opts.tokenStyleIds`), `style="..."`
|
|
2194
|
+
* attributes, `class="..."` attributes, and SVG presentation attributes.
|
|
2195
|
+
* - `cut-order` on `[data-cut]` and `.f12-cut` elements in DOM reading order.
|
|
2196
|
+
* - `unsafe-html` on external `<script src="...">`, external subresource loads
|
|
2197
|
+
* (`<link href>`, `<img src>`, CSS `@import`/`url()`), embedded frames
|
|
2198
|
+
* (`<iframe>`, `<object>`, `<embed>`, `srcdoc`), `<meta http-equiv="refresh">`,
|
|
2199
|
+
* `<base href>`, `on*=` attributes, and `javascript:` URLs.
|
|
2200
|
+
*/
|
|
2201
|
+
export function lintHtml(input, opts = {}) {
|
|
2202
|
+
// Accept objects with a string form (e.g. @frame12/static `Html` values) by converting them
|
|
2203
|
+
// once; indexing a non-string character by character would never terminate or lint nothing.
|
|
2204
|
+
const html = typeof input === 'string' ? input : String(input ?? '');
|
|
2205
|
+
const filePath = opts.file ?? 'input.html';
|
|
2206
|
+
const tokenStyleIds = new Set(opts.tokenStyleIds ?? []);
|
|
2207
|
+
const violations = [];
|
|
2208
|
+
const pendingCuts = [];
|
|
2209
|
+
const openStack = [];
|
|
2210
|
+
|
|
2211
|
+
const len = html.length;
|
|
2212
|
+
let i = 0;
|
|
2213
|
+
|
|
2214
|
+
const appendTextToOpenBadges = (text) => {
|
|
2215
|
+
if (!text) return;
|
|
2216
|
+
for (const entry of openStack) {
|
|
2217
|
+
if (entry.isCutBadge) {
|
|
2218
|
+
entry.textBuffer += text;
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
};
|
|
2222
|
+
|
|
2223
|
+
const finalizeElementOnClose = (entry, closeEndIdx) => {
|
|
2224
|
+
if (!entry.isCutBadge || !entry.cutRecord) return;
|
|
2225
|
+
const trimmed = entry.textBuffer.trim();
|
|
2226
|
+
if (!trimmed || trimmed.toLowerCase() === 'auto') {
|
|
2227
|
+
entry.cutRecord.ignore = true;
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
if (
|
|
2231
|
+
entry.ancestorWithDataCut &&
|
|
2232
|
+
!entry.ancestorWithDataCut.consumedChildCut &&
|
|
2233
|
+
entry.ancestorWithDataCut.explicitDataCut === trimmed
|
|
2234
|
+
) {
|
|
2235
|
+
entry.ancestorWithDataCut.consumedChildCut = true;
|
|
2236
|
+
entry.cutRecord.ignore = true;
|
|
2237
|
+
return;
|
|
2238
|
+
}
|
|
2239
|
+
entry.cutRecord.raw = trimmed;
|
|
2240
|
+
if (closeEndIdx !== undefined) {
|
|
2241
|
+
entry.cutRecord.snippet = html.slice(entry.tagStartOffset, closeEndIdx);
|
|
2242
|
+
}
|
|
2243
|
+
};
|
|
2244
|
+
|
|
2245
|
+
while (i < len) {
|
|
2246
|
+
if (html[i] !== '<') {
|
|
2247
|
+
const nextLt = html.indexOf('<', i);
|
|
2248
|
+
const chunk = nextLt === -1 ? html.slice(i) : html.slice(i, nextLt);
|
|
2249
|
+
appendTextToOpenBadges(chunk);
|
|
2250
|
+
i = nextLt === -1 ? len : nextLt;
|
|
2251
|
+
continue;
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
// 1. HTML comment <!-- ... -->
|
|
2255
|
+
if (html.startsWith('<!--', i)) {
|
|
2256
|
+
const endComment = html.indexOf('-->', i + 4);
|
|
2257
|
+
i = endComment === -1 ? len : endComment + 3;
|
|
2258
|
+
continue;
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
// 2. CDATA <![CDATA[ ... ]]>
|
|
2262
|
+
if (html.slice(i, i + 9).toUpperCase() === '<![CDATA[') {
|
|
2263
|
+
const endCdata = html.indexOf(']]>', i + 9);
|
|
2264
|
+
if (endCdata === -1) {
|
|
2265
|
+
appendTextToOpenBadges(html.slice(i + 9));
|
|
2266
|
+
break;
|
|
2267
|
+
}
|
|
2268
|
+
appendTextToOpenBadges(html.slice(i + 9, endCdata));
|
|
2269
|
+
i = endCdata + 3;
|
|
2270
|
+
continue;
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
// 3. Declaration <!DOCTYPE ...> or processing instruction <? ... ?>
|
|
2274
|
+
if (html[i + 1] === '!' || html[i + 1] === '?') {
|
|
2275
|
+
const endDecl = html.indexOf('>', i + 2);
|
|
2276
|
+
i = endDecl === -1 ? len : endDecl + 1;
|
|
2277
|
+
continue;
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
// 4. Closing tag </tagName ...>
|
|
2281
|
+
if (html[i + 1] === '/') {
|
|
2282
|
+
const closeMatch = /^<\/\s*([A-Za-z][A-Za-z0-9:_-]*)[^>]*>/.exec(html.slice(i));
|
|
2283
|
+
if (closeMatch) {
|
|
2284
|
+
const closeTag = closeMatch[1].toLowerCase();
|
|
2285
|
+
const closeEndIdx = i + closeMatch[0].length;
|
|
2286
|
+
for (let s = openStack.length - 1; s >= 0; s--) {
|
|
2287
|
+
if (openStack[s].tagName === closeTag) {
|
|
2288
|
+
while (openStack.length > s) {
|
|
2289
|
+
const popped = openStack.pop();
|
|
2290
|
+
finalizeElementOnClose(popped, closeEndIdx);
|
|
2291
|
+
}
|
|
2292
|
+
break;
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
i = closeEndIdx;
|
|
2296
|
+
continue;
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
// 5. Opening tag <tagName ...>
|
|
2301
|
+
const tagMatch = /^<([A-Za-z][A-Za-z0-9:_-]*)/.exec(html.slice(i));
|
|
2302
|
+
if (!tagMatch) {
|
|
2303
|
+
appendTextToOpenBadges('<');
|
|
2304
|
+
i++;
|
|
2305
|
+
continue;
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
const tagStartOffset = i;
|
|
2309
|
+
const rawTagName = tagMatch[1];
|
|
2310
|
+
const lowerTag = rawTagName.toLowerCase();
|
|
2311
|
+
const { attrs, endIdx: tagEndOffset, selfClosing } = parseHtmlTagAttributes(
|
|
2312
|
+
html,
|
|
2313
|
+
i + tagMatch[0].length,
|
|
2314
|
+
);
|
|
2315
|
+
|
|
2316
|
+
// unsafe-html: forbidden embedded frame/object tags (<iframe>, <object>, <embed>)
|
|
2317
|
+
if (FORBIDDEN_EMBED_TAGS.has(lowerTag)) {
|
|
2318
|
+
violations.push({
|
|
2319
|
+
rule: 'unsafe-html',
|
|
2320
|
+
file: filePath,
|
|
2321
|
+
line: lineNumberAt(html, tagStartOffset),
|
|
2322
|
+
column: columnNumberAt(html, tagStartOffset),
|
|
2323
|
+
message: `Embedded <${lowerTag}> element is forbidden in self-contained generated HTML.`,
|
|
2324
|
+
snippet: html.slice(tagStartOffset, tagEndOffset),
|
|
2325
|
+
});
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
// Check attributes on this element
|
|
2329
|
+
let idAttr = null;
|
|
2330
|
+
let hasDataF12Tokens = false;
|
|
2331
|
+
let dataCutAttr = null;
|
|
2332
|
+
let hasDataAutocut = false;
|
|
2333
|
+
let classAttr = null;
|
|
2334
|
+
|
|
2335
|
+
for (const attr of attrs) {
|
|
2336
|
+
if (attr.lowerName === 'id') idAttr = attr;
|
|
2337
|
+
if (attr.lowerName === 'data-f12-tokens' && attr.value.toLowerCase() !== 'false') {
|
|
2338
|
+
hasDataF12Tokens = true;
|
|
2339
|
+
}
|
|
2340
|
+
if (attr.lowerName === 'data-cut') dataCutAttr = attr;
|
|
2341
|
+
if (attr.lowerName === 'data-autocut' && attr.value.toLowerCase() !== 'false') {
|
|
2342
|
+
hasDataAutocut = true;
|
|
2343
|
+
}
|
|
2344
|
+
if (attr.lowerName === 'class') classAttr = attr;
|
|
2345
|
+
|
|
2346
|
+
// unsafe-html: inline event handlers (on*=)
|
|
2347
|
+
if (/^on[a-z]+$/i.test(attr.lowerName)) {
|
|
2348
|
+
violations.push({
|
|
2349
|
+
rule: 'unsafe-html',
|
|
2350
|
+
file: filePath,
|
|
2351
|
+
line: lineNumberAt(html, attr.nameOffset),
|
|
2352
|
+
column: columnNumberAt(html, attr.nameOffset),
|
|
2353
|
+
message: `Inline event handler attribute "${attr.name}" is forbidden in generated HTML.`,
|
|
2354
|
+
snippet: attr.raw,
|
|
2355
|
+
});
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
// unsafe-html: srcdoc attribute
|
|
2359
|
+
if (attr.lowerName === 'srcdoc') {
|
|
2360
|
+
violations.push({
|
|
2361
|
+
rule: 'unsafe-html',
|
|
2362
|
+
file: filePath,
|
|
2363
|
+
line: lineNumberAt(html, attr.nameOffset),
|
|
2364
|
+
column: columnNumberAt(html, attr.nameOffset),
|
|
2365
|
+
message: 'Inline "srcdoc" attribute is forbidden in generated HTML.',
|
|
2366
|
+
snippet: attr.raw,
|
|
2367
|
+
});
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
// unsafe-html: <meta http-equiv="refresh">
|
|
2371
|
+
if (
|
|
2372
|
+
lowerTag === 'meta' &&
|
|
2373
|
+
attr.lowerName === 'http-equiv' &&
|
|
2374
|
+
attr.value.trim().toLowerCase() === 'refresh'
|
|
2375
|
+
) {
|
|
2376
|
+
violations.push({
|
|
2377
|
+
rule: 'unsafe-html',
|
|
2378
|
+
file: filePath,
|
|
2379
|
+
line: lineNumberAt(html, attr.nameOffset),
|
|
2380
|
+
column: columnNumberAt(html, attr.nameOffset),
|
|
2381
|
+
message: '<meta http-equiv="refresh"> redirect is forbidden in generated HTML.',
|
|
2382
|
+
snippet: attr.raw,
|
|
2383
|
+
});
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
// unsafe-html: <base href="...">
|
|
2387
|
+
if (lowerTag === 'base' && attr.lowerName === 'href') {
|
|
2388
|
+
violations.push({
|
|
2389
|
+
rule: 'unsafe-html',
|
|
2390
|
+
file: filePath,
|
|
2391
|
+
line: lineNumberAt(html, attr.nameOffset),
|
|
2392
|
+
column: columnNumberAt(html, attr.nameOffset),
|
|
2393
|
+
message: '<base href="…"> is forbidden in self-contained generated HTML.',
|
|
2394
|
+
snippet: attr.raw,
|
|
2395
|
+
});
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
// unsafe-html: javascript: URLs and external subresource loads
|
|
2399
|
+
if (attr.hasValue && attr.value) {
|
|
2400
|
+
const normalizedUrl = normalizeUrlValueForSchemeCheck(attr.value);
|
|
2401
|
+
if (/^(?:javascript|vbscript):/i.test(normalizedUrl)) {
|
|
2402
|
+
violations.push({
|
|
2403
|
+
rule: 'unsafe-html',
|
|
2404
|
+
file: filePath,
|
|
2405
|
+
line: lineNumberAt(html, attr.nameOffset),
|
|
2406
|
+
column: columnNumberAt(html, attr.nameOffset),
|
|
2407
|
+
message: `Unsafe "javascript:" URL in "${attr.name}" attribute is forbidden in generated HTML.`,
|
|
2408
|
+
snippet: attr.raw,
|
|
2409
|
+
});
|
|
2410
|
+
} else if (
|
|
2411
|
+
EXTERNAL_SUBRESOURCE_TAGS.has(lowerTag) &&
|
|
2412
|
+
EXTERNAL_SUBRESOURCE_ATTRS.has(attr.lowerName)
|
|
2413
|
+
) {
|
|
2414
|
+
const hasExternalOrigin =
|
|
2415
|
+
/^(?:https?:)?\/\//i.test(normalizedUrl) ||
|
|
2416
|
+
(attr.lowerName === 'srcset' && /(?:^|[\s,])(?:https?:)?\/\//i.test(attr.value));
|
|
2417
|
+
const isExternalStylesheetLink =
|
|
2418
|
+
lowerTag === 'link' &&
|
|
2419
|
+
attrs.some(
|
|
2420
|
+
(a) =>
|
|
2421
|
+
a.lowerName === 'rel' &&
|
|
2422
|
+
a.value.toLowerCase().split(/\s+/).includes('stylesheet'),
|
|
2423
|
+
);
|
|
2424
|
+
if (hasExternalOrigin || isExternalStylesheetLink) {
|
|
2425
|
+
violations.push({
|
|
2426
|
+
rule: 'unsafe-html',
|
|
2427
|
+
file: filePath,
|
|
2428
|
+
line: lineNumberAt(html, attr.nameOffset),
|
|
2429
|
+
column: columnNumberAt(html, attr.nameOffset),
|
|
2430
|
+
message: `External resource load <${lowerTag} ${attr.name}="${attr.value}"> is forbidden in self-contained generated HTML.`,
|
|
2431
|
+
snippet: attr.raw,
|
|
2432
|
+
});
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
// Inline style="..." attribute
|
|
2438
|
+
if (attr.lowerName === 'style' && attr.hasValue) {
|
|
2439
|
+
lintCssInHtml(attr.value, attr.valueOffset, html, filePath, violations, true);
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
// Presentation attributes stroke / fill / color
|
|
2443
|
+
if (
|
|
2444
|
+
(attr.lowerName === 'stroke' ||
|
|
2445
|
+
attr.lowerName === 'fill' ||
|
|
2446
|
+
attr.lowerName === 'color') &&
|
|
2447
|
+
attr.hasValue
|
|
2448
|
+
) {
|
|
2449
|
+
if (/(?:--f-marker(?!-ink)|#ffd60a)/i.test(attr.value)) {
|
|
2450
|
+
violations.push({
|
|
2451
|
+
rule: 'marker-misuse',
|
|
2452
|
+
file: filePath,
|
|
2453
|
+
line: lineNumberAt(html, attr.nameOffset),
|
|
2454
|
+
column: columnNumberAt(html, attr.nameOffset),
|
|
2455
|
+
message: `Marker yellow cannot be used as an SVG/HTML "${attr.name}" attribute. Use <Tag>, <Badge>, or <Balloon>.`,
|
|
2456
|
+
snippet: attr.raw,
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2459
|
+
const hexRe = /#[0-9a-fA-F]{3,8}\b/g;
|
|
2460
|
+
let hm;
|
|
2461
|
+
while ((hm = hexRe.exec(attr.value)) !== null) {
|
|
2462
|
+
const hex = hm[0];
|
|
2463
|
+
if (![4, 5, 7, 9].includes(hex.length)) continue;
|
|
2464
|
+
const before = attr.value.slice(Math.max(0, hm.index - 12), hm.index);
|
|
2465
|
+
if (/url\(\s*['"]?$/i.test(before)) continue;
|
|
2466
|
+
const absIdx = attr.valueOffset + hm.index;
|
|
2467
|
+
violations.push({
|
|
2468
|
+
rule: 'color-literal',
|
|
2469
|
+
file: filePath,
|
|
2470
|
+
line: lineNumberAt(html, absIdx),
|
|
2471
|
+
column: columnNumberAt(html, absIdx),
|
|
2472
|
+
message: `Hard-coded hex color "${hex}" is forbidden outside token files. Use a var(--f-*) token.`,
|
|
2473
|
+
snippet: hex,
|
|
2474
|
+
});
|
|
2475
|
+
}
|
|
2476
|
+
const fnRe = /\b(?:rgb|rgba|hsl|hsla|oklch|oklab)\s*\([^)]*\)/gi;
|
|
2477
|
+
while ((hm = fnRe.exec(attr.value)) !== null) {
|
|
2478
|
+
const absIdx = attr.valueOffset + hm.index;
|
|
2479
|
+
violations.push({
|
|
2480
|
+
rule: 'color-literal',
|
|
2481
|
+
file: filePath,
|
|
2482
|
+
line: lineNumberAt(html, absIdx),
|
|
2483
|
+
column: columnNumberAt(html, absIdx),
|
|
2484
|
+
message: `Hard-coded color function "${hm[0]}" is forbidden outside token files. Use a var(--f-*) token.`,
|
|
2485
|
+
snippet: hm[0],
|
|
2486
|
+
});
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
// Check class="..." attribute for Tailwind illegal-gutter or marker-misuse utilities
|
|
2492
|
+
let classTokens = [];
|
|
2493
|
+
if (classAttr && classAttr.hasValue) {
|
|
2494
|
+
classTokens = classAttr.value.split(/\s+/).filter(Boolean);
|
|
2495
|
+
for (const tok of classTokens) {
|
|
2496
|
+
if (/^gap-(?:x-|y-)?/.test(tok) && !LEGAL_TAILWIND_GAP_CLASSES.has(tok)) {
|
|
2497
|
+
const tokIdx = classAttr.value.indexOf(tok);
|
|
2498
|
+
const absIdx = classAttr.valueOffset + (tokIdx >= 0 ? tokIdx : 0);
|
|
2499
|
+
violations.push({
|
|
2500
|
+
rule: 'illegal-gutter',
|
|
2501
|
+
file: filePath,
|
|
2502
|
+
line: lineNumberAt(html, absIdx),
|
|
2503
|
+
column: columnNumberAt(html, absIdx),
|
|
2504
|
+
message: `Illegal Tailwind gutter class "${tok}". Use Scene gutter="pair|scene|change" or 12px/20px/32px tokens.`,
|
|
2505
|
+
snippet: tok,
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
2508
|
+
if (
|
|
2509
|
+
/^(?:text|border|stroke|fill|ring|outline)-(?:f-marker\b|\[[^\]]*(?:--f-marker(?!-ink)|#ffd60a)[^\]]*\])/.test(
|
|
2510
|
+
tok,
|
|
2511
|
+
)
|
|
2512
|
+
) {
|
|
2513
|
+
const tokIdx = classAttr.value.indexOf(tok);
|
|
2514
|
+
const absIdx = classAttr.valueOffset + (tokIdx >= 0 ? tokIdx : 0);
|
|
2515
|
+
violations.push({
|
|
2516
|
+
rule: 'marker-misuse',
|
|
2517
|
+
file: filePath,
|
|
2518
|
+
line: lineNumberAt(html, absIdx),
|
|
2519
|
+
column: columnNumberAt(html, absIdx),
|
|
2520
|
+
message: `Marker yellow utility "${tok}" is forbidden. Marker yellow may only be used as a background paired with --f-marker-ink text.`,
|
|
2521
|
+
snippet: tok,
|
|
2522
|
+
});
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
// Handle <script> tags
|
|
2528
|
+
if (lowerTag === 'script') {
|
|
2529
|
+
const srcAttr = attrs.find((a) => a.lowerName === 'src');
|
|
2530
|
+
if (srcAttr) {
|
|
2531
|
+
violations.push({
|
|
2532
|
+
rule: 'unsafe-html',
|
|
2533
|
+
file: filePath,
|
|
2534
|
+
line: lineNumberAt(html, srcAttr.nameOffset),
|
|
2535
|
+
column: columnNumberAt(html, srcAttr.nameOffset),
|
|
2536
|
+
message: `External <script src="${srcAttr.value}"> is forbidden in generated HTML. Only inline <script> without src is allowed.`,
|
|
2537
|
+
snippet: srcAttr.raw,
|
|
2538
|
+
});
|
|
2539
|
+
}
|
|
2540
|
+
if (!selfClosing) {
|
|
2541
|
+
const closeScriptRe = /<\/\s*script\s*>/gi;
|
|
2542
|
+
closeScriptRe.lastIndex = tagEndOffset;
|
|
2543
|
+
const closeMatch = closeScriptRe.exec(html);
|
|
2544
|
+
i = closeMatch ? closeMatch.index + closeMatch[0].length : len;
|
|
2545
|
+
} else {
|
|
2546
|
+
i = tagEndOffset;
|
|
2547
|
+
}
|
|
2548
|
+
continue;
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
// Handle <style> tags
|
|
2552
|
+
if (lowerTag === 'style') {
|
|
2553
|
+
let styleContent = '';
|
|
2554
|
+
const contentStartOffset = tagEndOffset;
|
|
2555
|
+
if (!selfClosing) {
|
|
2556
|
+
const closeStyleRe = /<\/\s*style\s*>/gi;
|
|
2557
|
+
closeStyleRe.lastIndex = tagEndOffset;
|
|
2558
|
+
const closeMatch = closeStyleRe.exec(html);
|
|
2559
|
+
if (closeMatch) {
|
|
2560
|
+
styleContent = html.slice(contentStartOffset, closeMatch.index);
|
|
2561
|
+
i = closeMatch.index + closeMatch[0].length;
|
|
2562
|
+
} else {
|
|
2563
|
+
styleContent = html.slice(contentStartOffset);
|
|
2564
|
+
i = len;
|
|
2565
|
+
}
|
|
2566
|
+
} else {
|
|
2567
|
+
i = tagEndOffset;
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
const isExemptTokenStyle =
|
|
2571
|
+
hasDataF12Tokens || (idAttr && idAttr.hasValue && tokenStyleIds.has(idAttr.value));
|
|
2572
|
+
if (!isExemptTokenStyle && styleContent) {
|
|
2573
|
+
lintCssInHtml(styleContent, contentStartOffset, html, filePath, violations, false);
|
|
2574
|
+
}
|
|
2575
|
+
continue;
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
// Track data-cut and .f12-cut in DOM reading order
|
|
2579
|
+
let explicitDataCut = null;
|
|
2580
|
+
if (dataCutAttr && dataCutAttr.hasValue) {
|
|
2581
|
+
const val = dataCutAttr.value.trim();
|
|
2582
|
+
if (val !== '' && val.toLowerCase() !== 'auto') {
|
|
2583
|
+
explicitDataCut = val;
|
|
2584
|
+
pendingCuts.push({
|
|
2585
|
+
raw: val,
|
|
2586
|
+
line: lineNumberAt(html, dataCutAttr.nameOffset),
|
|
2587
|
+
column: columnNumberAt(html, dataCutAttr.nameOffset),
|
|
2588
|
+
snippet: dataCutAttr.raw,
|
|
2589
|
+
ignore: false,
|
|
2590
|
+
});
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
const isCutBadge =
|
|
2595
|
+
!dataCutAttr && !hasDataAutocut && classTokens.includes('f12-cut');
|
|
2596
|
+
|
|
2597
|
+
let ancestorWithDataCut = null;
|
|
2598
|
+
if (isCutBadge) {
|
|
2599
|
+
for (let s = openStack.length - 1; s >= 0; s--) {
|
|
2600
|
+
if (openStack[s].explicitDataCut) {
|
|
2601
|
+
ancestorWithDataCut = openStack[s];
|
|
2602
|
+
break;
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
let cutRecord = null;
|
|
2608
|
+
if (isCutBadge) {
|
|
2609
|
+
cutRecord = {
|
|
2610
|
+
raw: '',
|
|
2611
|
+
line: lineNumberAt(html, tagStartOffset),
|
|
2612
|
+
column: columnNumberAt(html, tagStartOffset),
|
|
2613
|
+
snippet: html.slice(tagStartOffset, tagEndOffset),
|
|
2614
|
+
ignore: false,
|
|
2615
|
+
};
|
|
2616
|
+
pendingCuts.push(cutRecord);
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
const isVoid = selfClosing || HTML_VOID_ELEMENTS.has(lowerTag);
|
|
2620
|
+
if (!isVoid) {
|
|
2621
|
+
openStack.push({
|
|
2622
|
+
tagName: lowerTag,
|
|
2623
|
+
tagStartOffset,
|
|
2624
|
+
explicitDataCut,
|
|
2625
|
+
consumedChildCut: false,
|
|
2626
|
+
isCutBadge,
|
|
2627
|
+
textBuffer: '',
|
|
2628
|
+
cutRecord,
|
|
2629
|
+
ancestorWithDataCut,
|
|
2630
|
+
});
|
|
2631
|
+
} else if (isCutBadge && cutRecord) {
|
|
2632
|
+
cutRecord.ignore = true;
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
i = tagEndOffset;
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
// Finalize any unclosed tags at EOF
|
|
2639
|
+
while (openStack.length > 0) {
|
|
2640
|
+
const popped = openStack.pop();
|
|
2641
|
+
finalizeElementOnClose(popped, len);
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
// Validate collected cut entries
|
|
2645
|
+
const validCutMatches = [];
|
|
2646
|
+
for (const c of pendingCuts) {
|
|
2647
|
+
if (c.ignore) continue;
|
|
2648
|
+
const parsed = /^C-(\d{2,})$/.exec(c.raw);
|
|
2649
|
+
if (!parsed) {
|
|
2650
|
+
violations.push({
|
|
2651
|
+
rule: 'cut-order',
|
|
2652
|
+
file: filePath,
|
|
2653
|
+
line: c.line,
|
|
2654
|
+
column: c.column,
|
|
2655
|
+
message: `Malformed cut label "${c.raw}". Cut numbers must use two-digit format "C-01", "C-02", etc.`,
|
|
2656
|
+
snippet: c.snippet,
|
|
2657
|
+
});
|
|
2658
|
+
} else {
|
|
2659
|
+
validCutMatches.push({
|
|
2660
|
+
raw: c.raw,
|
|
2661
|
+
num: parseInt(parsed[1], 10),
|
|
2662
|
+
line: c.line,
|
|
2663
|
+
column: c.column,
|
|
2664
|
+
snippet: c.snippet,
|
|
2665
|
+
});
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
validateCutSequence(validCutMatches, filePath, violations);
|
|
2670
|
+
|
|
2671
|
+
return violations;
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
/**
|
|
2675
|
+
* Recursively collects lintable files under the given paths.
|
|
2676
|
+
*/
|
|
2677
|
+
export function collectFiles(targetPaths) {
|
|
2678
|
+
const files = [];
|
|
2679
|
+
const walk = (p) => {
|
|
2680
|
+
if (!existsSync(p)) return;
|
|
2681
|
+
const st = statSync(p);
|
|
2682
|
+
if (st.isDirectory()) {
|
|
2683
|
+
const name = basename(p);
|
|
2684
|
+
if (IGNORED_DIRS.has(name)) return;
|
|
2685
|
+
for (const entry of readdirSync(p)) {
|
|
2686
|
+
walk(resolve(p, entry));
|
|
2687
|
+
}
|
|
2688
|
+
} else if (st.isFile() && CODE_EXTENSIONS.has(extname(p))) {
|
|
2689
|
+
files.push(p);
|
|
2690
|
+
}
|
|
2691
|
+
};
|
|
2692
|
+
for (const p of targetPaths) {
|
|
2693
|
+
walk(resolve(p));
|
|
2694
|
+
}
|
|
2695
|
+
return files.sort();
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
export function lintFiles(targetPaths) {
|
|
2699
|
+
const files = collectFiles(targetPaths);
|
|
2700
|
+
const allViolations = [];
|
|
2701
|
+
for (const f of files) {
|
|
2702
|
+
const content = readFileSync(f, 'utf8');
|
|
2703
|
+
const v = lintSource(content, f);
|
|
2704
|
+
allViolations.push(...v);
|
|
2705
|
+
}
|
|
2706
|
+
return { files, violations: allViolations };
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
export const lintDirectory = lintFiles;
|
|
2710
|
+
|
|
2711
|
+
/**
|
|
2712
|
+
* Runs the CLI checker and returns the exit code (0 on pass, 1 on violations or missing files).
|
|
2713
|
+
*/
|
|
2714
|
+
export function runCli(argv = process.argv.slice(2), cwd = process.cwd()) {
|
|
2715
|
+
const defaultTargets = [
|
|
2716
|
+
'registry/ui',
|
|
2717
|
+
'registry/hooks',
|
|
2718
|
+
'eval/screens',
|
|
2719
|
+
'site/src',
|
|
2720
|
+
'site/app',
|
|
2721
|
+
].filter((p) => existsSync(resolve(cwd, p)));
|
|
2722
|
+
const rawTargets = argv.length > 0 ? argv : defaultTargets;
|
|
2723
|
+
const targets = rawTargets.map((p) => resolve(cwd, p));
|
|
2724
|
+
|
|
2725
|
+
if (targets.length === 0) {
|
|
2726
|
+
console.error(
|
|
2727
|
+
'FAIL check-frame12: No lintable files (.tsx, .ts, .jsx, .js, .css, .html) found. Pass one or more files or directories to check.',
|
|
2728
|
+
);
|
|
2729
|
+
return 1;
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
const { files, violations } = lintFiles(targets);
|
|
2733
|
+
|
|
2734
|
+
if (files.length === 0) {
|
|
2735
|
+
console.error(
|
|
2736
|
+
'FAIL check-frame12: No lintable files (.tsx, .ts, .jsx, .js, .css, .html) found. Pass one or more files or directories to check.',
|
|
2737
|
+
);
|
|
2738
|
+
return 1;
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
if (violations.length === 0) {
|
|
2742
|
+
console.log(`PASS check-frame12: ${files.length} file(s) checked, 0 violations.`);
|
|
2743
|
+
return 0;
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
for (const v of violations) {
|
|
2747
|
+
const rel = relative(cwd, v.file) || v.file;
|
|
2748
|
+
console.error(`FAIL [${v.rule}] ${rel}:${v.line} — ${v.message}`);
|
|
2749
|
+
if (v.snippet) {
|
|
2750
|
+
console.error(` > ${v.snippet}`);
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
console.error(`\n${violations.length} FRAME/12 violation(s) found across ${files.length} file(s).`);
|
|
2754
|
+
return 1;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
function safeRealpath(p) {
|
|
2758
|
+
try {
|
|
2759
|
+
return realpathSync(p);
|
|
2760
|
+
} catch {
|
|
2761
|
+
return resolve(p);
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
const isMain =
|
|
2766
|
+
process.argv[1] &&
|
|
2767
|
+
safeRealpath(fileURLToPath(import.meta.url)) === safeRealpath(process.argv[1]);
|
|
2768
|
+
|
|
2769
|
+
if (isMain) {
|
|
2770
|
+
process.exit(runCli());
|
|
2771
|
+
}
|