@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.
Files changed (60) hide show
  1. package/README.md +52 -5
  2. package/bin/heyramzi-comment-length.js +5 -6
  3. package/bin/heyramzi-file-tokens.js +2 -2
  4. package/bin/heyramzi-old-names.js +72 -0
  5. package/bin/heyramzi-push-diff.js +16 -5
  6. package/bin/heyramzi-slop.js +3 -1
  7. package/bin/heyramzi-ui-rules.js +18 -9
  8. package/comment-length.js +18 -12
  9. package/config/oxlintrc.json +3 -4
  10. package/data/jev-code-bench.js +5 -5
  11. package/data/slop/artifacts.js +87 -0
  12. package/data/slop/pattern-parts.js +56 -0
  13. package/data/slop/patterns/appositives-and-sayings.js +81 -0
  14. package/data/slop/patterns/comma-clauses.js +106 -0
  15. package/data/slop/patterns/diagram-and-script-tells.js +71 -0
  16. package/data/slop/patterns/dropped-elements.js +149 -0
  17. package/data/slop/patterns/duration-and-openers.js +102 -0
  18. package/data/slop/patterns/headings-and-laws.js +116 -0
  19. package/data/slop/patterns/numbers-and-runs.js +94 -0
  20. package/data/slop/patterns/openers-and-tails.js +90 -0
  21. package/data/slop/patterns/parallel-shapes.js +100 -0
  22. package/data/slop/patterns/reveals-and-tallies.js +101 -0
  23. package/data/slop/patterns/setups-and-closers.js +83 -0
  24. package/data/slop/patterns/subject-voice.js +67 -0
  25. package/data/slop/patterns/vague-and-flourish.js +102 -0
  26. package/data/slop/patterns/wikipedia-signs.js +103 -0
  27. package/data/slop/patterns.js +62 -0
  28. package/data/slop/phrases-en-closers.js +66 -0
  29. package/data/slop/phrases-en-core.js +185 -0
  30. package/data/slop/phrases-en-wikipedia.js +136 -0
  31. package/data/slop/phrases-fr.js +152 -0
  32. package/data/slop/shapes-density.js +81 -0
  33. package/data/slop/shapes-framing.js +101 -0
  34. package/data/slop/shapes-reveals.js +88 -0
  35. package/data/slop/title-tells.js +8 -0
  36. package/data/slop/words-en.js +178 -0
  37. package/data/slop/words-fr.js +101 -0
  38. package/data/slop-words.js +18 -2576
  39. package/data/vendor/heading-words.js +6 -0
  40. package/file-tokens.js +26 -13
  41. package/index.js +4 -6
  42. package/inline-path.js +2 -0
  43. package/jev-code.js +1 -1
  44. package/old-names-mask.js +206 -0
  45. package/old-names.js +145 -0
  46. package/package.json +8 -2
  47. package/push-diff.js +2 -2
  48. package/repo-rules/packages.js +5 -5
  49. package/repo-rules.js +2 -2
  50. package/rules/convex-index-name.js +3 -1
  51. package/rules/convex-internal-only.js +2 -1
  52. package/rules/{convex-no-deprecated-crons.js → convex-no-hourly-daily-crons.js} +8 -7
  53. package/rules/convex-runtime-imports.js +2 -1
  54. package/rules/{no-legacy-compat.js → no-underscore-reexport.js} +1 -1
  55. package/slop.js +4 -2
  56. package/ui-rules-css.js +221 -0
  57. package/ui-rules-markup.js +130 -0
  58. package/ui-rules.js +98 -25
  59. package/vendor/old-names-words.js +9 -0
  60. 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)) context.report({ node, messageId: "actionsOnly", data: { name } });
44
+ if (name && NODE_ONLY.has(name))
45
+ context.report({ node, messageId: "actionsOnly", data: { name } });
45
46
  },
46
47
  };
47
48
  },
@@ -1,5 +1,5 @@
1
1
  /**
2
- * A re-export renamed to a leading `_` keeps a dead name alive. Compat words in comments are
2
+ * A re-export renamed to a leading `_` keeps a dead name alive. Old-name words in comments are
3
3
  * the comment gate's job: `comment-length.js`.
4
4
  */
5
5
  export default {
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-words.js` are the one home for each.
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 { artifacts, patterns, phrases, words } from "#lint/data/slop-words.js";
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,
@@ -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|very-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, very-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
- "Never `user-select: none` on body or html: users copy addresses and order numbers. Put it on controls only (`button`, `[role=\"button\"]`, `.tab`, `.chip`).",
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
- "app.css needs `@source \"../node_modules/@heyramzi/ui/src\";` (path relative to app.css), or Tailwind generates none of the kit's classes.",
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")) out = out.replace(/<!--[\s\S]*?-->/g, blank);
78
- if (/\.(svelte|tsx?)$/.test(file)) out = out.replace(/(^|[\s;{(,])\/\/[^\n]*/g, (m, lead) => lead + blank(m.slice(lead.length)));
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
- ["transition-all", /(?<![\w-])transition(?:-property)?\s*:\s*[^;}"'`]*\ball\b|(?<![\w-])transition-(?:all|\[all\])(?![\w-])/g],
104
- ["scale-zero", /(?<![\w-])scale\(\s*0(?:\.0+)?\s*(?:,\s*0(?:\.0+)?\s*)?\)|(?<![\w-])scale-0(?![\w.-])/g],
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({ prelude, from: i + 1, line: lineOf(css, start + (css.slice(start).length - css.slice(start).trimStart().length)) });
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({ selector: open.prelude, body, ancestors: stack.map((s) => s.prelude), line: open.line });
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((p) => /^@media\b[^{]*hover\s*:\s*hover/i.test(p) || /^@(custom-variant|variant|utility|theme)\b/.test(p));
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
- for (const rule of parseRules(css)) {
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((s) => /(^|[\s>+~])\.t-acc-panel$/.test(s) || /(^|[\s>+~])\.t-acc-panel(?=[:.[])/.test(s));
162
- if (own && /(?<![\w-])padding(?:-[a-z]+)*\s*:/.test(rule.body) && !selectors.some((s) => /t-acc-panel-/.test(s))) {
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 (root && /(?<![\w-])(?:-webkit-)?user-select\s*:\s*none|@apply[^;]*\bselect-none\b/.test(rule.body)) {
167
- hits.push({ rule: "body-user-select", line, snippet: `${rule.selector} { user-select: none }` });
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({ rule: "body-user-select", line: lineOf(source, match.index), snippet: "<body class=\"select-none\">" });
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(/class\s*=\s*["'`{][^"'`>]*(?<![\w-])t-acc-panel(?![\w-])[^"'`>]*/g)) {
180
- if (/(?:^|[\s:"'`{])-?p[xytblrse]?-[\w[\]./]+/.test(match[0].replace(/^class\s*=\s*["'`{]/, " "))) {
181
- hits.push({ rule: "acc-panel-padding", line: lineOf(source, match.index), snippet: match[0].slice(0, 80) });
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(([rule, pattern]) => scan(clean, pattern, rule));
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
- return hits.sort((a, b) => a.line - b.line);
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 [...files, ...tracked.filter((f) => /\/\+(server|page)\.(ts|js|svelte)$/.test(f) && files.includes(f))]) {
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].split("/").filter((seg) => !/^\(.*\)$/.test(seg)).join("/");
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((hit) => !(hit.rule === "duration-bare" && names.has(hit.snippet.replace(/^(?:duration|delay)-/, ""))));
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"];