@stacksjs/stx 0.2.170 → 0.2.172

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 (44) hide show
  1. package/dist/cli.js +1263 -1168
  2. package/dist/client-script-bundler.js +2 -1
  3. package/dist/client-script.d.ts +1 -12
  4. package/dist/client-script.js +2 -149
  5. package/dist/codemod.d.ts +1 -24
  6. package/dist/codemod.js +61 -9
  7. package/dist/compression.d.ts +11 -4
  8. package/dist/compression.js +52 -13
  9. package/dist/craft.js +489 -415
  10. package/dist/expressions.d.ts +0 -21
  11. package/dist/expressions.js +12 -1
  12. package/dist/find-body-tag.js +29 -17
  13. package/dist/index.d.ts +8 -0
  14. package/dist/index.js +2 -0
  15. package/dist/page-action.d.ts +83 -0
  16. package/dist/page-action.js +64 -0
  17. package/dist/production-builder.js +1 -0
  18. package/dist/production-server.js +10 -3
  19. package/dist/pwa.js +398 -324
  20. package/dist/reactive-tracking.d.ts +81 -0
  21. package/dist/reactive-tracking.js +49 -0
  22. package/dist/reactivity.js +29 -23
  23. package/dist/render.js +5 -18
  24. package/dist/runtime-injection.js +25 -39
  25. package/dist/script-validation.d.ts +0 -7
  26. package/dist/script-validation.js +6 -4
  27. package/dist/signals-api.d.ts +18 -0
  28. package/dist/signals-api.js +26 -41
  29. package/dist/signals.js +93 -15
  30. package/dist/ssg.d.ts +8 -0
  31. package/dist/ssg.js +515 -441
  32. package/dist/state-management.js +2 -2
  33. package/dist/strip-literals.d.ts +40 -0
  34. package/dist/strip-literals.js +149 -0
  35. package/dist/stx-virtual-ts.d.ts +103 -1
  36. package/dist/stx-virtual-ts.js +124 -7
  37. package/dist/template-compiler.js +4 -2
  38. package/dist/template-hydrator.d.ts +1 -1
  39. package/dist/template-hydrator.js +13 -1
  40. package/dist/typecheck.d.ts +2 -14
  41. package/dist/typecheck.js +133 -11
  42. package/dist/types/config-types.d.ts +10 -3
  43. package/dist/variable-extractor.js +1 -1
  44. package/package.json +5 -5
