@satinhq/lint 0.0.0-stage → 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,595 @@
1
+ import { rules } from './rules.mjs'
2
+
3
+ const VIEWPORT_HEIGHT_UNITS = new Set(['vh', 'dvh', 'svh', 'lvh'])
4
+ const SAFE_AREA_INSETS = new Set([
5
+ 'safe-area-inset-top',
6
+ 'safe-area-inset-right',
7
+ 'safe-area-inset-bottom',
8
+ 'safe-area-inset-left',
9
+ ])
10
+ const SCROLLING_VALUES = new Set(['auto', 'scroll'])
11
+ const CSS_OVERFLOW_PROPERTIES = new Set(['overflow', 'overflow-x', 'overflow-y'])
12
+ const INLINE_OVERFLOW_PROPERTIES = new Set(['overflow', 'overflowx', 'overflowy'])
13
+
14
+ function isWhitespace(character) {
15
+ return (
16
+ character === ' ' ||
17
+ character === '\n' ||
18
+ character === '\r' ||
19
+ character === '\t' ||
20
+ character === '\f'
21
+ )
22
+ }
23
+
24
+ function isDigit(character) {
25
+ return character !== undefined && character >= '0' && character <= '9'
26
+ }
27
+
28
+ function isNameStart(character) {
29
+ if (character === undefined) return false
30
+ const code = character.codePointAt(0) ?? 0
31
+ return (
32
+ character === '-' ||
33
+ character === '_' ||
34
+ (character >= 'a' && character <= 'z') ||
35
+ (character >= 'A' && character <= 'Z') ||
36
+ code >= 0x80
37
+ )
38
+ }
39
+
40
+ function isNameCharacter(character) {
41
+ return isNameStart(character) || isDigit(character)
42
+ }
43
+
44
+ function isNumberStart(source, index) {
45
+ const character = source[index]
46
+ const next = source[index + 1]
47
+ return isDigit(character) || (character === '.' && isDigit(next))
48
+ }
49
+
50
+ function scanNumber(source, start) {
51
+ let index = start
52
+ while (isDigit(source[index])) index += 1
53
+
54
+ if (source[index] === '.' && isDigit(source[index + 1])) {
55
+ index += 1
56
+ while (isDigit(source[index])) index += 1
57
+ }
58
+
59
+ if (source[index] === 'e' || source[index] === 'E') {
60
+ let exponent = index + 1
61
+ if (source[exponent] === '+' || source[exponent] === '-') exponent += 1
62
+ if (isDigit(source[exponent])) {
63
+ index = exponent + 1
64
+ while (isDigit(source[index])) index += 1
65
+ }
66
+ }
67
+
68
+ return index
69
+ }
70
+
71
+ function scanName(source, start) {
72
+ let index = start
73
+ while (isNameCharacter(source[index])) index += 1
74
+ return index
75
+ }
76
+
77
+ function token(kind, source, start, end, extra = {}) {
78
+ return {
79
+ kind,
80
+ start,
81
+ end,
82
+ text: source.slice(start, end),
83
+ lower: source.slice(start, end).toLowerCase(),
84
+ ...extra,
85
+ }
86
+ }
87
+
88
+ export function tokenizeCss(source) {
89
+ const tokens = []
90
+ let index = 0
91
+
92
+ while (index < source.length) {
93
+ const character = source[index]
94
+ const next = source[index + 1]
95
+
96
+ if (isWhitespace(character)) {
97
+ index += 1
98
+ continue
99
+ }
100
+
101
+ if (character === '/' && next === '*') {
102
+ index += 2
103
+ while (index < source.length && !(source[index] === '*' && source[index + 1] === '/')) {
104
+ index += 1
105
+ }
106
+ index = Math.min(source.length, index + 2)
107
+ continue
108
+ }
109
+
110
+ if (character === '"' || character === "'") {
111
+ const start = index
112
+ const quote = character
113
+ index += 1
114
+ while (index < source.length) {
115
+ if (source[index] === '\\') {
116
+ index += Math.min(2, source.length - index)
117
+ continue
118
+ }
119
+ const current = source[index]
120
+ index += 1
121
+ if (current === quote) break
122
+ }
123
+ tokens.push(token('string', source, start, index))
124
+ continue
125
+ }
126
+
127
+ if (isNumberStart(source, index)) {
128
+ const start = index
129
+ const numberEnd = scanNumber(source, index)
130
+ index = numberEnd
131
+ if (isNameStart(source[index])) {
132
+ index = scanName(source, index)
133
+ tokens.push(
134
+ token('dimension', source, start, index, {
135
+ unit: source.slice(numberEnd, index).toLowerCase(),
136
+ }),
137
+ )
138
+ } else {
139
+ tokens.push(token('number', source, start, index))
140
+ }
141
+ continue
142
+ }
143
+
144
+ if (isNameStart(character)) {
145
+ const start = index
146
+ index = scanName(source, index)
147
+ tokens.push(token('identifier', source, start, index))
148
+ continue
149
+ }
150
+
151
+ tokens.push(token('symbol', source, index, index + 1))
152
+ index += 1
153
+ }
154
+
155
+ return tokens
156
+ }
157
+
158
+ function rawValueSignals(tokens) {
159
+ const viewportHeight = []
160
+ const safeAreaEnv = []
161
+ const privateVariables = []
162
+
163
+ for (let index = 0; index < tokens.length; index += 1) {
164
+ const current = tokens[index]
165
+ if (
166
+ current.kind === 'identifier' &&
167
+ current.lower.startsWith('--satin-') &&
168
+ tokens[index - 1]?.text === '(' &&
169
+ tokens[index - 2]?.lower === 'var'
170
+ ) {
171
+ privateVariables.push(current.start)
172
+ }
173
+ if (current.kind === 'dimension' && VIEWPORT_HEIGHT_UNITS.has(current.unit)) {
174
+ viewportHeight.push(current.start)
175
+ }
176
+
177
+ if (
178
+ current.kind === 'identifier' &&
179
+ current.lower === 'env' &&
180
+ tokens[index + 1]?.text === '(' &&
181
+ tokens[index + 2]?.kind === 'identifier' &&
182
+ SAFE_AREA_INSETS.has(tokens[index + 2].lower)
183
+ ) {
184
+ safeAreaEnv.push(current.start)
185
+ }
186
+ }
187
+
188
+ return { viewportHeight, safeAreaEnv, privateVariables }
189
+ }
190
+
191
+ function declarationsFromTokens(tokens, { allowRoot = false } = {}) {
192
+ const declarations = []
193
+ const blockStack = []
194
+
195
+ for (let index = 0; index < tokens.length; index += 1) {
196
+ const current = tokens[index]
197
+
198
+ if (current.text === '{') {
199
+ blockStack.push(current.start)
200
+ continue
201
+ }
202
+ if (current.text === '}') {
203
+ blockStack.pop()
204
+ continue
205
+ }
206
+ if (current.kind !== 'identifier' || tokens[index + 1]?.text !== ':') continue
207
+ if (blockStack.length === 0 && !allowRoot) continue
208
+
209
+ const previous = tokens[index - 1]
210
+ const atDeclarationStart =
211
+ previous === undefined || previous.text === '{' || previous.text === ';'
212
+ if (!atDeclarationStart) continue
213
+
214
+ const valueTokens = []
215
+ let parentheses = 0
216
+ let brackets = 0
217
+ let valid = true
218
+
219
+ for (let cursor = index + 2; cursor < tokens.length; cursor += 1) {
220
+ const valueToken = tokens[cursor]
221
+ if (valueToken.text === '(') parentheses += 1
222
+ if (valueToken.text === ')') parentheses = Math.max(0, parentheses - 1)
223
+ if (valueToken.text === '[') brackets += 1
224
+ if (valueToken.text === ']') brackets = Math.max(0, brackets - 1)
225
+
226
+ if (parentheses === 0 && brackets === 0) {
227
+ if (valueToken.text === '{') {
228
+ valid = false
229
+ break
230
+ }
231
+ if (valueToken.text === ';' || valueToken.text === '}') break
232
+ }
233
+ valueTokens.push(valueToken)
234
+ }
235
+
236
+ if (!valid) continue
237
+ declarations.push({
238
+ property: current.lower,
239
+ propertyOffset: current.start,
240
+ valueTokens,
241
+ block: blockStack.at(-1) ?? -1,
242
+ })
243
+ }
244
+
245
+ return declarations
246
+ }
247
+
248
+ export function hasCustomLinearEasing(source) {
249
+ const tokens = tokenizeCss(source)
250
+ return tokens.some(
251
+ (token, index) =>
252
+ token.kind === 'identifier' && token.lower === 'linear' && tokens[index + 1]?.text === '(',
253
+ )
254
+ }
255
+
256
+ function customEasingDeclaration(declaration) {
257
+ return (
258
+ ['animation', 'animation-timing-function', 'transition', 'transition-timing-function'].includes(
259
+ declaration.property,
260
+ ) &&
261
+ declaration.valueTokens.some(
262
+ (token, index) =>
263
+ token.kind === 'identifier' &&
264
+ token.lower === 'linear' &&
265
+ declaration.valueTokens[index + 1]?.text === '(',
266
+ )
267
+ )
268
+ }
269
+
270
+ function firstIdentifier(declaration) {
271
+ return declaration.valueTokens.find(valueToken => valueToken.kind === 'identifier')?.lower
272
+ }
273
+
274
+ function hasScrollingValue(declaration) {
275
+ return declaration.valueTokens.some(
276
+ valueToken => valueToken.kind === 'identifier' && SCROLLING_VALUES.has(valueToken.lower),
277
+ )
278
+ }
279
+
280
+ function usesBottomInset(declaration) {
281
+ return declaration.valueTokens.some(
282
+ valueToken => valueToken.kind === 'identifier' && valueToken.lower === '--satin-inset-bottom',
283
+ )
284
+ }
285
+
286
+ function paddingPriority(valueTokens) {
287
+ const important =
288
+ valueTokens.at(-2)?.text === '!' &&
289
+ valueTokens.at(-1)?.kind === 'identifier' &&
290
+ valueTokens.at(-1)?.lower === 'important'
291
+ return {
292
+ important,
293
+ valueTokens: important ? valueTokens.slice(0, -2) : valueTokens,
294
+ }
295
+ }
296
+
297
+ function shorthandBottomPadding(valueTokens) {
298
+ const components = []
299
+ let depth = 0
300
+ let previousToken
301
+
302
+ for (const valueToken of valueTokens) {
303
+ // Whitespace is omitted by the tokenizer. Source gaps separate top-level values;
304
+ // tokens inside functions stay together, including calc() operators and spaces.
305
+ if (!previousToken || (depth === 0 && valueToken.start > previousToken.end)) {
306
+ components.push([])
307
+ }
308
+ components.at(-1).push(valueToken)
309
+ if (valueToken.text === '(' || valueToken.text === '[') depth += 1
310
+ if (valueToken.text === ')' || valueToken.text === ']') depth -= 1
311
+ previousToken = valueToken
312
+ }
313
+
314
+ if (depth !== 0 || components.length === 0 || components.length > 4) return []
315
+ return components.length < 3 ? components[0] : components[2]
316
+ }
317
+
318
+ function winningBottomPadding(declarations) {
319
+ let winner
320
+
321
+ for (const declaration of declarations) {
322
+ if (declaration.property !== 'padding' && declaration.property !== 'padding-bottom') continue
323
+ const candidate = paddingPriority(declaration.valueTokens)
324
+ if (winner?.important && !candidate.important) continue
325
+ winner = {
326
+ important: candidate.important,
327
+ valueTokens:
328
+ declaration.property === 'padding'
329
+ ? shorthandBottomPadding(candidate.valueTokens)
330
+ : candidate.valueTokens,
331
+ }
332
+ }
333
+
334
+ return winner
335
+ }
336
+
337
+ function violation(rule, offset) {
338
+ return { ruleId: rule.id, message: rule.message, offset }
339
+ }
340
+
341
+ function rawValueViolations(source, baseOffset = 0) {
342
+ const signals = rawValueSignals(tokenizeCss(source))
343
+ return [
344
+ ...signals.privateVariables.map(offset =>
345
+ violation(rules['no-private-css-variable'], baseOffset + offset),
346
+ ),
347
+ ...signals.viewportHeight.map(offset =>
348
+ violation(rules['no-raw-viewport-height'], baseOffset + offset),
349
+ ),
350
+ ...signals.safeAreaEnv.map(offset =>
351
+ violation(rules['no-raw-safe-area-env'], baseOffset + offset),
352
+ ),
353
+ ]
354
+ }
355
+
356
+ function activeMenuSelectors(tokens) {
357
+ const scopes = [],
358
+ violations = []
359
+ let prelude = []
360
+ for (const current of tokens) {
361
+ if (current.text === '{') {
362
+ const selector = prelude.map(t => t.lower).join('')
363
+ const menu =
364
+ scopes.at(-1) ||
365
+ /\[role=(["']?)(?:menu|menuitem|menuitemcheckbox|menuitemradio)\1\]/.test(selector)
366
+ const active = prelude.find(
367
+ (t, i) => t.kind === 'identifier' && t.lower === 'active' && prelude[i - 1]?.text === ':',
368
+ )
369
+ if (menu && active) violations.push(violation(rules['no-native-menu-active'], active.start))
370
+ scopes.push(menu)
371
+ prelude = []
372
+ } else if (current.text === '}') {
373
+ scopes.pop()
374
+ prelude = []
375
+ } else if (current.text === ';') prelude = []
376
+ else prelude.push(current)
377
+ }
378
+ return violations
379
+ }
380
+
381
+ export function analyzeCss(source) {
382
+ const tokens = tokenizeCss(source)
383
+ const declarations = declarationsFromTokens(tokens)
384
+ const signals = rawValueSignals(tokens)
385
+ const violations = [
386
+ ...tokens
387
+ .filter(token => token.kind !== 'string' && token.lower === 'prefers-color-scheme')
388
+ .map(token => violation(rules['no-raw-appearance-query'], token.start)),
389
+ ...activeMenuSelectors(tokens),
390
+ ...signals.privateVariables.map(offset => violation(rules['no-private-css-variable'], offset)),
391
+ ...signals.viewportHeight.map(offset => violation(rules['no-raw-viewport-height'], offset)),
392
+ ...signals.safeAreaEnv.map(offset => violation(rules['no-raw-safe-area-env'], offset)),
393
+ ]
394
+
395
+ for (const declaration of declarations) {
396
+ if (
397
+ ['backdrop-filter', '-webkit-backdrop-filter'].includes(declaration.property) &&
398
+ !declaration.valueTokens.every(token => token.lower === 'none' || token.kind === 'whitespace')
399
+ )
400
+ violations.push(violation(rules['no-handrolled-glass'], declaration.propertyOffset))
401
+ const selector = source.slice(
402
+ Math.max(
403
+ source.lastIndexOf('}', declaration.block),
404
+ source.lastIndexOf('{', declaration.block - 1),
405
+ ) + 1,
406
+ declaration.block,
407
+ )
408
+ const documentRoot = String.raw`(?:html|body|:root|#root)(?:[.#][\w-]+|\[[^\]]*\]|:[\w-]+)*`
409
+ const globalElement = `(?:${documentRoot}|\\*(?::[\\w-]+)*)`
410
+ const globalPattern = new RegExp(`^${globalElement}(?:\\s+${globalElement})*$`, 'i')
411
+ const globalSelector = selector
412
+ .replace(/\/\*[\s\S]*?\*\//g, '')
413
+ .split(',')
414
+ .some(part => globalPattern.test(part.trim()))
415
+ if (globalSelector && ['user-select', '-webkit-user-select'].includes(declaration.property))
416
+ violations.push(violation(rules['no-global-selection-reset'], declaration.propertyOffset))
417
+ if (
418
+ globalSelector &&
419
+ ['outline', 'outline-style', 'outline-width'].includes(declaration.property) &&
420
+ (firstIdentifier(declaration) === 'none' ||
421
+ (['number', 'dimension'].includes(declaration.valueTokens[0]?.kind) &&
422
+ parseFloat(declaration.valueTokens[0].text) === 0))
423
+ )
424
+ violations.push(violation(rules['no-global-focus-reset'], declaration.propertyOffset))
425
+ if (
426
+ globalSelector &&
427
+ declaration.property === 'position' &&
428
+ firstIdentifier(declaration) === 'fixed'
429
+ )
430
+ violations.push(violation(rules['no-fixed-document'], declaration.propertyOffset))
431
+ const selectorTokens = tokenizeCss(selector)
432
+ const targetsInput =
433
+ selectorTokens.some(
434
+ (t, i) =>
435
+ ['input', 'textarea'].includes(t.lower) &&
436
+ !['.', '#', ':'].includes(selectorTokens[i - 1]?.text),
437
+ ) || /\[data-satin-text-(field|editor)/.test(selector)
438
+ if (
439
+ targetsInput &&
440
+ declaration.property === 'font-size' &&
441
+ declaration.valueTokens.some(
442
+ t => t.kind === 'dimension' && t.unit === 'px' && parseFloat(t.text) < 16,
443
+ )
444
+ )
445
+ violations.push(violation(rules['no-small-input-font'], declaration.propertyOffset))
446
+
447
+ if (customEasingDeclaration(declaration))
448
+ violations.push(violation(rules['no-custom-linear-easing'], declaration.propertyOffset))
449
+ if (
450
+ declaration.property === 'zoom' ||
451
+ (declaration.property === 'touch-action' &&
452
+ declaration.valueTokens.some(token => token.lower === 'pinch-zoom'))
453
+ )
454
+ violations.push(violation(rules['no-page-zoom'], declaration.propertyOffset))
455
+ if (CSS_OVERFLOW_PROPERTIES.has(declaration.property) && hasScrollingValue(declaration)) {
456
+ violations.push(violation(rules['no-raw-css-overflow-scroll'], declaration.propertyOffset))
457
+ }
458
+ if (declaration.property === '-webkit-overflow-scrolling') {
459
+ violations.push(
460
+ violation(rules['no-webkit-overflow-scrolling-css'], declaration.propertyOffset),
461
+ )
462
+ }
463
+ }
464
+
465
+ const declarationsByBlock = new Map()
466
+ for (const declaration of declarations) {
467
+ const blockDeclarations = declarationsByBlock.get(declaration.block) ?? []
468
+ blockDeclarations.push(declaration)
469
+ declarationsByBlock.set(declaration.block, blockDeclarations)
470
+ }
471
+ for (const blockDeclarations of declarationsByBlock.values()) {
472
+ const position = blockDeclarations.find(
473
+ declaration =>
474
+ declaration.property === 'position' &&
475
+ (firstIdentifier(declaration) === 'absolute' || firstIdentifier(declaration) === 'fixed'),
476
+ )
477
+ if (!position) continue
478
+
479
+ const hasLeft = blockDeclarations.some(
480
+ declaration =>
481
+ declaration.property === 'left' || declaration.property === 'inset-inline-start',
482
+ )
483
+ const hasRight = blockDeclarations.some(
484
+ declaration =>
485
+ declaration.property === 'right' || declaration.property === 'inset-inline-end',
486
+ )
487
+ if (!hasLeft || !hasRight) continue
488
+
489
+ const bottomDeclarations = blockDeclarations.filter(
490
+ declaration => declaration.property === 'bottom',
491
+ )
492
+ const bottomPadding = winningBottomPadding(blockDeclarations)
493
+ if (
494
+ bottomDeclarations.length > 0 &&
495
+ !bottomDeclarations.some(declaration => usesBottomInset(declaration)) &&
496
+ !(bottomPadding && usesBottomInset(bottomPadding))
497
+ ) {
498
+ violations.push(
499
+ violation(rules['require-bottom-safe-area-inset'], bottomDeclarations[0].propertyOffset),
500
+ )
501
+ }
502
+ }
503
+
504
+ return violations
505
+ }
506
+
507
+ export function analyzeInlineCssText(source, baseOffset = 0) {
508
+ const tokens = tokenizeCss(source)
509
+ const declarations = declarationsFromTokens(tokens, { allowRoot: true })
510
+ const violations = rawValueViolations(source, baseOffset)
511
+
512
+ for (const declaration of declarations) {
513
+ if (
514
+ ['backdrop-filter', '-webkit-backdrop-filter'].includes(declaration.property) &&
515
+ !declaration.valueTokens.every(token => token.lower === 'none' || token.kind === 'whitespace')
516
+ )
517
+ violations.push(violation(rules['no-handrolled-glass'], declaration.propertyOffset))
518
+ if (customEasingDeclaration(declaration))
519
+ violations.push(
520
+ violation(rules['no-custom-linear-easing'], baseOffset + declaration.propertyOffset),
521
+ )
522
+ if (
523
+ declaration.property === 'zoom' ||
524
+ (declaration.property === 'touch-action' &&
525
+ declaration.valueTokens.some(token => token.lower === 'pinch-zoom'))
526
+ )
527
+ violations.push(violation(rules['no-page-zoom'], declaration.propertyOffset))
528
+ const property = declaration.property.replaceAll('-', '')
529
+ if (INLINE_OVERFLOW_PROPERTIES.has(property) && hasScrollingValue(declaration)) {
530
+ violations.push(
531
+ violation(rules['no-raw-inline-overflow-scroll'], baseOffset + declaration.propertyOffset),
532
+ )
533
+ }
534
+ if (property === 'webkitoverflowscrolling') {
535
+ violations.push(
536
+ violation(
537
+ rules['no-webkit-overflow-scrolling-inline'],
538
+ baseOffset + declaration.propertyOffset,
539
+ ),
540
+ )
541
+ }
542
+ }
543
+
544
+ return violations
545
+ }
546
+
547
+ export function analyzeCssTextFragment(source, baseOffset = 0) {
548
+ const tokens = tokenizeCss(source)
549
+ const declarations = declarationsFromTokens(tokens, { allowRoot: true })
550
+ const violations = rawValueViolations(source, baseOffset)
551
+ violations.push(
552
+ ...activeMenuSelectors(tokens).map(item => ({ ...item, offset: item.offset + baseOffset })),
553
+ )
554
+
555
+ for (const declaration of declarations) {
556
+ if (
557
+ ['backdrop-filter', '-webkit-backdrop-filter'].includes(declaration.property) &&
558
+ !declaration.valueTokens.every(token => token.lower === 'none' || token.kind === 'whitespace')
559
+ )
560
+ violations.push(violation(rules['no-handrolled-glass'], declaration.propertyOffset))
561
+ if (customEasingDeclaration(declaration))
562
+ violations.push(
563
+ violation(rules['no-custom-linear-easing'], baseOffset + declaration.propertyOffset),
564
+ )
565
+ if (
566
+ declaration.property === 'zoom' ||
567
+ (declaration.property === 'touch-action' &&
568
+ declaration.valueTokens.some(token => token.lower === 'pinch-zoom'))
569
+ )
570
+ violations.push(violation(rules['no-page-zoom'], declaration.propertyOffset))
571
+ if (CSS_OVERFLOW_PROPERTIES.has(declaration.property) && hasScrollingValue(declaration)) {
572
+ violations.push(
573
+ violation(rules['no-raw-css-overflow-scroll'], baseOffset + declaration.propertyOffset),
574
+ )
575
+ }
576
+ if (declaration.property === '-webkit-overflow-scrolling') {
577
+ violations.push(
578
+ violation(
579
+ rules['no-webkit-overflow-scrolling-css'],
580
+ baseOffset + declaration.propertyOffset,
581
+ ),
582
+ )
583
+ }
584
+ }
585
+
586
+ return violations
587
+ }
588
+
589
+ export function analyzeInlineStyleValue(source, baseOffset = 0) {
590
+ return rawValueViolations(source, baseOffset)
591
+ }
592
+
593
+ export function isScrollingInlineValue(source) {
594
+ return SCROLLING_VALUES.has(source.trim().toLowerCase())
595
+ }
@@ -0,0 +1,49 @@
1
+ import { rules } from './rules.mjs'
2
+ import { analyzeCss } from './css-analysis.mjs'
3
+ import { analyzeTypeScript } from './typescript-analysis.mjs'
4
+
5
+ /** HTML inputs to a definition-owned build must leave head policy to the plugin. */
6
+ export function analyzeHtml(source) {
7
+ const clean = source.replace(/<!--[\s\S]*?-->/g, match => ' '.repeat(match.length))
8
+ const violations = []
9
+ const tags = /<(link|meta|title)\b[^>]*>/gi
10
+ for (const match of clean.matchAll(tags)) {
11
+ const attrs = Object.fromEntries(
12
+ [...match[0].matchAll(/([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g)].map(a => [
13
+ a[1].toLowerCase(),
14
+ (a[2] ?? a[3] ?? a[4]).toLowerCase(),
15
+ ]),
16
+ )
17
+ if (
18
+ match[1].toLowerCase() === 'title' ||
19
+ ['manifest', 'apple-touch-icon', 'apple-touch-startup-image'].includes(attrs.rel) ||
20
+ /^(theme-color|viewport|color-scheme|apple-mobile-web-app.*)$/.test(attrs.name ?? '')
21
+ ) {
22
+ const rule = rules['no-handwritten-app-head']
23
+ violations.push({ ruleId: rule.id, message: rule.message, offset: match.index })
24
+ }
25
+ }
26
+ const inlineBlocks = /<(script|style)\b((?:"[^"]*"|'[^']*'|[^'">])*)>([\s\S]*?)<\/\1\s*>/gi
27
+ for (const match of clean.matchAll(inlineBlocks)) {
28
+ const kind = match[1].toLowerCase()
29
+ const attributes = new Map()
30
+ const attributePattern = /([^\s=/'"<>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g
31
+ for (const attribute of match[2].matchAll(attributePattern)) {
32
+ const name = attribute[1].toLowerCase()
33
+ if (!attributes.has(name))
34
+ attributes.set(name, attribute[2] ?? attribute[3] ?? attribute[4] ?? '')
35
+ }
36
+ const mediaType = (attributes.get('type') ?? '').toLowerCase()
37
+ if (kind === 'script' && attributes.has('src')) continue
38
+ if (
39
+ kind === 'script' &&
40
+ !['', 'module', 'text/javascript', 'application/javascript'].includes(mediaType)
41
+ )
42
+ continue
43
+ if (kind === 'style' && !['', 'text/css'].includes(mediaType)) continue
44
+ const contentOffset = match.index + match[1].length + match[2].length + 2
45
+ const found = kind === 'style' ? analyzeCss(match[3]) : analyzeTypeScript('inline.js', match[3])
46
+ violations.push(...found.map(item => ({ ...item, offset: contentOffset + item.offset })))
47
+ }
48
+ return violations
49
+ }
@@ -0,0 +1,59 @@
1
+ // The pinned TS 7 native API owns parsing. Decoded ASTs remain usable after the
2
+ // snapshot/server closes; no compiler process or snapshot escapes this helper.
3
+ import * as ast from 'typescript/unstable/ast'
4
+ import { API } from 'typescript/unstable/sync'
5
+ import { createVirtualFileSystem } from 'typescript/unstable/fs'
6
+ import { basename } from 'node:path'
7
+
8
+ export const syntax = {
9
+ ...ast,
10
+ forEachChild: (node, visitor) => node.forEachChild(visitor),
11
+ isTypeAssertionExpression: ast.isTypeAssertion,
12
+ isParameter: ast.isParameterDeclaration,
13
+ isStringLiteralLike: node =>
14
+ ast.isStringLiteral(node) || ast.isNoSubstitutionTemplateLiteral(node),
15
+ isFunctionLike: node =>
16
+ [
17
+ ast.SyntaxKind.FunctionDeclaration,
18
+ ast.SyntaxKind.FunctionExpression,
19
+ ast.SyntaxKind.ArrowFunction,
20
+ ast.SyntaxKind.MethodDeclaration,
21
+ ast.SyntaxKind.GetAccessor,
22
+ ast.SyntaxKind.SetAccessor,
23
+ ast.SyntaxKind.Constructor,
24
+ ].includes(node.kind),
25
+ }
26
+
27
+ export function parseSourceFile(fileName, source) {
28
+ const path = `/satin-syntax/${basename(fileName)}`
29
+ const config = '/satin-syntax/tsconfig.json'
30
+ const api = new API({
31
+ cwd: '/satin-syntax',
32
+ fs: createVirtualFileSystem({
33
+ [config]: JSON.stringify({
34
+ compilerOptions: {
35
+ noLib: true,
36
+ noResolve: true,
37
+ allowJs: true,
38
+ jsx: 'preserve',
39
+ target: 'esnext',
40
+ },
41
+ files: [path],
42
+ }),
43
+ [path]: source,
44
+ }),
45
+ })
46
+ let snapshot
47
+ try {
48
+ snapshot = api.updateSnapshot({ openProjects: [config] })
49
+ const program = snapshot.getProject(config).program
50
+ const file = program.getSourceFile(path)
51
+ if (!file) throw new Error(`TS 7 could not parse ${fileName}`)
52
+ // Preserve the caller's diagnostic identity, independent of the virtual path.
53
+ Object.defineProperty(file, 'fileName', { value: fileName })
54
+ return { file, diagnostics: program.getSyntacticDiagnostics(path) }
55
+ } finally {
56
+ snapshot?.dispose()
57
+ api.close()
58
+ }
59
+ }