@stacksjs/stx 0.2.145 → 0.2.147

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.
@@ -43,6 +43,11 @@ export declare function renderVendorStyleTags(styles: VendorCssImport[]): string
43
43
  * helpers together with the component, exactly like an explicit user import.
44
44
  */
45
45
  export declare function injectBrowserCoreAutoImports(code: string): BrowserCoreAutoImportResult;
46
+ /**
47
+ * Blank out comments and string literals, preserving length and line structure
48
+ * so offsets still line up, so that identifier detection only ever sees code.
49
+ */
50
+ export declare function stripCommentsAndLiterals(code: string): string;
46
51
  /**
47
52
  * Generate destructuring statements for auto-imported symbols
48
53
  * These access globals set up by the STX runtime and @stacksjs/browser
@@ -250,7 +250,7 @@ const STX_AUTO_IMPORTS = [
250
250
  "useIntervalFn"
251
251
  ];
252
252
  export function injectBrowserCoreAutoImports(code) {
253
- const explicitlyImported = new Set;
253
+ const searchable = stripCommentsAndLiterals(code), explicitlyImported = new Set;
254
254
  for (const match of code.matchAll(/^\s*import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+['"][^'"]+['"]\s*;?\s*$/gm))
255
255
  for (const specifier of match[1].split(",")) {
256
256
  const localName = specifier.trim().split(/\s+as\s+/).pop()?.replace(/^type\s+/, "").trim();
@@ -258,9 +258,9 @@ export function injectBrowserCoreAutoImports(code) {
258
258
  explicitlyImported.add(localName);
259
259
  }
260
260
  const locallyDeclared = new Set;
261
- for (const match of code.matchAll(/\b(?:const|let|var|function|class)\s+([A-Za-z_$][\w$]*)/g))
261
+ for (const match of searchable.matchAll(/\b(?:const|let|var|function|class)\s+([A-Za-z_$][\w$]*)/g))
262
262
  locallyDeclared.add(match[1]);
263
- for (const match of code.matchAll(/\b(?:const|let|var)\s*\{([^}]*)\}/g))
263
+ for (const match of searchable.matchAll(/\b(?:const|let|var)\s*\{([^}]*)\}/g))
264
264
  for (const binding of match[1].split(",")) {
265
265
  const localName = binding.trim().split(/[:=]/).pop()?.trim();
266
266
  if (localName)
@@ -269,8 +269,8 @@ export function injectBrowserCoreAutoImports(code) {
269
269
  const imports = BROWSER_CORE_IMPORTS.filter((symbol) => {
270
270
  if (explicitlyImported.has(symbol) || locallyDeclared.has(symbol))
271
271
  return !1;
272
- return new RegExp(`\\b${symbol}\\b`).test(code);
273
- }), models = detectModelUsage(code).filter((model) => !explicitlyImported.has(model) && !locallyDeclared.has(model));
272
+ return new RegExp(`\\b${symbol}\\s*\\(`).test(searchable);
273
+ }), models = detectModelUsage(searchable).filter((model) => !explicitlyImported.has(model) && !locallyDeclared.has(model));
274
274
  if (imports.length === 0 && models.length === 0)
275
275
  return { code, imports, models };
276
276
  return {
@@ -342,6 +342,50 @@ const JS_BUILTINS = new Set([
342
342
  "Modal",
343
343
  "Intl"
344
344
  ]);
345
+ export function stripCommentsAndLiterals(code) {
346
+ let out = "", i = 0;
347
+ while (i < code.length) {
348
+ const two = code.slice(i, i + 2);
349
+ if (two === "//") {
350
+ while (i < code.length && code[i] !== `
351
+ `)
352
+ out += " ", i++;
353
+ continue;
354
+ }
355
+ if (two === "/*") {
356
+ while (i < code.length && code.slice(i, i + 2) !== "*/")
357
+ out += code[i] === `
358
+ ` ? `
359
+ ` : " ", i++;
360
+ out += " ";
361
+ i += 2;
362
+ continue;
363
+ }
364
+ const ch = code[i];
365
+ if (ch === '"' || ch === "'" || ch === "`") {
366
+ const quote = ch;
367
+ out += " ";
368
+ i++;
369
+ while (i < code.length && code[i] !== quote) {
370
+ if (code[i] === "\\") {
371
+ out += " ";
372
+ i += 2;
373
+ continue;
374
+ }
375
+ out += code[i] === `
376
+ ` ? `
377
+ ` : " ";
378
+ i++;
379
+ }
380
+ out += " ";
381
+ i++;
382
+ continue;
383
+ }
384
+ out += ch;
385
+ i++;
386
+ }
387
+ return out;
388
+ }
345
389
  function detectModelUsage(code) {
346
390
  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;
347
391
  let match;
@@ -350,6 +394,18 @@ function detectModelUsage(code) {
350
394
  models.add(match[1]);
351
395
  return Array.from(models);
352
396
  }
397
+ function collectLocalBindings(code) {
398
+ const bindings = new Set;
399
+ for (const match of code.matchAll(/\b(?:const|let|var|function|class)\s+([A-Za-z_$][\w$]*)/g))
400
+ bindings.add(match[1]);
401
+ for (const match of code.matchAll(/\b(?:const|let|var)\s*\{([^}]*)\}/g))
402
+ for (const binding of match[1].split(",")) {
403
+ const localName = binding.trim().split(/[:=]/).pop()?.trim();
404
+ if (localName && /^[A-Za-z_$][\w$]*$/.test(localName))
405
+ bindings.add(localName);
406
+ }
407
+ return bindings;
408
+ }
353
409
  function transformAutoImports(code) {
354
410
  const usedStxImports = new Set, usedBrowserImports = new Set;
355
411
  let transformedCode = code;
@@ -360,30 +416,43 @@ function transformAutoImports(code) {
360
416
  const imports = match[1], source = match[2];
361
417
  if (match[0].includes("import type"))
362
418
  continue;
363
- imports.split(",").map((s) => s.trim().split(/\s+as\s+/)[0]).filter((s) => s.length > 0).forEach((name) => {
364
- existingImports.add(name);
419
+ const aliases = [];
420
+ for (const rawSpecifier of imports.split(",")) {
421
+ const specifier = rawSpecifier.trim();
422
+ if (!specifier || specifier.startsWith("type "))
423
+ continue;
424
+ const aliasMatch = specifier.match(/^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
425
+ if (!aliasMatch)
426
+ continue;
427
+ const importedName = aliasMatch[1], localName = aliasMatch[2] || importedName;
428
+ existingImports.add(importedName);
429
+ existingImports.add(localName);
365
430
  if (source === "stx" || source === "@stacksjs/stx")
366
- usedStxImports.add(name);
431
+ usedStxImports.add(importedName);
367
432
  else
368
- usedBrowserImports.add(name);
369
- });
370
- transformedCode = transformedCode.replace(match[0], "// [auto-import processed]");
433
+ usedBrowserImports.add(importedName);
434
+ if (localName !== importedName)
435
+ aliases.push(`${localName} = ${importedName}`);
436
+ }
437
+ const replacement = aliases.length > 0 ? `var ${aliases.join(", ")}; /* [auto-import processed] */` : "// [auto-import processed]";
438
+ transformedCode = transformedCode.replace(match[0], replacement);
371
439
  }
440
+ const locallyDeclared = collectLocalBindings(transformedCode);
372
441
  for (const symbol of STX_AUTO_IMPORTS) {
373
- if (existingImports.has(symbol))
442
+ if (existingImports.has(symbol) || locallyDeclared.has(symbol))
374
443
  continue;
375
444
  if (new RegExp(`\\b${symbol}\\s*\\(`, "g").test(transformedCode))
376
445
  usedStxImports.add(symbol);
377
446
  }
378
447
  for (const symbol of BROWSER_CORE_IMPORTS) {
379
- if (existingImports.has(symbol))
448
+ if (existingImports.has(symbol) || locallyDeclared.has(symbol))
380
449
  continue;
381
450
  if (new RegExp(`\\b${symbol}\\b`, "g").test(transformedCode))
382
451
  usedBrowserImports.add(symbol);
383
452
  }
384
453
  const detectedModels = detectModelUsage(transformedCode);
385
454
  for (const model of detectedModels)
386
- if (!existingImports.has(model))
455
+ if (!existingImports.has(model) && !locallyDeclared.has(model))
387
456
  usedBrowserImports.add(model);
388
457
  return {
389
458
  code: transformedCode,
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Build the synchronous pre-paint `<script>`.
3
+ *
4
+ * Every storage access is wrapped: a sandboxed origin or disabled storage must
5
+ * degrade to the default mode, never throw. A throw here is unusually costly
6
+ * because the script sits above the application shell — an uncaught error would
7
+ * block the rest of the document.
8
+ */
9
+ export declare function generateColorModeBootScript(options?: ColorModeBootConfig): string;
10
+ /**
11
+ * Insert the boot script as the first thing in `<head>`.
12
+ *
13
+ * Placement matters twice over: the script must run before the browser paints,
14
+ * and a stylesheet `<link>` above it would block its execution (CSS blocks JS),
15
+ * delaying the very thing it exists to make early.
16
+ *
17
+ * `<meta charset>` is stepped over when it leads the head, so the encoding
18
+ * declaration keeps its place at the top of the document.
19
+ *
20
+ * Idempotent — a document that already carries the marker is returned
21
+ * unchanged, so this is safe on paths that run it more than once.
22
+ */
23
+ export declare function injectColorModeBootScript(html: string, options?: ColorModeBootConfig): string;
24
+ /**
25
+ * Pre-paint color-mode bootstrap (stacksjs/stx#1794).
26
+ *
27
+ * `useColorMode` is part of the client signals runtime: it reads localStorage
28
+ * at call time, after the runtime bundle has loaded and hydration has begun.
29
+ * That is too late. Applying the theme post-hydration guarantees a flash of the
30
+ * wrong theme on every cold load for every user whose stored preference differs
31
+ * from the default — which is why apps hand-write a render-blocking snippet in
32
+ * `<head>` and why `useColorMode` could not replace it.
33
+ *
34
+ * This module emits that snippet from the *same* option surface `useColorMode`
35
+ * takes, so the duplication collapses into one declaration in `stx.config.ts`:
36
+ *
37
+ * ```ts
38
+ * // stx.config.ts
39
+ * export default {
40
+ * app: {
41
+ * colorMode: { storageKey: 'theme', attribute: 'data-theme' },
42
+ * },
43
+ * }
44
+ * ```
45
+ *
46
+ * The generated script is deliberately render-blocking, is injected as the
47
+ * first thing inside `<head>` (above any stylesheet), and touches the root
48
+ * element synchronously.
49
+ *
50
+ * ## Staying in lockstep with useColorMode
51
+ *
52
+ * The boot script must apply *exactly* what `useColorMode` will apply on
53
+ * hydration. If they disagree the flash comes back — the boot script would set
54
+ * one thing and hydration would immediately undo it, which is worse than not
55
+ * having a boot script at all. So this mirrors the runtime's `applyDOM` and
56
+ * `readPersisted` precisely, including two limitations that are NOT fixed here:
57
+ *
58
+ * - `attribute` and `darkClass` are either/or, never both (stacksjs/stx#1788)
59
+ * - the persisted vocabulary is exactly `light | dark | auto` — `'system'`
60
+ * is not accepted (also #1788)
61
+ *
62
+ * Mirroring the limitation is intentional. When #1788 lands, both sides change
63
+ * together; `test/directives/color-mode-boot.test.ts` pins them to the same
64
+ * expectations so drift fails the suite rather than shipping a flash.
65
+ *
66
+ * The script publishes its resolved configuration on `window.__STX_COLOR_MODE__`
67
+ * so a bare `useColorMode()` call picks up the app's configured storage key and
68
+ * attribute without the options having to be repeated at the call site.
69
+ *
70
+ * @module color-mode-boot
71
+ */
72
+ /** Marker attribute — makes injection idempotent and findable by tests. */
73
+ export declare const COLOR_MODE_BOOT_MARKER: 'data-stx-color-mode-boot';
74
+ /** The global the boot script publishes its resolved config on. */
75
+ export declare const COLOR_MODE_BOOT_GLOBAL: '__STX_COLOR_MODE__';
76
+ /**
77
+ * Pre-paint options. A strict subset of `ColorModeOptions` — only the fields
78
+ * that affect what lands on the root element before paint. `disableTransitions`
79
+ * is deliberately absent: nothing has painted yet, so there is no transition to
80
+ * suppress, and forcing a reflow in the critical path would only cost time.
81
+ */
82
+ export declare interface ColorModeBootConfig {
83
+ storageKey?: string
84
+ initialMode?: ColorModeBootMode
85
+ darkClass?: string
86
+ attribute?: string
87
+ }
88
+ export type ColorModeBootMode = 'light' | 'dark' | 'auto';
@@ -0,0 +1,35 @@
1
+ export const COLOR_MODE_BOOT_MARKER = "data-stx-color-mode-boot", COLOR_MODE_BOOT_GLOBAL = "__STX_COLOR_MODE__";
2
+ const MODES = new Set(["light", "dark", "auto"]), ATTRIBUTE_PATTERN = /^[a-z][\w:-]*$/i, CLASS_PATTERN = /^[\w-]+$/;
3
+ function resolveConfig(options) {
4
+ const storageKey = options.storageKey ?? "stx-color-mode", initialMode = options.initialMode ?? "auto", darkClass = options.darkClass ?? "dark", attribute = options.attribute ?? null;
5
+ if (typeof storageKey !== "string" || storageKey.length === 0)
6
+ throw Error("colorMode.storageKey must be a non-empty string");
7
+ if (!MODES.has(initialMode))
8
+ throw Error(`colorMode.initialMode must be one of light, dark, auto \u2014 got ${JSON.stringify(initialMode)}`);
9
+ if (typeof darkClass !== "string" || !CLASS_PATTERN.test(darkClass))
10
+ throw Error(`colorMode.darkClass must be a single CSS class name \u2014 got ${JSON.stringify(darkClass)}`);
11
+ if (attribute !== null && (typeof attribute !== "string" || !ATTRIBUTE_PATTERN.test(attribute)))
12
+ throw Error(`colorMode.attribute must be a valid HTML attribute name \u2014 got ${JSON.stringify(attribute)}`);
13
+ return { storageKey, initialMode, darkClass, attribute };
14
+ }
15
+ function serializeForScript(value) {
16
+ return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
17
+ }
18
+ export function generateColorModeBootScript(options = {}) {
19
+ const config = resolveConfig(options), body = `(function(){"use strict";var c=${serializeForScript(config)};var p=c.initialMode;try{var v=localStorage.getItem(c.storageKey);if(v==="light"||v==="dark"||v==="auto")p=v}catch(e){}var m=p;if(p==="auto"){try{m=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}catch(e){m="light"}}var r=document.documentElement;if(c.attribute){r.setAttribute(c.attribute,m)}else if(m==="dark"){r.classList.add(c.darkClass)}else{r.classList.remove(c.darkClass)}try{window.${COLOR_MODE_BOOT_GLOBAL}={storageKey:c.storageKey,initialMode:c.initialMode,darkClass:c.darkClass,attribute:c.attribute,preference:p,mode:m}}catch(e){}}());`;
20
+ return `<script ${COLOR_MODE_BOOT_MARKER}>${body}</script>`;
21
+ }
22
+ export function injectColorModeBootScript(html, options = {}) {
23
+ if (html.includes(COLOR_MODE_BOOT_MARKER))
24
+ return html;
25
+ const headOpen = /<head\b[^>]*>/i.exec(html);
26
+ if (!headOpen)
27
+ return html;
28
+ let insertAt = headOpen.index + headOpen[0].length;
29
+ const afterHead = html.slice(insertAt), charset = /^\s*<meta\b[^>]*\bcharset\b[^>]*>/i.exec(afterHead);
30
+ if (charset)
31
+ insertAt += charset[0].length;
32
+ const script = generateColorModeBootScript(options);
33
+ return `${html.slice(0, insertAt)}
34
+ ${script}${html.slice(insertAt)}`;
35
+ }
@@ -1,9 +1,9 @@
1
1
  export function useColorMode(options = {}) {
2
- const {
3
- storageKey = "stx-color-mode",
4
- initialMode = "auto",
5
- darkClass = "dark",
6
- attribute,
2
+ const boot = typeof window < "u" ? window.__STX_COLOR_MODE__ : null, {
3
+ storageKey = boot?.storageKey ?? "stx-color-mode",
4
+ initialMode = boot?.initialMode ?? "auto",
5
+ darkClass = boot?.darkClass ?? "dark",
6
+ attribute = boot?.attribute,
7
7
  disableTransitions = !0
8
8
  } = options;
9
9
  let preference = initialMode, resolved = "light";
@@ -61,7 +61,12 @@ export function useStorage(key, defaultValue, options = {}) {
61
61
  const onStorage = (event) => {
62
62
  if (event.key === key && event.storageArea === getStorage()) {
63
63
  const prevValue = currentValue;
64
- currentValue = event.newValue ? serializer.read(event.newValue) : defaultValue;
64
+ try {
65
+ currentValue = event.newValue ? serializer.read(event.newValue) : defaultValue;
66
+ } catch (e) {
67
+ console.warn(`[useStorage] Failed to read key "${key}" from a cross-tab update:`, e);
68
+ return;
69
+ }
65
70
  notify(currentValue, prevValue);
66
71
  }
67
72
  };