@@ -30,7 +30,8 @@ export function resolveBundlerFallback() {
30
30
  if (_fallbackOverride)
31
31
  return _fallbackOverride;
32
32
  try {
33
- return config.strict?.bundlerFallback ?? "warn";
33
+ const strict = config.strict;
34
+ return typeof strict === "object" && strict !== null ? strict.bundlerFallback ?? "warn" : "warn";
34
35
  } catch {
35
36
  return "warn";
36
37
  }
@@ -32,6 +32,7 @@
32
32
  * @module client-script
33
33
  */
34
34
  import type { ParsedEvent } from './events';
35
+ export { findInterpolationEnd, stripCommentsAndLiterals } from './strip-literals';
35
36
  export interface VendorCssImport {
36
37
  /** The original specifier as written in the import — kept verbatim for the data attribute. */
37
38
  source: string;
@@ -117,18 +118,6 @@ export interface BrowserCoreAutoImportResult {
117
118
  * helpers together with the component, exactly like an explicit user import.
118
119
  */
119
120
  export declare function injectBrowserCoreAutoImports(code: string): BrowserCoreAutoImportResult;
120
- /**
121
- * Blank out comments and string literals, preserving length and line structure
122
- * so offsets still line up, so that identifier detection only ever sees code.
123
- *
124
- * Template-literal INTERPOLATIONS are kept, because `${...}` is code, not text.
125
- * Blanking them hid every identifier that appears only inside one — a helper
126
- * called as `${formatDate(d)}` looked unused to the auto-import scanner, and a
127
- * server value read as `` `/api/users/${userId}` `` looked unreferenced to the
128
- * data bridge, which would have withheld it and left the client throwing on an
129
- * undefined name.
130
- */
131
- export declare function stripCommentsAndLiterals(code: string): string;
132
121
  export interface ClientScriptOptions {
133
122
  /** Event bindings collected from template @event attributes */
134
123
  eventBindings?: ParsedEvent[];
@@ -2,6 +2,8 @@ import path from "node:path";
2
2
  import fs from "node:fs";
3
3
  import { asInvocableStatement } from "./events.js";
4
4
  import { transformStoreImports } from "./store-imports.js";
5
+ import { findInterpolationEnd, stripCommentsAndLiterals } from "./strip-literals.js";
6
+ export { findInterpolationEnd, stripCommentsAndLiterals } from "./strip-literals.js";
5
7
  import { shouldTranspileTypeScript, transpileTypeScript } from "./utils.js";
6
8
  import { importOnce } from "./lazy-module.js";
7
9
  import { STX_RUNTIME_GLOBALS, usesReactiveRuntime } from "./runtime-globals.js";
@@ -263,155 +265,6 @@ const JS_BUILTINS = new Set([
263
265
  "Modal",
264
266
  "Intl"
265
267
  ]);
266
- function skipQuoted(code, start) {
267
- const quote = code[start];
268
- let i = start + 1;
269
- while (i < code.length) {
270
- if (code[i] === "\\") {
271
- i += 2;
272
- continue;
273
- }
274
- if (code[i] === quote)
275
- return i + 1;
276
- i++;
277
- }
278
- return code.length;
279
- }
280
- function skipTemplate(code, start) {
281
- let i = start + 1;
282
- while (i < code.length) {
283
- if (code[i] === "\\") {
284
- i += 2;
285
- continue;
286
- }
287
- if (code[i] === "`")
288
- return i + 1;
289
- if (code[i] === "$" && code[i + 1] === "{") {
290
- i = findInterpolationEnd(code, i + 2);
291
- if (i < code.length)
292
- i++;
293
- continue;
294
- }
295
- i++;
296
- }
297
- return code.length;
298
- }
299
- function findInterpolationEnd(code, start) {
300
- let depth = 1, i = start;
301
- while (i < code.length) {
302
- const ch = code[i];
303
- if (ch === "\\") {
304
- i += 2;
305
- continue;
306
- }
307
- if (ch === '"' || ch === "'") {
308
- i = skipQuoted(code, i);
309
- continue;
310
- }
311
- if (ch === "`") {
312
- i = skipTemplate(code, i);
313
- continue;
314
- }
315
- if (ch === "/" && code[i + 1] === "/") {
316
- while (i < code.length && code[i] !== `
317
- `)
318
- i++;
319
- continue;
320
- }
321
- if (ch === "/" && code[i + 1] === "*") {
322
- i += 2;
323
- while (i < code.length && code.slice(i, i + 2) !== "*/")
324
- i++;
325
- i += 2;
326
- continue;
327
- }
328
- if (ch === "{") {
329
- depth++;
330
- i++;
331
- continue;
332
- }
333
- if (ch === "}") {
334
- depth--;
335
- if (depth === 0)
336
- return i;
337
- i++;
338
- continue;
339
- }
340
- i++;
341
- }
342
- return code.length;
343
- }
344
- export function stripCommentsAndLiterals(code) {
345
- let out = "", i = 0;
346
- while (i < code.length) {
347
- const two = code.slice(i, i + 2);
348
- if (two === "//") {
349
- while (i < code.length && code[i] !== `
350
- `)
351
- out += " ", i++;
352
- continue;
353
- }
354
- if (two === "/*") {
355
- while (i < code.length && code.slice(i, i + 2) !== "*/")
356
- out += code[i] === `
357
- ` ? `
358
- ` : " ", i++;
359
- out += " ";
360
- i += 2;
361
- continue;
362
- }
363
- const ch = code[i];
364
- if (ch === '"' || ch === "'") {
365
- const quote = ch;
366
- out += " ";
367
- i++;
368
- while (i < code.length && code[i] !== quote) {
369
- if (code[i] === "\\") {
370
- out += " ";
371
- i += 2;
372
- continue;
373
- }
374
- out += code[i] === `
375
- ` ? `
376
- ` : " ";
377
- i++;
378
- }
379
- out += " ";
380
- i++;
381
- continue;
382
- }
383
- if (ch === "`") {
384
- out += " ";
385
- i++;
386
- while (i < code.length && code[i] !== "`") {
387
- if (code[i] === "\\") {
388
- out += " ";
389
- i += 2;
390
- continue;
391
- }
392
- if (code[i] === "$" && code[i + 1] === "{") {
393
- const bodyStart = i + 2, bodyEnd = findInterpolationEnd(code, bodyStart);
394
- out += " ";
395
- out += stripCommentsAndLiterals(code.slice(bodyStart, bodyEnd));
396
- if (bodyEnd < code.length)
397
- out += " ";
398
- i = bodyEnd + 1;
399
- continue;
400
- }
401
- out += code[i] === `
402
- ` ? `
403
- ` : " ";
404
- i++;
405
- }
406
- out += " ";
407
- i++;
408
- continue;
409
- }
410
- out += ch;
411
- i++;
412
- }
413
- return out;
414
- }
415
268
  function detectModelUsage(code) {
416
269
  const models = new Set, modelPattern = /\b([A-Z][a-zA-Z0-9]*)\s*\.\s*(all|find|first|get|where|orderBy|orderByDesc|limit|select|pluck|create|update|delete)\s*\(/g;
417
270
  let match;
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",
@@ -87,10 +102,15 @@ function isInsideAsyncFunction(source, index) {
87
102
  const window = source.slice(Math.max(0, boundary - 120), boundary);
88
103
  return /\basync\b[^;{}]*$/.test(window);
89
104
  }
105
+ function declaresOwnConfirm(source) {
106
+ return /\b(?:async\s+)?function\s+confirm\s*\(/.test(source) || /\b(?:const|let|var)\s+confirm\s*=/.test(source) || /\bimport\s*\{[^}]*\bconfirm\b[^}]*\}/.test(source);
107
+ }
90
108
  function applyConfirmRule(source, file, findings) {
91
- const pattern = /(^|[^\w$.])confirm\s*\(/g;
109
+ if (declaresOwnConfirm(source))
110
+ return source;
111
+ const pattern = /(^|[^\w$.])(?:(?:window|globalThis)\s*\.\s*)?confirm\s*\(/g, searchable = searchableSource(source);
92
112
  let out = "", last = 0, match;
93
- while ((match = pattern.exec(source)) !== null) {
113
+ while ((match = pattern.exec(searchable)) !== null) {
94
114
  const callAt = match.index + match[1].length, line = lineOf(source, callAt), snippet = snippetAt(source, callAt);
95
115
  if (/stxConfirm\s*\($/.test(source.slice(0, callAt + 8)))
96
116
  continue;
@@ -105,16 +125,27 @@ function applyConfirmRule(source, file, findings) {
105
125
  });
106
126
  if (safe) {
107
127
  out += source.slice(last, callAt) + "await stxConfirm(";
108
- last = callAt + 8;
128
+ last = callAt + (match[0].length - match[1].length);
109
129
  }
110
130
  }
111
131
  return out + source.slice(last);
112
132
  }
113
133
  function applyTooltipRule(source, file, findings) {
114
- const pattern = /(<[a-z][\w-]*\b[^>]*?)\stitle=(["'])(.*?)\2/gis;
134
+ const pattern = /(<[a-z][\w-]*\b[^>]*?)\stitle=(["'])(.*?)\2/gis, searchable = markupSearchable(source);
115
135
  let out = "", last = 0, match;
116
- while ((match = pattern.exec(source)) !== null) {
117
- 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
+ }
118
149
  if (/\bx-tooltip[=\s]/.test(openTag) || value.trim() === "") {
119
150
  findings.push({
120
151
  file,
@@ -132,7 +163,19 @@ function applyTooltipRule(source, file, findings) {
132
163
  }
133
164
  return out + source.slice(last);
134
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
+ }
135
177
  function applyReportRules(source, file, rules, findings) {
178
+ const codeView = searchableSource(source), stringView = searchableSource(source, !0);
136
179
  for (const entry of REPORT_RULES) {
137
180
  if (!rules.includes(entry.rule))
138
181
  continue;
@@ -140,7 +183,9 @@ function applyReportRules(source, file, rules, findings) {
140
183
  continue;
141
184
  entry.pattern.lastIndex = 0;
142
185
  let match;
143
- 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) {
144
189
  if (entry.reject?.(source, match.index))
145
190
  continue;
146
191
  findings.push({
@@ -163,7 +208,14 @@ export function codemodSource(source, options = {}) {
163
208
  if (rules.includes("tooltip"))
164
209
  code = applyTooltipRule(code, file, findings);
165
210
  findings.sort((a, b) => a.line - b.line);
166
- 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 };
167
219
  }
168
220
  const REWRITABLE = new Set(["confirm", "tooltip"]);
169
221
  export function formatCodemodFindings(findings, wrote) {
@@ -3,10 +3,17 @@ export declare function isCompressible(contentType: string | null): boolean;
3
3
  /**
4
4
  * The best encoding both sides support, or `null` for none.
5
5
  *
6
- * Deliberately ignores the q-values in `Accept-Encoding`. Honouring them means
7
- * parsing a header that is, in practice, one of about four fixed strings every
8
- * real browser sends, all of which accept both encodings; the only clients
9
- * that weight them are testing tools.
6
+ * This used to be `accepted.includes('br')` on the raw header, on the argument
7
+ * that q-values are only ever sent by testing tools. The substring is the
8
+ * problem: `br;q=0` contains `br`, and it is the one string in the header that
9
+ * means *do not send me brotli*. Answering that with a brotli body is not a
10
+ * missed optimisation, it is a response the client cannot read — the failure
11
+ * this function's `Vary` sibling exists to prevent, arrived at from the other
12
+ * direction.
13
+ *
14
+ * Brotli is still preferred when both are genuinely on offer: ~20% smaller
15
+ * than gzip on HTML at comparable cost, and every browser without it predates
16
+ * 2017.
10
17
  */
11
18
  export declare function negotiateEncoding(acceptEncoding: string | null): 'br' | 'gzip' | null;
12
19
  /**
@@ -1,5 +1,6 @@
1
1
  import zlib from "node:zlib";
2
- const COMPRESSIBLE = [
2
+ import { promisify } from "node:util";
3
+ const brotliCompress = promisify(zlib.brotliCompress), gzipCompress = promisify(zlib.gzip), COMPRESSIBLE = [
3
4
  "text/",
4
5
  "application/json",
5
6
  "application/javascript",
@@ -21,13 +22,32 @@ export function isCompressible(contentType) {
21
22
  return !1;
22
23
  return COMPRESSIBLE.some((prefix) => type.startsWith(prefix));
23
24
  }
25
+ function parseAcceptEncoding(header) {
26
+ const weights = new Map;
27
+ for (const part of header.split(",")) {
28
+ const [rawName, ...params] = part.trim().split(";"), name = rawName.trim().toLowerCase();
29
+ if (!name)
30
+ continue;
31
+ let quality = 1;
32
+ for (const param of params) {
33
+ const match = /^\s*q\s*=\s*([\d.]+)\s*$/i.exec(param);
34
+ if (!match)
35
+ continue;
36
+ const parsed = Number.parseFloat(match[1]);
37
+ if (Number.isFinite(parsed))
38
+ quality = parsed;
39
+ }
40
+ weights.set(name, quality);
41
+ }
42
+ return weights;
43
+ }
24
44
  export function negotiateEncoding(acceptEncoding) {
25
45
  if (!acceptEncoding)
26
46
  return null;
27
- const accepted = acceptEncoding.toLowerCase();
28
- if (accepted.includes("br"))
47
+ const weights = parseAcceptEncoding(acceptEncoding), wildcard = weights.get("*"), quality = (coding) => weights.get(coding) ?? wildcard ?? 0;
48
+ if (quality("br") > 0)
29
49
  return "br";
30
- if (accepted.includes("gzip"))
50
+ if (quality("gzip") > 0)
31
51
  return "gzip";
32
52
  return null;
33
53
  }
@@ -41,28 +61,47 @@ export async function compressResponse(request, response, options = {}) {
41
61
  const encoding = negotiateEncoding(request.headers.get("accept-encoding"));
42
62
  if (!encoding)
43
63
  return response;
64
+ let body;
44
65
  try {
45
- const body = Buffer.from(await response.clone().arrayBuffer());
46
- if (body.length < (options.threshold ?? MIN_BYTES))
47
- return response;
48
- const compressed = encoding === "br" ? zlib.brotliCompressSync(body, {
66
+ body = new Uint8Array(await response.arrayBuffer());
67
+ } catch {
68
+ return response;
69
+ }
70
+ const passthrough = () => new Response(body, {
71
+ status: response.status,
72
+ statusText: response.statusText,
73
+ headers: varyByAcceptEncoding(response.headers)
74
+ });
75
+ if (body.length < (options.threshold ?? MIN_BYTES))
76
+ return passthrough();
77
+ try {
78
+ const compressed = encoding === "br" ? await brotliCompress(body, {
49
79
  params: {
50
80
  [zlib.constants.BROTLI_PARAM_QUALITY]: options.quality ?? 5,
51
81
  [zlib.constants.BROTLI_PARAM_SIZE_HINT]: body.length
52
82
  }
53
- }) : zlib.gzipSync(body);
83
+ }) : await gzipCompress(body);
54
84
  if (compressed.length >= body.length)
55
- return response;
56
- const headers = new Headers(response.headers);
85
+ return passthrough();
86
+ const headers = varyByAcceptEncoding(response.headers);
57
87
  headers.set("Content-Encoding", encoding);
58
88
  headers.set("Content-Length", String(compressed.length));
59
- headers.append("Vary", "Accept-Encoding");
60
89
  return new Response(compressed, {
61
90
  status: response.status,
62
91
  statusText: response.statusText,
63
92
  headers
64
93
  });
65
94
  } catch {
66
- return response;
95
+ return passthrough();
96
+ }
97
+ }
98
+ function varyByAcceptEncoding(source) {
99
+ const headers = new Headers(source), existing = headers.get("Vary");
100
+ if (!existing) {
101
+ headers.set("Vary", "Accept-Encoding");
102
+ return headers;
67
103
  }
104
+ if (!existing.split(",").some((field) => field.trim().toLowerCase() === "accept-encoding"))
105
+ headers.set("Vary", `${existing}, Accept-Encoding`);
106
+ return headers;
68
107
  }