@heyramzi/lint 1.34.0 → 2.0.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 +1 -3
- package/bin/heyramzi-old-names.js +72 -0
- package/bin/heyramzi-push-diff.js +16 -5
- package/bin/heyramzi-ui-rules.js +18 -9
- package/comment-length.js +18 -12
- package/config/oxlintrc.json +2 -3
- package/data/jev-code-bench.js +5 -5
- package/file-tokens.js +26 -13
- package/index.js +4 -6
- 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/ui-rules-css.js +221 -0
- package/ui-rules-markup.js +130 -0
- package/ui-rules.js +94 -23
- package/vendor/old-names-words.js +9 -0
- package/rules/no-relative-type-imports.js +0 -40
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
|
|
|
@@ -55,7 +57,7 @@ const MESSAGES = {
|
|
|
55
57
|
"viewport-zoom":
|
|
56
58
|
"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
59
|
"body-user-select":
|
|
58
|
-
|
|
60
|
+
'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
61
|
"outline-neutral":
|
|
60
62
|
"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
63
|
"raw-hover":
|
|
@@ -63,19 +65,26 @@ const MESSAGES = {
|
|
|
63
65
|
"local-shadcn":
|
|
64
66
|
"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
67
|
"ui-source":
|
|
66
|
-
|
|
68
|
+
'app.css needs `@source "../node_modules/@heyramzi/ui/src";` (path relative to app.css), or Tailwind generates none of the kit\'s classes.',
|
|
67
69
|
"cloudflare-relative":
|
|
68
70
|
"Set `paths: { relative: false }` in svelte.config. adapter-cloudflare with relative paths took upsys-consulting.com down with intermittent 404s.",
|
|
69
71
|
"static-shadow":
|
|
70
72
|
"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.",
|
|
73
|
+
...CSS_MESSAGES,
|
|
74
|
+
...MARKUP_MESSAGES,
|
|
71
75
|
};
|
|
72
76
|
|
|
77
|
+
/** Exports, thumbnails and client mock-ups are .html. The rules added after them judge components only. */
|
|
78
|
+
const HTML_EXEMPT = new Set([...Object.keys(CSS_MESSAGES), ...Object.keys(MARKUP_MESSAGES)]);
|
|
79
|
+
|
|
73
80
|
/** Blank out comments, keeping every newline so line numbers hold. */
|
|
74
81
|
export function stripComments(source, file) {
|
|
75
82
|
const blank = (match) => match.replace(/[^\n]/g, " ");
|
|
76
83
|
let out = source.replace(/\/\*[\s\S]*?\*\//g, blank);
|
|
77
|
-
if (file.endsWith(".svelte") || file.endsWith(".html"))
|
|
78
|
-
|
|
84
|
+
if (file.endsWith(".svelte") || file.endsWith(".html"))
|
|
85
|
+
out = out.replace(/<!--[\s\S]*?-->/g, blank);
|
|
86
|
+
if (/\.(svelte|tsx?)$/.test(file))
|
|
87
|
+
out = out.replace(/(^|[\s;{(,])\/\/[^\n]*/g, (m, lead) => lead + blank(m.slice(lead.length)));
|
|
79
88
|
return out;
|
|
80
89
|
}
|
|
81
90
|
|
|
@@ -100,8 +109,14 @@ const SWIFT_T_CLASS = new RegExp(
|
|
|
100
109
|
);
|
|
101
110
|
|
|
102
111
|
const LINE_RULES = [
|
|
103
|
-
[
|
|
104
|
-
|
|
112
|
+
[
|
|
113
|
+
"transition-all",
|
|
114
|
+
/(?<![\w-])transition(?:-property)?\s*:\s*[^;}"'`]*\ball\b|(?<![\w-])transition-(?:all|\[all\])(?![\w-])/g,
|
|
115
|
+
],
|
|
116
|
+
[
|
|
117
|
+
"scale-zero",
|
|
118
|
+
/(?<![\w-])scale\(\s*0(?:\.0+)?\s*(?:,\s*0(?:\.0+)?\s*)?\)|(?<![\w-])scale-0(?![\w.-])/g,
|
|
119
|
+
],
|
|
105
120
|
["ease-in", /(?<![\w-])ease-in(?![\w-])/g],
|
|
106
121
|
["duration-literal", /(?<![\w-])(?:duration|delay)-\[\s*[\d.]+m?s\s*\]/g],
|
|
107
122
|
["duration-bare", new RegExp(`(?<![\\w-])(?:duration|delay)-(?:${TOKEN_NAMES})(?![\\w-])`, "g")],
|
|
@@ -129,13 +144,22 @@ export function parseRules(css) {
|
|
|
129
144
|
const char = css[i];
|
|
130
145
|
if (char === "{") {
|
|
131
146
|
const prelude = css.slice(start, i).trim();
|
|
132
|
-
stack.push({
|
|
147
|
+
stack.push({
|
|
148
|
+
prelude,
|
|
149
|
+
from: i + 1,
|
|
150
|
+
line: lineOf(css, start + (css.slice(start).length - css.slice(start).trimStart().length)),
|
|
151
|
+
});
|
|
133
152
|
start = i + 1;
|
|
134
153
|
} else if (char === "}") {
|
|
135
154
|
const open = stack.pop();
|
|
136
155
|
if (open) {
|
|
137
156
|
const body = css.slice(open.from, i);
|
|
138
|
-
rules.push({
|
|
157
|
+
rules.push({
|
|
158
|
+
selector: open.prelude,
|
|
159
|
+
body,
|
|
160
|
+
ancestors: stack.map((s) => s.prelude),
|
|
161
|
+
line: open.line,
|
|
162
|
+
});
|
|
139
163
|
}
|
|
140
164
|
start = i + 1;
|
|
141
165
|
} else if (char === ";") {
|
|
@@ -146,25 +170,44 @@ export function parseRules(css) {
|
|
|
146
170
|
}
|
|
147
171
|
|
|
148
172
|
const hoverGated = (ancestors) =>
|
|
149
|
-
ancestors.some(
|
|
173
|
+
ancestors.some(
|
|
174
|
+
(p) =>
|
|
175
|
+
/^@media\b[^{]*hover\s*:\s*hover/i.test(p) ||
|
|
176
|
+
/^@(custom-variant|variant|utility|theme)\b/.test(p),
|
|
177
|
+
);
|
|
150
178
|
|
|
151
179
|
function cssRules(source, file) {
|
|
152
180
|
const hits = [];
|
|
153
181
|
for (const { css, offset } of styleBlocks(source, file)) {
|
|
154
|
-
|
|
182
|
+
const rules = parseRules(css);
|
|
183
|
+
hits.push(...cssBlockRules(rules, offset));
|
|
184
|
+
for (const rule of rules) {
|
|
155
185
|
if (rule.selector.startsWith("@")) continue;
|
|
156
186
|
const line = rule.line + offset;
|
|
157
187
|
if (/:hover\b/.test(rule.selector) && !hoverGated(rule.ancestors)) {
|
|
158
188
|
hits.push({ rule: "raw-hover", line, snippet: rule.selector.slice(0, 80) });
|
|
159
189
|
}
|
|
160
190
|
const selectors = rule.selector.split(",").map((s) => s.trim());
|
|
161
|
-
const own = selectors.some(
|
|
162
|
-
|
|
191
|
+
const own = selectors.some(
|
|
192
|
+
(s) => /(^|[\s>+~])\.t-acc-panel$/.test(s) || /(^|[\s>+~])\.t-acc-panel(?=[:.[])/.test(s),
|
|
193
|
+
);
|
|
194
|
+
if (
|
|
195
|
+
own &&
|
|
196
|
+
/(?<![\w-])padding(?:-[a-z]+)*\s*:/.test(rule.body) &&
|
|
197
|
+
!selectors.some((s) => /t-acc-panel-/.test(s))
|
|
198
|
+
) {
|
|
163
199
|
hits.push({ rule: "acc-panel-padding", line, snippet: `${rule.selector} { padding }` });
|
|
164
200
|
}
|
|
165
201
|
const root = selectors.some((s) => /^(?:body|html)$/.test(s));
|
|
166
|
-
if (
|
|
167
|
-
|
|
202
|
+
if (
|
|
203
|
+
root &&
|
|
204
|
+
/(?<![\w-])(?:-webkit-)?user-select\s*:\s*none|@apply[^;]*\bselect-none\b/.test(rule.body)
|
|
205
|
+
) {
|
|
206
|
+
hits.push({
|
|
207
|
+
rule: "body-user-select",
|
|
208
|
+
line,
|
|
209
|
+
snippet: `${rule.selector} { user-select: none }`,
|
|
210
|
+
});
|
|
168
211
|
}
|
|
169
212
|
}
|
|
170
213
|
}
|
|
@@ -174,11 +217,23 @@ function cssRules(source, file) {
|
|
|
174
217
|
function markupRules(source) {
|
|
175
218
|
const hits = [];
|
|
176
219
|
for (const match of source.matchAll(/<body\b[^>]*\bclass\s*=\s*["'{`][^"'`>]*\bselect-none\b/g)) {
|
|
177
|
-
hits.push({
|
|
220
|
+
hits.push({
|
|
221
|
+
rule: "body-user-select",
|
|
222
|
+
line: lineOf(source, match.index),
|
|
223
|
+
snippet: '<body class="select-none">',
|
|
224
|
+
});
|
|
178
225
|
}
|
|
179
|
-
for (const match of source.matchAll(
|
|
180
|
-
|
|
181
|
-
|
|
226
|
+
for (const match of source.matchAll(
|
|
227
|
+
/class\s*=\s*["'`{][^"'`>]*(?<![\w-])t-acc-panel(?![\w-])[^"'`>]*/g,
|
|
228
|
+
)) {
|
|
229
|
+
if (
|
|
230
|
+
/(?:^|[\s:"'`{])-?p[xytblrse]?-[\w[\]./]+/.test(match[0].replace(/^class\s*=\s*["'`{]/, " "))
|
|
231
|
+
) {
|
|
232
|
+
hits.push({
|
|
233
|
+
rule: "acc-panel-padding",
|
|
234
|
+
line: lineOf(source, match.index),
|
|
235
|
+
snippet: match[0].slice(0, 80),
|
|
236
|
+
});
|
|
182
237
|
}
|
|
183
238
|
}
|
|
184
239
|
return hits;
|
|
@@ -192,10 +247,17 @@ export function checkFile(file, source) {
|
|
|
192
247
|
if (!STYLE_EXTENSIONS.some((ext) => file.endsWith(ext))) return [];
|
|
193
248
|
const clean = stripComments(source, file);
|
|
194
249
|
const plainTs = file.endsWith(".ts") || file.endsWith(".tsx");
|
|
195
|
-
const hits = LINE_RULES.filter(([rule]) => !(plainTs && rule === "ease-in")).flatMap(
|
|
250
|
+
const hits = LINE_RULES.filter(([rule]) => !(plainTs && rule === "ease-in")).flatMap(
|
|
251
|
+
([rule, pattern]) => scan(clean, pattern, rule),
|
|
252
|
+
);
|
|
196
253
|
if (!plainTs) hits.push(...cssRules(clean, file));
|
|
197
254
|
if (!file.endsWith(".css")) hits.push(...markupRules(clean));
|
|
198
|
-
|
|
255
|
+
const svelte = file.endsWith(".svelte");
|
|
256
|
+
hits.push(...cssTextRules(svelte ? blankCode(clean) : clean, { declarations: !plainTs }));
|
|
257
|
+
if (!file.endsWith(".css")) hits.push(...markupTextRules(clean));
|
|
258
|
+
if (svelte) hits.push(...tagRules(source));
|
|
259
|
+
const kept = file.endsWith(".html") ? hits.filter((hit) => !HTML_EXEMPT.has(hit.rule)) : hits;
|
|
260
|
+
return kept.sort((a, b) => a.line - b.line);
|
|
199
261
|
}
|
|
200
262
|
|
|
201
263
|
export function describe(rule) {
|
|
@@ -261,7 +323,10 @@ export function checkProject(files, tracked, read) {
|
|
|
261
323
|
}
|
|
262
324
|
|
|
263
325
|
const prefixes = new Set();
|
|
264
|
-
for (const file of [
|
|
326
|
+
for (const file of [
|
|
327
|
+
...files,
|
|
328
|
+
...tracked.filter((f) => /\/\+(server|page)\.(ts|js|svelte)$/.test(f) && files.includes(f)),
|
|
329
|
+
]) {
|
|
265
330
|
const staticAt = file.match(/^(.*?)(?:^|\/)static\/(.+)$/) ?? file.match(/^()static\/(.+)$/);
|
|
266
331
|
if (staticAt) prefixes.add(staticAt[1] === undefined ? "" : staticAt[1].replace(/\/$/, ""));
|
|
267
332
|
const routeAt = file.match(/^(.*?)\/?src\/routes\//);
|
|
@@ -275,7 +340,10 @@ export function checkProject(files, tracked, read) {
|
|
|
275
340
|
const route = file.slice(`${base}src/routes/`.length);
|
|
276
341
|
const match = route.match(/^(.*?)\/?\+(?:server\.(?:ts|js)|page\.svelte)$/);
|
|
277
342
|
if (!match) continue;
|
|
278
|
-
const url = match[1]
|
|
343
|
+
const url = match[1]
|
|
344
|
+
.split("/")
|
|
345
|
+
.filter((seg) => !/^\(.*\)$/.test(seg))
|
|
346
|
+
.join("/");
|
|
279
347
|
if (!url || /[[\]]/.test(url)) continue;
|
|
280
348
|
const twin = `${base}static/${url}`;
|
|
281
349
|
if (trackedSet.has(twin) && (judged.has(twin) || judged.has(file))) {
|
|
@@ -320,5 +388,8 @@ export function durationNames(tracked, read) {
|
|
|
320
388
|
|
|
321
389
|
/** Drops `duration-bare` hits the repo resolves. */
|
|
322
390
|
export function resolvedDurations(hits, names) {
|
|
323
|
-
return hits.filter(
|
|
391
|
+
return hits.filter(
|
|
392
|
+
(hit) =>
|
|
393
|
+
!(hit.rule === "duration-bare" && names.has(hit.snippet.replace(/^(?:duration|delay)-/, ""))),
|
|
394
|
+
);
|
|
324
395
|
}
|
|
@@ -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"];
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
export default {
|
|
2
|
-
meta: {
|
|
3
|
-
type: "suggestion",
|
|
4
|
-
deprecated: true,
|
|
5
|
-
replacedBy: ["no-relative-imports"],
|
|
6
|
-
docs: {
|
|
7
|
-
description: "Disallow relative imports for type-only imports. Use @/ path alias instead.",
|
|
8
|
-
},
|
|
9
|
-
messages: {
|
|
10
|
-
noRelativeTypeImport:
|
|
11
|
-
"Use @/ path alias for type imports instead of relative import '{{ source }}'.",
|
|
12
|
-
},
|
|
13
|
-
schema: [],
|
|
14
|
-
},
|
|
15
|
-
create(context) {
|
|
16
|
-
return {
|
|
17
|
-
ImportDeclaration(node) {
|
|
18
|
-
if (node.importKind === "type" || hasTypeImport(node)) {
|
|
19
|
-
const source = node.source.value;
|
|
20
|
-
if (source === "./$types") {
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
23
|
-
if (source.startsWith(".")) {
|
|
24
|
-
context.report({
|
|
25
|
-
node: node.source,
|
|
26
|
-
messageId: "noRelativeTypeImport",
|
|
27
|
-
data: { source },
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
},
|
|
32
|
-
};
|
|
33
|
-
},
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
function hasTypeImport(node) {
|
|
37
|
-
return node.specifiers.some(
|
|
38
|
-
(specifier) => specifier.type === "ImportSpecifier" && specifier.importKind === "type",
|
|
39
|
-
);
|
|
40
|
-
}
|