@stacksjs/stx 0.2.171 → 0.2.173

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/dist/codemod.d.ts CHANGED
@@ -18,31 +18,8 @@ export declare interface CodemodOptions {
18
18
  rules?: CodemodRule[]
19
19
  file?: string
20
20
  }
21
- /**
22
- * Codemods for adopting stx primitives an app hand-rolled around (#1843).
23
- *
24
- * Two production apps built on stx reached past it into vanilla JS for things
25
- * it already ships. The audit found the cause was discovery, not absence: one
26
- * app called bare `confirm()` in five places and used `title=` thirteen times
27
- * while `stxConfirm` and `x-tooltip` were both delivered and used zero times.
28
- * A native fallback that WORKS is the hardest kind of gap to notice — nothing
29
- * fails, so nothing prompts you to look.
30
- *
31
- * #1843 proposes both rewrites as "mechanical and safe". Neither is, and the
32
- * difference matters enough that this tool reports by default and only edits
33
- * when asked:
34
- *
35
- * - `confirm()` returns a boolean synchronously; `stxConfirm()` returns a
36
- * Promise. `if (confirm(m))` rewritten to `if (stxConfirm(m))` is ALWAYS
37
- * TRUE, because a Promise is truthy. A blind rewrite turns every "Delete
38
- * this?" into an unconditional yes.
39
- *
40
- * - `title=` is announced by screen readers. `x-tooltip` sets no `role` and
41
- * no `aria-*` — it is a visual tooltip driven by mouseover/focusin.
42
- * Replacing one with the other removes the accessible description. So the
43
- * tooltip rule ADDS `x-tooltip` and keeps `title`.
44
- */
45
21
  export type CodemodRule = | 'confirm'
22
+ | 'alert'
46
23
  | 'tooltip'
47
24
  | 'route-params'
48
25
  | 'search-params'
package/dist/codemod.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { stripCommentsAndLiterals } from "./strip-literals.js";
1
2
  function contextAfter(source, index, length = 240) {
2
3
  return source.slice(index, index + length);
3
4
  }
@@ -8,11 +9,25 @@ const REPORT_RULES = [
8
9
  primitive: "useRoute() / useRouteParams() / useRouteParam(name)",
9
10
  why: "parsing the URL by hand re-implements the router and does not update on SPA navigation"
10
11
  },
