@heyramzi/lint 1.34.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -5
- package/bin/heyramzi-comment-length.js +5 -6
- package/bin/heyramzi-file-tokens.js +2 -2
- package/bin/heyramzi-old-names.js +72 -0
- package/bin/heyramzi-push-diff.js +16 -5
- package/bin/heyramzi-slop.js +3 -1
- package/bin/heyramzi-ui-rules.js +18 -9
- package/comment-length.js +18 -12
- package/config/oxlintrc.json +3 -4
- package/data/jev-code-bench.js +5 -5
- package/data/slop/artifacts.js +87 -0
- package/data/slop/pattern-parts.js +56 -0
- package/data/slop/patterns/appositives-and-sayings.js +81 -0
- package/data/slop/patterns/comma-clauses.js +106 -0
- package/data/slop/patterns/diagram-and-script-tells.js +71 -0
- package/data/slop/patterns/dropped-elements.js +149 -0
- package/data/slop/patterns/duration-and-openers.js +102 -0
- package/data/slop/patterns/headings-and-laws.js +116 -0
- package/data/slop/patterns/numbers-and-runs.js +94 -0
- package/data/slop/patterns/openers-and-tails.js +90 -0
- package/data/slop/patterns/parallel-shapes.js +100 -0
- package/data/slop/patterns/reveals-and-tallies.js +101 -0
- package/data/slop/patterns/setups-and-closers.js +83 -0
- package/data/slop/patterns/subject-voice.js +67 -0
- package/data/slop/patterns/vague-and-flourish.js +102 -0
- package/data/slop/patterns/wikipedia-signs.js +103 -0
- package/data/slop/patterns.js +62 -0
- package/data/slop/phrases-en-closers.js +66 -0
- package/data/slop/phrases-en-core.js +185 -0
- package/data/slop/phrases-en-wikipedia.js +136 -0
- package/data/slop/phrases-fr.js +152 -0
- package/data/slop/shapes-density.js +81 -0
- package/data/slop/shapes-framing.js +101 -0
- package/data/slop/shapes-reveals.js +88 -0
- package/data/slop/title-tells.js +8 -0
- package/data/slop/words-en.js +178 -0
- package/data/slop/words-fr.js +101 -0
- package/data/slop-words.js +18 -2576
- package/data/vendor/heading-words.js +6 -0
- package/file-tokens.js +26 -13
- package/index.js +4 -6
- package/inline-path.js +2 -0
- package/jev-code.js +1 -1
- package/old-names-mask.js +206 -0
- package/old-names.js +145 -0
- package/package.json +8 -2
- package/push-diff.js +2 -2
- package/repo-rules/packages.js +5 -5
- package/repo-rules.js +2 -2
- package/rules/convex-index-name.js +3 -1
- package/rules/convex-internal-only.js +2 -1
- package/rules/{convex-no-deprecated-crons.js → convex-no-hourly-daily-crons.js} +8 -7
- package/rules/convex-runtime-imports.js +2 -1
- package/rules/{no-legacy-compat.js → no-underscore-reexport.js} +1 -1
- package/slop.js +4 -2
- package/ui-rules-css.js +221 -0
- package/ui-rules-markup.js +130 -0
- package/ui-rules.js +98 -25
- package/vendor/old-names-words.js +9 -0
- package/rules/no-relative-type-imports.js +0 -40
|
@@ -41,7 +41,8 @@ export default {
|
|
|
41
41
|
CallExpression(node) {
|
|
42
42
|
if (!useNode) return;
|
|
43
43
|
const name = registerName(node);
|
|
44
|
-
if (name && NODE_ONLY.has(name))
|
|
44
|
+
if (name && NODE_ONLY.has(name))
|
|
45
|
+
context.report({ node, messageId: "actionsOnly", data: { name } });
|
|
45
46
|
},
|
|
46
47
|
};
|
|
47
48
|
},
|
package/slop.js
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
/*
|
|
38
|
-
* Both lists are imported, never restated: `data/invisibles.js` and `data/slop
|
|
38
|
+
* Both lists are imported, never restated: `data/invisibles.js` and `data/slop/` are the one home for each.
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
41
|
import {
|
|
@@ -46,7 +46,9 @@ import {
|
|
|
46
46
|
invisibleKind,
|
|
47
47
|
isInvisible,
|
|
48
48
|
} from "#lint/data/invisibles.js";
|
|
49
|
-
import {
|
|
49
|
+
import { phrases, words } from "#lint/data/slop-words.js";
|
|
50
|
+
import { artifacts } from "#lint/data/slop/artifacts.js";
|
|
51
|
+
import { patterns } from "#lint/data/slop/patterns.js";
|
|
50
52
|
import { auditRegister } from "#lint/register.js";
|
|
51
53
|
import {
|
|
52
54
|
FAQ_TITLE,
|
package/ui-rules-css.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CSS and Tailwind half of the UI-rules gate: will-change, press scale, keyframe toggles and typography.
|
|
3
|
+
* Pure. `ui-rules.js` calls it with comment-stripped text and parsed CSS rules.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const CSS_MESSAGES = {
|
|
7
|
+
"will-change-all":
|
|
8
|
+
"Name the property: `will-change: transform` or `opacity`. `all` puts every property on its own layer and eats memory. Better still, drop it until you've seen the first frame stutter.",
|
|
9
|
+
"will-change-layout":
|
|
10
|
+
"will-change only helps transform, opacity and filter. On width, height, top, margin or padding it does nothing and costs a layer. Animate transform instead and drop the hint.",
|
|
11
|
+
"press-scale-floor":
|
|
12
|
+
"Press feedback scales to 0.95 or higher: `active:scale-95` or `scale(0.97)`. Lower than that and the control looks like it's collapsing.",
|
|
13
|
+
"keyframe-toggle":
|
|
14
|
+
"Use a transition on the state change. A keyframe animation on hover or open restarts from zero when the state flips back, so a quick second tap jumps. A transition reverses from where it is.",
|
|
15
|
+
"light-small-text":
|
|
16
|
+
"Use font-normal (400) or heavier at text-base and below. Thin strokes break up on small text.",
|
|
17
|
+
"line-height-unit":
|
|
18
|
+
"Write line-height with no unit: `line-height: 1.5` or `leading-[1.5]`. A px or rem value stays put when the font size changes.",
|
|
19
|
+
"tracking-px":
|
|
20
|
+
"Write letter-spacing in em: `letter-spacing: -0.01em` or `tracking-[0.02em]`. It then scales with the font size.",
|
|
21
|
+
"raw-font-tags":
|
|
22
|
+
"Use the named property: font-weight for wght, font-optical-sizing for opsz, `font-variant-numeric: tabular-nums` (Tailwind `tabular-nums`) for tnum, `slashed-zero` for zero. Raw tags override one another and the named ones.",
|
|
23
|
+
"balance-paragraph":
|
|
24
|
+
"Balance is for headings. Use `text-pretty` on a paragraph: it stops a lone last word without reshaping every line.",
|
|
25
|
+
"text-justify":
|
|
26
|
+
"Left-align the text. Justified text without hyphenation opens gaps between words that read as rivers.",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const LAYOUT_PROPERTY =
|
|
30
|
+
/^(?:width|height|(?:min|max)-(?:width|height)|top|right|bottom|left|inset(?:-[\w-]+)?|margin(?:-[\w-]+)?|padding(?:-[\w-]+)?|gap|(?:row|column)-gap|flex(?:-[\w-]+)?|grid(?:-[\w-]+)?|font-size|line-height|letter-spacing|border(?:-[\w-]*width)?|display|position)$/;
|
|
31
|
+
|
|
32
|
+
const NUMBER = String.raw`(?:0*\.\d+|[1-9]\d*(?:\.\d+)?)`;
|
|
33
|
+
const SMALL_TEXT_CLASS = /^(?:[\w[\]=&>*:-]+:)*text-(?:xs|sm|base)(?:\/\S+)?$/;
|
|
34
|
+
const LIGHT_CLASS = /^(?:[\w[\]=&>*:-]+:)*font-(?:thin|extralight|light)$/;
|
|
35
|
+
|
|
36
|
+
function lineOf(source, index) {
|
|
37
|
+
let line = 1;
|
|
38
|
+
for (let i = 0; i < index; i++) if (source.charCodeAt(i) === 10) line++;
|
|
39
|
+
return line;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True when a press scale (a factor like 0.9, or a percent like 90) sits under 0.95 and above 0. */
|
|
43
|
+
function underFloor(raw) {
|
|
44
|
+
const value = raw.trim();
|
|
45
|
+
if (value.endsWith("%")) {
|
|
46
|
+
const percent = Number.parseFloat(value);
|
|
47
|
+
return percent > 0 && percent < 95;
|
|
48
|
+
}
|
|
49
|
+
const factor = Number.parseFloat(value);
|
|
50
|
+
return factor > 0 && factor < 0.95;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Blank `<code>` and `<pre>` bodies, so a doc page that shows a bad class isn't flagged. */
|
|
54
|
+
export function blankCode(source) {
|
|
55
|
+
return source.replace(/<(code|pre)\b[\s\S]*?<\/\1>/g, (m) => m.replace(/[^\n]/g, " "));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function willChange(source, hits) {
|
|
59
|
+
const add = (match, rule) =>
|
|
60
|
+
hits.push({ rule, line: lineOf(source, match.index), snippet: match[0].trim().slice(0, 80) });
|
|
61
|
+
const values = [
|
|
62
|
+
...source.matchAll(/(?<![\w-])will-change\s*:\s*([^;}"'`\n]+)/g),
|
|
63
|
+
...source.matchAll(/(?<![\w-])will-change-\[([^\]\n]+)\]/g),
|
|
64
|
+
];
|
|
65
|
+
for (const match of values) {
|
|
66
|
+
const names = match[1]
|
|
67
|
+
.replaceAll("_", " ")
|
|
68
|
+
.split(/[,\s]+/)
|
|
69
|
+
.map((n) => n.trim().toLowerCase())
|
|
70
|
+
.filter(Boolean);
|
|
71
|
+
if (names.includes("all")) add(match, "will-change-all");
|
|
72
|
+
else if (names.some((n) => LAYOUT_PROPERTY.test(n))) add(match, "will-change-layout");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function pressScale(source, hits) {
|
|
77
|
+
const pattern =
|
|
78
|
+
/(?<![\w-])(?:(?:group|peer)-)?active:(?:[\w[\]=&>*-]+:)*scale-(\d+|\[[^\]\s]+\])/g;
|
|
79
|
+
for (const match of source.matchAll(pattern)) {
|
|
80
|
+
const raw = match[1].replace(/^\[|\]$/g, "");
|
|
81
|
+
const factor = /^\d+$/.test(match[1]) ? `${raw}%` : raw;
|
|
82
|
+
if (underFloor(factor))
|
|
83
|
+
hits.push({
|
|
84
|
+
rule: "press-scale-floor",
|
|
85
|
+
line: lineOf(source, match.index),
|
|
86
|
+
snippet: match[0].slice(0, 80),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function keyframeToggle(source, hits) {
|
|
92
|
+
const state = String.raw`(?:(?:group|peer)-)?(?:hover|open|expanded|checked)|data-\[state=(?:open|checked|on|active|selected)\]|aria-(?:expanded|pressed|checked)`;
|
|
93
|
+
const pattern = new RegExp(
|
|
94
|
+
String.raw`(?<![\w-])(?:${state}):(?:[\w[\]=&>*-]+:)*animate-(?!in\b|out\b|none\b)[\w[\]-]+`,
|
|
95
|
+
"g",
|
|
96
|
+
);
|
|
97
|
+
for (const match of source.matchAll(pattern))
|
|
98
|
+
hits.push({
|
|
99
|
+
rule: "keyframe-toggle",
|
|
100
|
+
line: lineOf(source, match.index),
|
|
101
|
+
snippet: match[0].slice(0, 80),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function typography(source, hits, declarations) {
|
|
106
|
+
const line = (rule, declared, utility) => {
|
|
107
|
+
const pattern = new RegExp(declarations ? `${declared}|${utility}` : utility, "g");
|
|
108
|
+
for (const match of source.matchAll(pattern))
|
|
109
|
+
hits.push({ rule, line: lineOf(source, match.index), snippet: match[0].trim().slice(0, 80) });
|
|
110
|
+
};
|
|
111
|
+
line(
|
|
112
|
+
"line-height-unit",
|
|
113
|
+
String.raw`(?<![\w-])line-height\s*:\s*-?${NUMBER}(?:px|rem)\b`,
|
|
114
|
+
String.raw`(?<![\w-])leading-\[-?${NUMBER}(?:px|rem)\]|(?<![\w-])text-(?:xs|sm|base|lg|xl|[2-9]xl|\[[^\]\s]+\])/\[${NUMBER}(?:px|rem)\]`,
|
|
115
|
+
);
|
|
116
|
+
line(
|
|
117
|
+
"tracking-px",
|
|
118
|
+
String.raw`(?<![\w-])letter-spacing\s*:\s*-?${NUMBER}(?:px|rem)\b`,
|
|
119
|
+
String.raw`(?<![\w-])tracking-\[-?${NUMBER}(?:px|rem)\]`,
|
|
120
|
+
);
|
|
121
|
+
line(
|
|
122
|
+
"raw-font-tags",
|
|
123
|
+
String.raw`(?<![\w-])font-variation-settings\s*:[^;}\]\n]*["']\s*(?:wght|opsz)\b|(?<![\w-])font-feature-settings\s*:[^;}\]\n]*["']\s*(?:tnum|zero)\b`,
|
|
124
|
+
String.raw`\[font-(?:variation|feature)-settings:[^\]]*(?:wght|opsz|tnum|zero)\b[^\]]*\]`,
|
|
125
|
+
);
|
|
126
|
+
line(
|
|
127
|
+
"text-justify",
|
|
128
|
+
String.raw`(?<![\w-])text-align(?:-last)?\s*:\s*justify\b`,
|
|
129
|
+
String.raw`(?<![\w-])text-justify(?![\w-])`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function lightSmallText(source, hits) {
|
|
134
|
+
const seen = new Set();
|
|
135
|
+
for (const pattern of [/"([^"]{1,400})"/g, /'([^'\n]{1,400})'/g, /`([^`]{1,400})`/g]) {
|
|
136
|
+
for (const match of source.matchAll(pattern)) {
|
|
137
|
+
const tokens = match[1].split(/\s+/);
|
|
138
|
+
if (!tokens.some((t) => LIGHT_CLASS.test(t)) || !tokens.some((t) => SMALL_TEXT_CLASS.test(t)))
|
|
139
|
+
continue;
|
|
140
|
+
const line = lineOf(source, match.index);
|
|
141
|
+
if (seen.has(line)) continue;
|
|
142
|
+
seen.add(line);
|
|
143
|
+
hits.push({ rule: "light-small-text", line, snippet: match[1].trim().slice(0, 80) });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Every text-level CSS and Tailwind rule on one comment-stripped source. */
|
|
149
|
+
export function cssTextRules(source, { declarations = true } = {}) {
|
|
150
|
+
const hits = [];
|
|
151
|
+
willChange(source, hits);
|
|
152
|
+
pressScale(source, hits);
|
|
153
|
+
keyframeToggle(source, hits);
|
|
154
|
+
typography(source, hits, declarations);
|
|
155
|
+
lightSmallText(source, hits);
|
|
156
|
+
return hits;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const SMALL_FONT =
|
|
160
|
+
/(?<![\w-])font-size\s*:\s*(?:(\d*\.?\d+)(px|rem)|var\(\s*--text-(?:xs|sm|base)\b)/;
|
|
161
|
+
const LIGHT_WEIGHT = /(?<![\w-])font-weight\s*:\s*(?:[123]00|thin|extra-?light|light|lighter)\b/;
|
|
162
|
+
const STATE_SELECTOR =
|
|
163
|
+
/:hover\b|\[open\]|\[data-state=["']?(?:open|checked|on|active|selected)|\[aria-(?:expanded|pressed|checked)=["']?true|\.(?:is-)?open\b|:checked\b|:popover-open\b/;
|
|
164
|
+
|
|
165
|
+
/** The rules that read one CSS rule's selector against its body. `rules` is `parseRules` output. */
|
|
166
|
+
export function cssBlockRules(rules, offset) {
|
|
167
|
+
const hits = [];
|
|
168
|
+
for (const rule of rules) {
|
|
169
|
+
if (rule.selector.startsWith("@")) continue;
|
|
170
|
+
const line = rule.line + offset;
|
|
171
|
+
const selectors = rule.selector.split(",").map((s) => s.trim());
|
|
172
|
+
if (
|
|
173
|
+
selectors.some((s) => /:active\b/.test(s)) &&
|
|
174
|
+
/(?<![\w-])(?:transform\s*:[^;}]*\bscale\(\s*([\d.]+)|scale\s*:\s*([\d.]+))/.test(rule.body)
|
|
175
|
+
) {
|
|
176
|
+
const match =
|
|
177
|
+
/(?<![\w-])(?:transform\s*:[^;}]*\bscale\(\s*([\d.]+)|scale\s*:\s*([\d.]+))/.exec(
|
|
178
|
+
rule.body,
|
|
179
|
+
);
|
|
180
|
+
if (underFloor(match[1] ?? match[2]))
|
|
181
|
+
hits.push({
|
|
182
|
+
rule: "press-scale-floor",
|
|
183
|
+
line,
|
|
184
|
+
snippet: `${rule.selector} { scale ${match[1] ?? match[2]} }`.slice(0, 80),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
if (
|
|
188
|
+
STATE_SELECTOR.test(rule.selector) &&
|
|
189
|
+
!/\\:animate-/.test(rule.selector) &&
|
|
190
|
+
/(?<![\w-])animation(?:-name)?\s*:(?!\s*none\b)(?![^;}]*(?:collapsible|accordion))[^;}]*[\w-]/.test(
|
|
191
|
+
rule.body,
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
hits.push({
|
|
195
|
+
rule: "keyframe-toggle",
|
|
196
|
+
line,
|
|
197
|
+
snippet: `${rule.selector} { animation }`.slice(0, 80),
|
|
198
|
+
});
|
|
199
|
+
if (
|
|
200
|
+
selectors.some((s) => /(?:^|[\s>+~])(?:p|li)$/.test(s)) &&
|
|
201
|
+
/(?<![\w-])text-wrap(?:-style)?\s*:\s*balance\b/.test(rule.body)
|
|
202
|
+
)
|
|
203
|
+
hits.push({
|
|
204
|
+
rule: "balance-paragraph",
|
|
205
|
+
line,
|
|
206
|
+
snippet: `${rule.selector} { text-wrap: balance }`,
|
|
207
|
+
});
|
|
208
|
+
const size = SMALL_FONT.exec(rule.body);
|
|
209
|
+
if (size && LIGHT_WEIGHT.test(rule.body)) {
|
|
210
|
+
const small =
|
|
211
|
+
size[1] === undefined || (size[2] === "px" ? Number(size[1]) <= 16 : Number(size[1]) <= 1);
|
|
212
|
+
if (small)
|
|
213
|
+
hits.push({
|
|
214
|
+
rule: "light-small-text",
|
|
215
|
+
line,
|
|
216
|
+
snippet: `${rule.selector} { font-weight light, small font-size }`.slice(0, 80),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return hits;
|
|
221
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The markup half of the UI-rules gate: the accessibility and form checks that need a tag's attributes.
|
|
3
|
+
* Pure. `ui-rules.js` calls it with a .svelte or .html source.
|
|
4
|
+
*/
|
|
5
|
+
import { scanTemplate } from "#lint/svelte-template.js";
|
|
6
|
+
|
|
7
|
+
export const MARKUP_MESSAGES = {
|
|
8
|
+
"positive-tabindex":
|
|
9
|
+
'Use tabindex="0" or "-1", never above 0. A positive value jumps ahead of the whole page in tab order. Fix the order of the markup instead.',
|
|
10
|
+
"div-onclick":
|
|
11
|
+
'Use a <button> (or an <a href> for a link). A <div> with onclick can\'t be focused and ignores Enter and Space. A click-away backdrop that keyboard users close with Escape can take role="presentation".',
|
|
12
|
+
"role-no-keys":
|
|
13
|
+
'Use a real <button>: it takes Enter, Space and focus for free, and works with role="tab" too. A div with role="button" or "tab" needs tabindex and an onkeydown that handles Enter and Space.',
|
|
14
|
+
"img-no-alt": 'Add alt. Write what the image shows, or alt="" when it only decorates the page.',
|
|
15
|
+
"aria-hidden-focusable":
|
|
16
|
+
"Don't hide what the keyboard can reach. Remove aria-hidden, or add tabindex=\"-1\" (or inert) so focus can't land on it.",
|
|
17
|
+
"input-autocomplete":
|
|
18
|
+
'Add autocomplete: "email", "tel", "current-password" or "new-password". Use "off" when you type someone else\'s details. Password managers and phones fill from it.',
|
|
19
|
+
"locale-less-date":
|
|
20
|
+
"Pass the locale: `date.toLocaleDateString(locale, options)`, or use `Intl.DateTimeFormat(locale, options)`. With no locale the browser picks one, and the page and the server disagree.",
|
|
21
|
+
"block-paste":
|
|
22
|
+
"Let people paste. Remove the handler that cancels it: confirm-email and password-manager users paste on purpose.",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const KEY_ATTR = /^(?:on(?:key(?:down|up|press))|on:key(?:down|up|press)(?:\|.*)?)$/i;
|
|
26
|
+
const CLICK_ATTR = /^(?:onclick|on:click(?:\|.*)?|\{onclick\})$/i;
|
|
27
|
+
|
|
28
|
+
const attrOf = (tag, pattern) => tag.attrs.find((a) => pattern.test(a.name));
|
|
29
|
+
const hasSpread = (tag) => tag.attrs.some((a) => a.name.startsWith("{..."));
|
|
30
|
+
const hasAction = (tag) =>
|
|
31
|
+
tag.attrs.some((a) => a.name.startsWith("use:") || a.name.startsWith("{@attach"));
|
|
32
|
+
const literal = (attr) =>
|
|
33
|
+
attr?.value === null || attr === undefined
|
|
34
|
+
? undefined
|
|
35
|
+
: attr.value.replace(/^\{\s*["'`]?|["'`]?\s*\}$/g, "").trim();
|
|
36
|
+
const isTrue = (attr) => attr !== undefined && /^(?:true)?$/.test(literal(attr) ?? "");
|
|
37
|
+
|
|
38
|
+
const NATIVE_KEYBOARD = new Set(["button", "input", "select", "textarea", "summary", "option"]);
|
|
39
|
+
const regular = (tag) => /^[a-z][a-z0-9]*$/.test(tag.name);
|
|
40
|
+
|
|
41
|
+
function tabindexOf(tag) {
|
|
42
|
+
const value = literal(attrOf(tag, /^tabindex$/i));
|
|
43
|
+
return value === undefined ? undefined : Number(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The tag-level hits in one .svelte or .html source. */
|
|
47
|
+
export function tagRules(source) {
|
|
48
|
+
const { tags } = scanTemplate(source);
|
|
49
|
+
const hits = [];
|
|
50
|
+
const add = (rule, tag, snippet) =>
|
|
51
|
+
hits.push({ rule, line: tag.line, snippet: snippet.slice(0, 80) });
|
|
52
|
+
const tablistKeys = tags.some(
|
|
53
|
+
(t) => literal(attrOf(t, /^role$/)) === "tablist" && attrOf(t, KEY_ATTR),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
for (const tag of tags) {
|
|
57
|
+
if (tag.closing) continue;
|
|
58
|
+
const role = literal(attrOf(tag, /^role$/));
|
|
59
|
+
|
|
60
|
+
if (/^(?:div|span)$/.test(tag.name) && attrOf(tag, CLICK_ATTR) && role === undefined) {
|
|
61
|
+
if (!isTrue(attrOf(tag, /^aria-hidden$/))) add("div-onclick", tag, `<${tag.name} onclick>`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (
|
|
65
|
+
regular(tag) &&
|
|
66
|
+
!NATIVE_KEYBOARD.has(tag.name) &&
|
|
67
|
+
!(tag.name === "a" && attrOf(tag, /^href$/)) &&
|
|
68
|
+
(role === "button" || (role === "tab" && !tablistKeys)) &&
|
|
69
|
+
attrOf(tag, CLICK_ATTR) &&
|
|
70
|
+
!attrOf(tag, KEY_ATTR) &&
|
|
71
|
+
!hasSpread(tag) &&
|
|
72
|
+
!hasAction(tag)
|
|
73
|
+
)
|
|
74
|
+
add("role-no-keys", tag, `<${tag.name} role="${role}" onclick> with no key handler`);
|
|
75
|
+
|
|
76
|
+
if (tag.name === "img" && !attrOf(tag, /^(?:alt|\{alt\})$/) && !hasSpread(tag))
|
|
77
|
+
add("img-no-alt", tag, "an img tag with no alt");
|
|
78
|
+
|
|
79
|
+
if (isTrue(attrOf(tag, /^aria-hidden$/)) && regular(tag)) {
|
|
80
|
+
const index = tabindexOf(tag);
|
|
81
|
+
const type = literal(attrOf(tag, /^type$/));
|
|
82
|
+
const native =
|
|
83
|
+
/^(?:button|select|textarea|summary)$/.test(tag.name) ||
|
|
84
|
+
(tag.name === "a" && attrOf(tag, /^href$/)) ||
|
|
85
|
+
(tag.name === "input" && type !== "hidden");
|
|
86
|
+
if (!attrOf(tag, /^inert$/) && (index === undefined ? native : index >= 0))
|
|
87
|
+
add("aria-hidden-focusable", tag, `<${tag.name} aria-hidden="true"> that takes focus`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (/^[Ii]nput$/.test(tag.name) && !hasSpread(tag) && !attrOf(tag, /^autocomplete$/i)) {
|
|
91
|
+
const type = literal(attrOf(tag, /^type$/));
|
|
92
|
+
if (type === "email" || type === "password" || type === "tel")
|
|
93
|
+
add("input-autocomplete", tag, `<${tag.name} type="${type}"> with no autocomplete`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const paste = attrOf(tag, /^(?:onpaste|on:paste\|.*preventDefault.*)$/);
|
|
97
|
+
if (
|
|
98
|
+
paste &&
|
|
99
|
+
(paste.name.startsWith("on:") ||
|
|
100
|
+
/^\{\s*(?:async\s*)?(?:\(\s*\w*\s*\)|\w+)\s*=>\s*(?:\{\s*)?(?:\w+\.)?preventDefault\(\)\s*;?\s*\}?\s*\}$/.test(
|
|
101
|
+
paste.value ?? "",
|
|
102
|
+
))
|
|
103
|
+
)
|
|
104
|
+
add("block-paste", tag, `<${tag.name} onpaste> that cancels the paste`);
|
|
105
|
+
|
|
106
|
+
if (tag.name === "p") {
|
|
107
|
+
const classes = (attrOf(tag, /^class$/)?.value ?? "") + (attrOf(tag, /^style$/)?.value ?? "");
|
|
108
|
+
if (/(?<![\w-])text-balance(?![\w-])|text-wrap\s*:\s*balance/.test(classes))
|
|
109
|
+
add("balance-paragraph", tag, "<p> with text-balance");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return hits;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The text-level checks that hold in any file: a positive tabindex, a locale-less date and a paste cancelled in script. */
|
|
116
|
+
export function markupTextRules(source) {
|
|
117
|
+
const hits = [];
|
|
118
|
+
const line = (index) => source.slice(0, index).split("\n").length;
|
|
119
|
+
for (const match of source.matchAll(
|
|
120
|
+
/(?<![\w-])tab[iI]ndex\s*=\s*(?:"\s*|'\s*|\{\s*["']?\s*)[1-9]\d*(?![\d.])/g,
|
|
121
|
+
))
|
|
122
|
+
hits.push({ rule: "positive-tabindex", line: line(match.index), snippet: match[0].trim() });
|
|
123
|
+
for (const match of source.matchAll(/\.toLocale(?:Date|Time)String\(\s*(?:\)|undefined\b|\{)/g))
|
|
124
|
+
hits.push({ rule: "locale-less-date", line: line(match.index), snippet: match[0].trim() });
|
|
125
|
+
for (const match of source.matchAll(
|
|
126
|
+
/addEventListener\(\s*["']paste["']\s*,\s*(?:\(\s*\w*\s*\)|\w+)\s*=>\s*(?:\{\s*)?\w+\.preventDefault\(\)/g,
|
|
127
|
+
))
|
|
128
|
+
hits.push({ rule: "block-paste", line: line(match.index), snippet: match[0].slice(0, 80) });
|
|
129
|
+
return hits;
|
|
130
|
+
}
|
package/ui-rules.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* The UI-rules gate: CSS, markup and config checks the impeccable detector misses.
|
|
3
3
|
* Pure here, git and process in `bin/heyramzi-ui-rules.js`.
|
|
4
4
|
*/
|
|
5
|
+
import { blankCode, CSS_MESSAGES, cssBlockRules, cssTextRules } from "#lint/ui-rules-css.js";
|
|
6
|
+
import { MARKUP_MESSAGES, markupTextRules, tagRules } from "#lint/ui-rules-markup.js";
|
|
5
7
|
|
|
6
8
|
export const STYLE_EXTENSIONS = [".svelte", ".css", ".html", ".ts", ".tsx"];
|
|
7
9
|
|
|
@@ -13,6 +15,8 @@ export const DEFAULT_IGNORED = [
|
|
|
13
15
|
"out",
|
|
14
16
|
"coverage",
|
|
15
17
|
"vendor",
|
|
18
|
+
// What a client sent us, kept as it came: company/clients/<slug>/inputs/.
|
|
19
|
+
"inputs",
|
|
16
20
|
".upstream",
|
|
17
21
|
"generated",
|
|
18
22
|
"_generated",
|
|
@@ -35,7 +39,7 @@ export const DEFAULT_IGNORED = [
|
|
|
35
39
|
const T_FAMILIES =
|
|
36
40
|
"avatar|check|clear|digit|dropdown|enter|error|exit|fade|fly|follow|glare|hold|input|learn|lift|like|mask|modal|morph|page|pop|press|reel|resize|return|scrollbar|shake|shimmer|stagger|success|swap|text|think|tilt|acc|tabs|toast|tooltip|number|badge|panel|toggle|banner|matrix|stream|reason|skeleton|accordion";
|
|
37
41
|
|
|
38
|
-
const TOKEN_NAMES = "stagger|micro|quick|fast|medium|slow|
|
|
42
|
+
const TOKEN_NAMES = "stagger|micro|quick|fast|medium|slow|overlay|loop";
|
|
39
43
|
|
|
40
44
|
const MESSAGES = {
|
|
41
45
|
"transition-all":
|
|
@@ -45,7 +49,7 @@ const MESSAGES = {
|
|
|
45
49
|
"ease-in":
|
|
46
50
|
"Use `var(--ease-smooth-out)` (or `ease-out`). `ease-in` starts slow, delaying the exact moment the user is watching. `ease-in-out` is fine for on-screen movement.",
|
|
47
51
|
"duration-literal":
|
|
48
|
-
"Write `duration-[var(--duration-fast)]` and `delay-[var(--duration-micro)]`, never a literal. Tokens: stagger, micro, quick, fast, medium, slow,
|
|
52
|
+
"Write `duration-[var(--duration-fast)]` and `delay-[var(--duration-micro)]`, never a literal. Tokens: stagger, micro, quick, fast, medium, slow, overlay, loop.",
|
|
49
53
|
"duration-bare":
|
|
50
54
|
"Write `duration-[var(--duration-fast)]`, never `duration-fast`. Tailwind's `duration-*` resolves against `--transition-duration-*`, so a bare token class generates nothing and sits at 0s.",
|
|
51
55
|
"acc-panel-padding":
|
|
@@ -55,7 +59,7 @@ const MESSAGES = {
|
|
|
55
59
|
"viewport-zoom":
|
|
56
60
|
"Never disable zoom, it's an accessibility failure. Remove `user-scalable=no` and `maximum-scale=1`; set input font size to 16px instead, since that caused the zoom.",
|
|
57
61
|
"body-user-select":
|
|
58
|
-
|
|
62
|
+
'Never `user-select: none` on body or html: users copy addresses and order numbers. Put it on controls only (`button`, `[role="button"]`, `.tab`, `.chip`).',
|
|
59
63
|
"outline-neutral":
|
|
60
64
|
"Image outlines are pure black or white at 10%: `outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10`. Never `outline-slate-*`, `-zinc-*` or `-neutral-*`.",
|
|
61
65
|
"raw-hover":
|
|
@@ -63,19 +67,26 @@ const MESSAGES = {
|
|
|
63
67
|
"local-shadcn":
|
|
64
68
|
"Never vendor shadcn. Import from `@heyramzi/ui/shadcn/<name>`; a fix or new component lands in the `@heyramzi/ui` source and arrives as a version bump. Delete the local copy and `components.json`.",
|
|
65
69
|
"ui-source":
|
|
66
|
-
|
|
70
|
+
'app.css needs `@source "../node_modules/@heyramzi/ui/src";` (path relative to app.css), or Tailwind generates none of the kit\'s classes.',
|
|
67
71
|
"cloudflare-relative":
|
|
68
72
|
"Set `paths: { relative: false }` in svelte.config. adapter-cloudflare with relative paths took upsys-consulting.com down with intermittent 404s.",
|
|
69
73
|
"static-shadow":
|
|
70
74
|
"Delete the static file. On Cloudflare a file in `static/` beats a route of the same path, so a robots or sitemap route never has a static twin.",
|
|
75
|
+
...CSS_MESSAGES,
|
|
76
|
+
...MARKUP_MESSAGES,
|
|
71
77
|
};
|
|
72
78
|
|
|
79
|
+
/** Exports, thumbnails and client mock-ups are .html. The rules added after them judge components only. */
|
|
80
|
+
const HTML_EXEMPT = new Set([...Object.keys(CSS_MESSAGES), ...Object.keys(MARKUP_MESSAGES)]);
|
|
81
|
+
|
|
73
82
|
/** Blank out comments, keeping every newline so line numbers hold. */
|
|
74
83
|
export function stripComments(source, file) {
|
|
75
84
|
const blank = (match) => match.replace(/[^\n]/g, " ");
|
|
76
85
|
let out = source.replace(/\/\*[\s\S]*?\*\//g, blank);
|
|
77
|
-
if (file.endsWith(".svelte") || file.endsWith(".html"))
|
|
78
|
-
|
|
86
|
+
if (file.endsWith(".svelte") || file.endsWith(".html"))
|
|
87
|
+
out = out.replace(/<!--[\s\S]*?-->/g, blank);
|
|
88
|
+
if (/\.(svelte|tsx?)$/.test(file))
|
|
89
|
+
out = out.replace(/(^|[\s;{(,])\/\/[^\n]*/g, (m, lead) => lead + blank(m.slice(lead.length)));
|
|
79
90
|
return out;
|
|
80
91
|
}
|
|
81
92
|
|
|
@@ -100,8 +111,14 @@ const SWIFT_T_CLASS = new RegExp(
|
|
|
100
111
|
);
|
|
101
112
|
|
|
102
113
|
const LINE_RULES = [
|
|
103
|
-
[
|
|
104
|
-
|
|
114
|
+
[
|
|
115
|
+
"transition-all",
|
|
116
|
+
/(?<![\w-])transition(?:-property)?\s*:\s*[^;}"'`]*\ball\b|(?<![\w-])transition-(?:all|\[all\])(?![\w-])/g,
|
|
117
|
+
],
|
|
118
|
+
[
|
|
119
|
+
"scale-zero",
|
|
120
|
+
/(?<![\w-])scale\(\s*0(?:\.0+)?\s*(?:,\s*0(?:\.0+)?\s*)?\)|(?<![\w-])scale-0(?![\w.-])/g,
|
|
121
|
+
],
|
|
105
122
|
["ease-in", /(?<![\w-])ease-in(?![\w-])/g],
|
|
106
123
|
["duration-literal", /(?<![\w-])(?:duration|delay)-\[\s*[\d.]+m?s\s*\]/g],
|
|
107
124
|
["duration-bare", new RegExp(`(?<![\\w-])(?:duration|delay)-(?:${TOKEN_NAMES})(?![\\w-])`, "g")],
|
|
@@ -129,13 +146,22 @@ export function parseRules(css) {
|
|
|
129
146
|
const char = css[i];
|
|
130
147
|
if (char === "{") {
|
|
131
148
|
const prelude = css.slice(start, i).trim();
|
|
132
|
-
stack.push({
|
|
149
|
+
stack.push({
|
|
150
|
+
prelude,
|
|
151
|
+
from: i + 1,
|
|
152
|
+
line: lineOf(css, start + (css.slice(start).length - css.slice(start).trimStart().length)),
|
|
153
|
+
});
|
|
133
154
|
start = i + 1;
|
|
134
155
|
} else if (char === "}") {
|
|
135
156
|
const open = stack.pop();
|
|
136
157
|
if (open) {
|
|
137
158
|
const body = css.slice(open.from, i);
|
|
138
|
-
rules.push({
|
|
159
|
+
rules.push({
|
|
160
|
+
selector: open.prelude,
|
|
161
|
+
body,
|
|
162
|
+
ancestors: stack.map((s) => s.prelude),
|
|
163
|
+
line: open.line,
|
|
164
|
+
});
|
|
139
165
|
}
|
|
140
166
|
start = i + 1;
|
|
141
167
|
} else if (char === ";") {
|
|
@@ -146,25 +172,44 @@ export function parseRules(css) {
|
|
|
146
172
|
}
|
|
147
173
|
|
|
148
174
|
const hoverGated = (ancestors) =>
|
|
149
|
-
ancestors.some(
|
|
175
|
+
ancestors.some(
|
|
176
|
+
(p) =>
|
|
177
|
+
/^@media\b[^{]*hover\s*:\s*hover/i.test(p) ||
|
|
178
|
+
/^@(custom-variant|variant|utility|theme)\b/.test(p),
|
|
179
|
+
);
|
|
150
180
|
|
|
151
181
|
function cssRules(source, file) {
|
|
152
182
|
const hits = [];
|
|
153
183
|
for (const { css, offset } of styleBlocks(source, file)) {
|
|
154
|
-
|
|
184
|
+
const rules = parseRules(css);
|
|
185
|
+
hits.push(...cssBlockRules(rules, offset));
|
|
186
|
+
for (const rule of rules) {
|
|
155
187
|
if (rule.selector.startsWith("@")) continue;
|
|
156
188
|
const line = rule.line + offset;
|
|
157
189
|
if (/:hover\b/.test(rule.selector) && !hoverGated(rule.ancestors)) {
|
|
158
190
|
hits.push({ rule: "raw-hover", line, snippet: rule.selector.slice(0, 80) });
|
|
159
191
|
}
|
|
160
192
|
const selectors = rule.selector.split(",").map((s) => s.trim());
|
|
161
|
-
const own = selectors.some(
|
|
162
|
-
|
|
193
|
+
const own = selectors.some(
|
|
194
|
+
(s) => /(^|[\s>+~])\.t-acc-panel$/.test(s) || /(^|[\s>+~])\.t-acc-panel(?=[:.[])/.test(s),
|
|
195
|
+
);
|
|
196
|
+
if (
|
|
197
|
+
own &&
|
|
198
|
+
/(?<![\w-])padding(?:-[a-z]+)*\s*:/.test(rule.body) &&
|
|
199
|
+
!selectors.some((s) => /t-acc-panel-/.test(s))
|
|
200
|
+
) {
|
|
163
201
|
hits.push({ rule: "acc-panel-padding", line, snippet: `${rule.selector} { padding }` });
|
|
164
202
|
}
|
|
165
203
|
const root = selectors.some((s) => /^(?:body|html)$/.test(s));
|
|
166
|
-
if (
|
|
167
|
-
|
|
204
|
+
if (
|
|
205
|
+
root &&
|
|
206
|
+
/(?<![\w-])(?:-webkit-)?user-select\s*:\s*none|@apply[^;]*\bselect-none\b/.test(rule.body)
|
|
207
|
+
) {
|
|
208
|
+
hits.push({
|
|
209
|
+
rule: "body-user-select",
|
|
210
|
+
line,
|
|
211
|
+
snippet: `${rule.selector} { user-select: none }`,
|
|
212
|
+
});
|
|
168
213
|
}
|
|
169
214
|
}
|
|
170
215
|
}
|
|
@@ -174,11 +219,23 @@ function cssRules(source, file) {
|
|
|
174
219
|
function markupRules(source) {
|
|
175
220
|
const hits = [];
|
|
176
221
|
for (const match of source.matchAll(/<body\b[^>]*\bclass\s*=\s*["'{`][^"'`>]*\bselect-none\b/g)) {
|
|
177
|
-
hits.push({
|
|
222
|
+
hits.push({
|
|
223
|
+
rule: "body-user-select",
|
|
224
|
+
line: lineOf(source, match.index),
|
|
225
|
+
snippet: '<body class="select-none">',
|
|
226
|
+
});
|
|
178
227
|
}
|
|
179
|
-
for (const match of source.matchAll(
|
|
180
|
-
|
|
181
|
-
|
|
228
|
+
for (const match of source.matchAll(
|
|
229
|
+
/class\s*=\s*["'`{][^"'`>]*(?<![\w-])t-acc-panel(?![\w-])[^"'`>]*/g,
|
|
230
|
+
)) {
|
|
231
|
+
if (
|
|
232
|
+
/(?:^|[\s:"'`{])-?p[xytblrse]?-[\w[\]./]+/.test(match[0].replace(/^class\s*=\s*["'`{]/, " "))
|
|
233
|
+
) {
|
|
234
|
+
hits.push({
|
|
235
|
+
rule: "acc-panel-padding",
|
|
236
|
+
line: lineOf(source, match.index),
|
|
237
|
+
snippet: match[0].slice(0, 80),
|
|
238
|
+
});
|
|
182
239
|
}
|
|
183
240
|
}
|
|
184
241
|
return hits;
|
|
@@ -192,10 +249,17 @@ export function checkFile(file, source) {
|
|
|
192
249
|
if (!STYLE_EXTENSIONS.some((ext) => file.endsWith(ext))) return [];
|
|
193
250
|
const clean = stripComments(source, file);
|
|
194
251
|
const plainTs = file.endsWith(".ts") || file.endsWith(".tsx");
|
|
195
|
-
const hits = LINE_RULES.filter(([rule]) => !(plainTs && rule === "ease-in")).flatMap(
|
|
252
|
+
const hits = LINE_RULES.filter(([rule]) => !(plainTs && rule === "ease-in")).flatMap(
|
|
253
|
+
([rule, pattern]) => scan(clean, pattern, rule),
|
|
254
|
+
);
|
|
196
255
|
if (!plainTs) hits.push(...cssRules(clean, file));
|
|
197
256
|
if (!file.endsWith(".css")) hits.push(...markupRules(clean));
|
|
198
|
-
|
|
257
|
+
const svelte = file.endsWith(".svelte");
|
|
258
|
+
hits.push(...cssTextRules(svelte ? blankCode(clean) : clean, { declarations: !plainTs }));
|
|
259
|
+
if (!file.endsWith(".css")) hits.push(...markupTextRules(clean));
|
|
260
|
+
if (svelte) hits.push(...tagRules(source));
|
|
261
|
+
const kept = file.endsWith(".html") ? hits.filter((hit) => !HTML_EXEMPT.has(hit.rule)) : hits;
|
|
262
|
+
return kept.sort((a, b) => a.line - b.line);
|
|
199
263
|
}
|
|
200
264
|
|
|
201
265
|
export function describe(rule) {
|
|
@@ -261,7 +325,10 @@ export function checkProject(files, tracked, read) {
|
|
|
261
325
|
}
|
|
262
326
|
|
|
263
327
|
const prefixes = new Set();
|
|
264
|
-
for (const file of [
|
|
328
|
+
for (const file of [
|
|
329
|
+
...files,
|
|
330
|
+
...tracked.filter((f) => /\/\+(server|page)\.(ts|js|svelte)$/.test(f) && files.includes(f)),
|
|
331
|
+
]) {
|
|
265
332
|
const staticAt = file.match(/^(.*?)(?:^|\/)static\/(.+)$/) ?? file.match(/^()static\/(.+)$/);
|
|
266
333
|
if (staticAt) prefixes.add(staticAt[1] === undefined ? "" : staticAt[1].replace(/\/$/, ""));
|
|
267
334
|
const routeAt = file.match(/^(.*?)\/?src\/routes\//);
|
|
@@ -275,7 +342,10 @@ export function checkProject(files, tracked, read) {
|
|
|
275
342
|
const route = file.slice(`${base}src/routes/`.length);
|
|
276
343
|
const match = route.match(/^(.*?)\/?\+(?:server\.(?:ts|js)|page\.svelte)$/);
|
|
277
344
|
if (!match) continue;
|
|
278
|
-
const url = match[1]
|
|
345
|
+
const url = match[1]
|
|
346
|
+
.split("/")
|
|
347
|
+
.filter((seg) => !/^\(.*\)$/.test(seg))
|
|
348
|
+
.join("/");
|
|
279
349
|
if (!url || /[[\]]/.test(url)) continue;
|
|
280
350
|
const twin = `${base}static/${url}`;
|
|
281
351
|
if (trackedSet.has(twin) && (judged.has(twin) || judged.has(file))) {
|
|
@@ -320,5 +390,8 @@ export function durationNames(tracked, read) {
|
|
|
320
390
|
|
|
321
391
|
/** Drops `duration-bare` hits the repo resolves. */
|
|
322
392
|
export function resolvedDurations(hits, names) {
|
|
323
|
-
return hits.filter(
|
|
393
|
+
return hits.filter(
|
|
394
|
+
(hit) =>
|
|
395
|
+
!(hit.rule === "duration-bare" && names.has(hit.snippet.replace(/^(?:duration|delay)-/, ""))),
|
|
396
|
+
);
|
|
324
397
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The words the old-names gate bans, declared once. The gate skips vendor folders, so this file can spell them.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Banned anywhere in a file, inside a longer word too. */
|
|
6
|
+
export const SUBSTRING_WORD = "legacy";
|
|
7
|
+
|
|
8
|
+
/** Banned as a whole word part of a name. */
|
|
9
|
+
export const PART_WORDS = ["compat", "deprecated", "shim"];
|