@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.
- package/LICENSE +21 -0
- package/README.md +104 -2
- package/oxlint.json +100 -0
- package/package.json +39 -3
- package/scripts/check-fidelity.mjs +28 -0
- package/scripts/check-modal-lifecycle.mjs +49 -0
- package/scripts/check-sidebar-artwork.mjs +20 -0
- package/scripts/check-webgpu-worker.mjs +144 -0
- package/src/checker.mjs +71 -0
- package/src/css-analysis.mjs +595 -0
- package/src/html-analysis.mjs +49 -0
- package/src/native-typescript.mjs +59 -0
- package/src/rules.mjs +276 -0
- package/src/typescript-analysis.mjs +1461 -0
|
@@ -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
|
+
}
|