12
+ {
13
+ rule: "alert",
14
+ pattern: /(?<![.#\w$])(?:(?:window|globalThis)\s*\.\s*)?alert\s*\(/g,
15
+ primitive: "stxAlert(message)",
16
+ why: "themed, focus-trapped and non-blocking \u2014 but `await` it if the code after it " + "depended on the native dialog blocking until dismissal",
17
+ reject: (source) => /\b(?:async\s+)?function\s+alert\s*\(/.test(source) || /\b(?:const|let|var)\s+alert\s*=/.test(source)
18
+ },
11
19
  {
12
20
  rule: "search-params",
13
21
  pattern: /new\s+URLSearchParams\s*\(/g,
14
22
  primitive: "useSearchParams()",
15
- why: "reactive, and writes back through the router instead of history.replaceState"
23
+ why: "reactive, and writes back through the router instead of history.replaceState",
24
+ reject: (source, index) => {
25
+ const open = source.indexOf("(", index);
26
+ if (open === -1)
27
+ return !0;
28
+ const arg = source.slice(open + 1, source.indexOf(")", open) + 1);
29
+ return !/\b(?:location|search|href|url)\b/i.test(arg);
30
+ }
16
31
  },
17
32
  {
18
33
  rule: "navigate",
@@ -93,9 +108,9 @@ function declaresOwnConfirm(source) {
93
108
  function applyConfirmRule(source, file, findings) {
94
109
  if (declaresOwnConfirm(source))
95
110
  return source;
96
- const pattern = /(^|[^\w$.])confirm\s*\(/g;
111
+ const pattern = /(^|[^\w$.])(?:(?:window|globalThis)\s*\.\s*)?confirm\s*\(/g, searchable = searchableSource(source);
97
112
  let out = "", last = 0, match;
98
- while ((match = pattern.exec(source)) !== null) {
113
+ while ((match = pattern.exec(searchable)) !== null) {
99
114
  const callAt = match.index + match[1].length, line = lineOf(source, callAt), snippet = snippetAt(source, callAt);
100
115
  if (/stxConfirm\s*\($/.test(source.slice(0, callAt + 8)))
101
116
  continue;
@@ -110,16 +125,27 @@ function applyConfirmRule(source, file, findings) {
110
125
  });
111
126
  if (safe) {
112
127
  out += source.slice(last, callAt) + "await stxConfirm(";
113
- last = callAt + 8;
128
+ last = callAt + (match[0].length - match[1].length);
114
129
  }
115
130
  }
116
131
  return out + source.slice(last);
117
132
  }
118
133
  function applyTooltipRule(source, file, findings) {
119
- const pattern = /(<[a-z][\w-]*\b[^>]*?)\stitle=(["'])(.*?)\2/gis;
134
+ const pattern = /(<[a-z][\w-]*\b[^>]*?)\stitle=(["'])(.*?)\2/gis, searchable = markupSearchable(source);
120
135
  let out = "", last = 0, match;
121
- while ((match = pattern.exec(source)) !== null) {
122
- const [whole, openTag, quote, value] = match, line = lineOf(source, match.index), snippet = snippetAt(source, match.index);
136
+ while ((match = pattern.exec(searchable)) !== null) {
137
+ const [whole, openTag, quote, value] = match, line = lineOf(source, match.index), snippet = snippetAt(source, match.index), tagName = openTag.match(/^<([A-Za-z][\w-]*)/)?.[1] ?? "";
138
+ if (/^[A-Z]/.test(tagName)) {
139
+ findings.push({
140
+ file,
141
+ line,
142
+ rule: "tooltip",
143
+ applied: !1,
144
+ snippet,
145
+ reason: `\`title\` on <${tagName}> is a component prop, not an HTML attribute \u2014 ` + "adding x-tooltip here would put a tooltip on a component and may duplicate text it already renders."
146
+ });
147
+ continue;
148
+ }
123
149
  if (/\bx-tooltip[=\s]/.test(openTag) || value.trim() === "") {
124
150
  findings.push({
125
151
  file,
@@ -137,7 +163,19 @@ function applyTooltipRule(source, file, findings) {
137
163
  }
138
164
  return out + source.slice(last);
139
165
  }
166
+ function markupSearchable(source) {
167
+ return source.replace(/<!--[\s\S]*?-->/g, (match) => match.replace(/[^\n]/g, " ")).replace(/\{\{--[\s\S]*?--\}\}/g, (match) => match.replace(/[^\n]/g, " "));
168
+ }
169
+ function blankJsComments(source) {
170
+ const blank = (match) => match.replace(/[^\n]/g, " ");
171
+ return source.replace(/\/\*[\s\S]*?\*\//g, blank).replace(/\/\/[^\n]*/g, blank);
172
+ }
173
+ function searchableSource(source, keepStrings = !1) {
174
+ const markupOnly = markupSearchable(source);
175
+ return (keepStrings ? blankJsComments(markupOnly) : stripCommentsAndLiterals(markupOnly)).replace(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi, (match, attrs) => /\bclient\b/i.test(attrs) ? match : match.replace(/[^\n]/g, " "));
176
+ }
140
177
  function applyReportRules(source, file, rules, findings) {
178
+ const codeView = searchableSource(source), stringView = searchableSource(source, !0);
141
179
  for (const entry of REPORT_RULES) {
142
180
  if (!rules.includes(entry.rule))
143
181
  continue;
@@ -145,7 +183,9 @@ function applyReportRules(source, file, rules, findings) {
145
183
  continue;
146
184
  entry.pattern.lastIndex = 0;
147
185
  let match;
148
- while ((match = entry.pattern.exec(source)) !== null) {
186
+ const searchable = /['"]/.test(entry.pattern.source) ? stringView : codeView;
187
+ entry.pattern.lastIndex = 0;
188
+ while ((match = entry.pattern.exec(searchable)) !== null) {
149
189
  if (entry.reject?.(source, match.index))
150
190
  continue;
151
191
  findings.push({
@@ -168,7 +208,14 @@ export function codemodSource(source, options = {}) {
168
208
  if (rules.includes("tooltip"))
169
209
  code = applyTooltipRule(code, file, findings);
170
210
  findings.sort((a, b) => a.line - b.line);
171
- return { code, findings };
211
+ const seen = new Set, unique = findings.filter((finding) => {
212
+ const key = `${finding.rule}:${finding.line}`;
213
+ if (seen.has(key))
214
+ return !1;
215
+ seen.add(key);
216
+ return !0;
217
+ });
218
+ return { code, findings: unique };
172
219
  }
173
220
  const REWRITABLE = new Set(["confirm", "tooltip"]);
174
221
  export function formatCodemodFindings(findings, wrote) {