@stacksjs/stx 0.2.112 → 0.2.114

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.
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
  import fs from "node:fs";
3
3
  import { getPublicEnvDefine } from "./public-env.js";
4
4
  import { stateDir } from "./state-dir.js";
5
- const EXTERNAL_PATTERNS = [
5
+ const BUNDLE_CACHE_VERSION = 4, EXTERNAL_PATTERNS = [
6
6
  /^stx$/,
7
7
  /^@stacksjs\/stx$/,
8
8
  /^@stores$/,
@@ -10,10 +10,7 @@ const EXTERNAL_PATTERNS = [
10
10
  /^@composables$/
11
11
  ];
12
12
  export function hasUserImports(code) {
13
- const importRegex = /^\s*import\s+(?!type\s)/gm;
14
- if (!code.match(importRegex))
15
- return !1;
16
- const fullImportRegex = /^\s*import\s+(?!type\s).*?\s+from\s+['"]([^'"]+)['"]/gm;
13
+ const fullImportRegex = /^[ \t]*import[ \t]+(?!type(?:[ \t]|{))[\s\S]*?\s+from\s+['"]([^'"]+)['"][ \t]*;?/gm;
17
14
  let match;
18
15
  while ((match = fullImportRegex.exec(code)) !== null) {
19
16
  const source = match[1];
@@ -22,7 +19,7 @@ export function hasUserImports(code) {
22
19
  return !0;
23
20
  }
24
21
  }
25
- const sideEffectRegex = /^\s*import\s+['"]([^'"]+)['"]/gm;
22
+ const sideEffectRegex = /^[ \t]*import[ \t]+['"]([^'"]+)['"]/gm;
26
23
  while ((match = sideEffectRegex.exec(code)) !== null) {
27
24
  const source = match[1];
28
25
  if (/\.css(?:\?.*)?$/.test(source))
@@ -66,6 +63,14 @@ function createBundlePlugin(projectRoot, templateDir, tmpEntry, inputFiles) {
66
63
  path: args.path,
67
64
  external: !0
68
65
  }));
66
+ build.onResolve({ filter: /^\// }, (args) => {
67
+ if (fs.existsSync(args.path))
68
+ return { path: args.path };
69
+ return {
70
+ path: args.path,
71
+ external: !0
72
+ };
73
+ });
69
74
  build.onResolve({ filter: /^\.\.?\// }, (args) => {
70
75
  const resolved = resolveRelative(args.importer, args.path);
71
76
  inputFiles.add(resolved);
@@ -98,7 +103,7 @@ function createBundlePlugin(projectRoot, templateDir, tmpEntry, inputFiles) {
98
103
  }
99
104
  export async function bundleClientScript(code, filePath, options = {}) {
100
105
  const projectRoot = options.projectRoot || process.cwd(), minify = options.minify ?? !1, cacheDir = options.cacheDir || stateDir(projectRoot, "bundle-cache"), hasher = new Bun.CryptoHasher("md5");
101
- hasher.update(code + filePath);
106
+ hasher.update(`${BUNDLE_CACHE_VERSION}\x00${code}\x00${filePath}`);
102
107
  const hash = hasher.digest("hex").slice(0, 12), cachePath = path.join(cacheDir, `${hash}.js`), depsPath = path.join(cacheDir, `${hash}.deps.json`), cacheFile = Bun.file(cachePath);
103
108
  if (await cacheFile.exists()) {
104
109
  const depsFile = Bun.file(depsPath);
@@ -176,19 +181,31 @@ export { ${declNames.join(", ")} }` : "";
176
181
  bundled = await output.text();
177
182
  break;
178
183
  }
179
- const renamePairs = [];
184
+ const exposedBindings = [];
180
185
  for (const exp of bundled.matchAll(/^\s*export\s*\{([^}]*)\}\s*;?\s*$/gm))
181
186
  for (const spec of exp[1].split(",")) {
182
187
  const asMatch = spec.trim().match(/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/);
183
- if (asMatch && asMatch[1] !== asMatch[2])
184
- renamePairs.push([asMatch[2], asMatch[1]]);
188
+ if (asMatch) {
189
+ exposedBindings.push([asMatch[2], asMatch[1]]);
190
+ continue;
191
+ }
192
+ const name = spec.trim();
193
+ if (/^[A-Za-z_$][\w$]*$/.test(name))
194
+ exposedBindings.push([name, name]);
185
195
  }
186
- bundled = bundled.replace(/^\s*export\s*\{[^}]*\}\s*;?\s*$/gm, "").replace(/^\s*export\s+default\s+.*$/gm, "").trim();
187
- if (renamePairs.length > 0) {
188
- const entries = renamePairs.map(([orig, id]) => `${JSON.stringify(orig)}: ${id}`).join(", ");
189
- bundled += `
190
- var __stxScopeRenames = { ${entries} };`;
191
- }
196
+ const externalImports = bundled.match(/^\s*import\s+[^;]+;?\s*$/gm) || [];
197
+ bundled = bundled.replace(/^\s*import\s+[^;]+;?\s*$/gm, "").replace(/^\s*export\s*\{[^}]*\}\s*;?\s*$/gm, "").replace(/^\s*export\s+default\s+.*$/gm, "").trim();
198
+ const namespace = `__stxBundle_${hash}`, returnedBindings = exposedBindings.map(([publicName, localName]) => `${JSON.stringify(publicName)}: ${localName}`).join(", "), publicDeclarations = exposedBindings.map(([publicName]) => `var ${publicName} = undefined;`).join(`
199
+ `), publicAssignments = exposedBindings.map(([publicName]) => `${publicName} = ${namespace}[${JSON.stringify(publicName)}];`).join(`
200
+ `);
201
+ bundled = `${externalImports.join(`
202
+ `)}
203
+ ${publicDeclarations}
204
+ var ${namespace} = (function() {
205
+ ${bundled}
206
+ return { ${returnedBindings} };
207
+ })();
208
+ ${publicAssignments}`.trim();
192
209
  console.log("[stx:bundler] bundled:", hash, "output:", bundled.length, "bytes");
193
210
  fs.mkdirSync(cacheDir, { recursive: !0 });
194
211
  await Bun.write(cachePath, bundled);
@@ -184,6 +184,11 @@ const STX_AUTO_IMPORTS = [
184
184
  "useAsync",
185
185
  "useColorMode",
186
186
  "useDark",
187
+ "useMediaQuery",
188
+ "usePreferredDark",
189
+ "usePreferredLight",
190
+ "usePreferredReducedMotion",
191
+ "usePreferredContrast",
187
192
  "ref",
188
193
  "reactive",
189
194
  "computed",
@@ -210,6 +215,7 @@ const STX_AUTO_IMPORTS = [
210
215
  "useAttrs",
211
216
  "createStore",
212
217
  "defineStore",
218
+ "registerStoresClient",
213
219
  "useStore",
214
220
  "action",
215
221
  "createSelector",
@@ -294,7 +300,7 @@ function transformAutoImports(code) {
294
300
  const usedStxImports = new Set, usedBrowserImports = new Set;
295
301
  let transformedCode = code;
296
302
  transformedCode = transformedCode.replace(/^\s*import\s+type\s+\{[^}]*\}\s+from\s+['"][^'"]+['"]\s*;?\s*$/gm, "// [type import stripped]");
297
- const existingImports = new Set, importRegex = /^\s*import\s+(?:type\s+)?{\s*([^}]+)\s*}\s+from\s+['"](@stacksjs\/browser|stx)['"]\s*;?\s*$/gm;
303
+ const existingImports = new Set, importRegex = /^\s*import\s+(?:type\s+)?{\s*([^}]+)\s*}\s+from\s+['"](@stacksjs\/browser|@stacksjs\/stx|stx)['"]\s*;?\s*$/gm;
298
304
  let match;
299
305
  while ((match = importRegex.exec(code)) !== null) {
300
306
  const imports = match[1], source = match[2];
@@ -302,7 +308,7 @@ function transformAutoImports(code) {
302
308
  continue;
303
309
  imports.split(",").map((s) => s.trim().split(/\s+as\s+/)[0]).filter((s) => s.length > 0).forEach((name) => {
304
310
  existingImports.add(name);
305
- if (source === "stx")
311
+ if (source === "stx" || source === "@stacksjs/stx")
306
312
  usedStxImports.add(name);
307
313
  else
308
314
  usedBrowserImports.add(name);
@@ -374,6 +380,34 @@ export function extractBridgeData(context) {
374
380
  }
375
381
  return out;
376
382
  }
383
+ function extractDestructuredBindings(pattern, objectPattern) {
384
+ const bindings = new Set;
385
+ for (const rawPart of pattern.split(",")) {
386
+ let part = rawPart.trim().replace(/^\.\.\./, "").trim();
387
+ if (!part)
388
+ continue;
389
+ if (objectPattern) {
390
+ const colon = part.lastIndexOf(":");
391
+ if (colon !== -1)
392
+ part = part.slice(colon + 1).trim();
393
+ }
394
+ part = part.split("=")[0].trim();
395
+ if (/^[A-Za-z_$][\w$]*$/.test(part))
396
+ bindings.add(part);
397
+ }
398
+ return bindings;
399
+ }
400
+ function declaresClientIdentifier(code, name) {
401
+ if (new RegExp(`(?:const|let|var|function|class)\\s+${name}\\b`).test(code))
402
+ return !0;
403
+ for (const match of code.matchAll(/(?:const|let|var)\s*\{([^}]*)\}/g))
404
+ if (extractDestructuredBindings(match[1], !0).has(name))
405
+ return !0;
406
+ for (const match of code.matchAll(/(?:const|let|var)\s*\[([^\]]*)\]/g))
407
+ if (extractDestructuredBindings(match[1], !1).has(name))
408
+ return !0;
409
+ return !1;
410
+ }
377
411
  export function generateServerDataBridge(code, serverData) {
378
412
  if (!serverData)
379
413
  return "";
@@ -383,7 +417,7 @@ export function generateServerDataBridge(code, serverData) {
383
417
  continue;
384
418
  if (!new RegExp(`\\b${name}\\b`).test(code))
385
419
  continue;
386
- if (new RegExp(`(?:const|let|var|function|class)\\s+${name}\\b`).test(code))
420
+ if (declaresClientIdentifier(code, name))
387
421
  continue;
388
422
  let json;
389
423
  try {
@@ -439,8 +473,13 @@ function generateSingleEventBinding(binding, index) {
439
473
  let handlerBody = "";
440
474
  if (checks)
441
475
  handlerBody += `${checks}; `;
442
- const escapedHandler = handler.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
476
+ const escapedHandler = handler.replace(/\\/g, "\\\\").replace(/'/g, "\\'"), emitHelper = `var $emit = function(name, detail) {
477
+ $el.dispatchEvent(new CustomEvent(name, { detail: detail, bubbles: true, cancelable: true }))
478
+ };`;
443
479
  function scopeAwareHandler(h) {
480
+ if (/\$emit\s*\(/.test(h))
481
+ return `${emitHelper}
482
+ ${h}`;
444
483
  return `var scope = $el.closest('[data-stx-scope]');
445
484
  if (scope && scope.__stx_execute) {
446
485
  scope.__stx_execute('${escapedHandler}', $event, $el);
@@ -474,6 +513,12 @@ else {
474
513
  }
475
514
  }${optionsStr})
476
515
  })()`;
516
+ if (/\$emit\s*\(/.test(handler))
517
+ return ` var $el = document.getElementById('${elementId}')
518
+ if ($el) $el.addEventListener('${event}', function($event) {
519
+ ${handlerBody}${emitHelper}
520
+ ${handler}
521
+ }${optionsStr})`;
477
522
  return ` var $el = document.getElementById('${elementId}')
478
523
  if ($el) $el.addEventListener('${event}', function($event) {
479
524
  ${handlerBody}var scope = $el.closest('[data-stx-scope]');
@@ -740,7 +785,7 @@ ${autoImportCode}${code}
740
785
  ${eventCode}
741
786
  </script>`;
742
787
  }
743
- const usesSignals = /\b(state|derived|effect|ref|reactive|computed|watch|watchEffect|useLocalStorage|useSessionStorage|useRef|useEventListener|useFetch|useDebounce|useDebouncedValue|useThrottle|useInterval|useTimeout|useToggle|useCounter|useClickOutside|useFocus|useAsync|useColorMode|useDark|useWebSocket|useRoute|useSearchParams|onMount|onDestroy|batch|untrack|peek)\s*(?:<[^>]*>)?\s*\(/.test(scriptContent), isSfcWrapped = /function __stx_setup_/.test(code), alreadyMounts = /\bstx\.mount\s*\(|\bstx\.mountEl\s*\(/.test(scriptContent);
788
+ const usesSignals = /\b(state|derived|effect|ref|reactive|computed|watch|watchEffect|useLocalStorage|useSessionStorage|useRef|useEventListener|useFetch|useDebounce|useDebouncedValue|useThrottle|useInterval|useTimeout|useToggle|useCounter|useClickOutside|useFocus|useAsync|useColorMode|useDark|useWebSocket|useRoute|useSearchParams|onMount|onDestroy|onMounted|onBeforeUnmount|onUnmounted|batch|untrack|peek)\s*(?:<[^>]*>)?\s*\(/.test(scriptContent), isSfcWrapped = /function __stx_setup_/.test(code), alreadyMounts = /\bstx\.mount\s*\(|\bstx\.mountEl\s*\(/.test(scriptContent);
744
789
  function wrapInMount(declarations) {
745
790
  const returnStmt = declarations.length > 0 ? `
746
791
  return { ${declarations.join(", ")} };` : "";
@@ -1,12 +1,13 @@
1
1
  import { registry } from "./component-registry.js";
2
2
  import { registerBuiltins } from "./builtins/index.js";
3
3
  import { decodeStxProp, findComponentTags, parseMultilineAttributes, pascalToKebab, restoreStashedScripts, stashScriptElements } from "./component-processing.js";
4
+ import { maskAtElementPosition, matchHtmlComment } from "./html-masking.js";
4
5
  import { renderComponentWithSlot, userComponentFileExists } from "./utils.js";
5
6
  import { createSafeFunction, isExpressionSafe, safeEvaluateObject } from "./safe-evaluator.js";
6
7
  import fs from "node:fs";
7
8
  import path from "node:path";
8
9
  let builtinsRegistered = !1;
9
- const htmlTags = new Set([
10
+ const BOOLEAN_ATTRIBUTE_SENTINEL = "\x00stx-boolean-attribute", htmlTags = new Set([
10
11
  "a",
11
12
  "abbr",
12
13
  "address",
@@ -212,7 +213,7 @@ function parseComponentProps(rawProps, context, options) {
212
213
  continue;
213
214
  }
214
215
  if (attrName.startsWith(":")) {
215
- const propName = kebabToCamel(attrName.slice(1)), expression = attrValue === "true" ? propName : attrValue;
216
+ const propName = kebabToCamel(attrName.slice(1)), expression = attrValue === BOOLEAN_ATTRIBUTE_SENTINEL ? propName : attrValue;
216
217
  try {
217
218
  if (!isExpressionSafe(expression)) {
218
219
  if (options.debug)
@@ -274,7 +275,7 @@ function parseComponentProps(rawProps, context, options) {
274
275
  }
275
276
  continue;
276
277
  }
277
- resolved.static[attrName] = attrValue;
278
+ resolved.static[attrName] = attrValue === BOOLEAN_ATTRIBUTE_SENTINEL ? "true" : attrValue;
278
279
  }
279
280
  return resolved;
280
281
  }
@@ -326,7 +327,7 @@ function parseAllAttributes(attributesStr) {
326
327
  }
327
328
  props[name] = value;
328
329
  } else
329
- props[name] = "true";
330
+ props[name] = BOOLEAN_ATTRIBUTE_SENTINEL;
330
331
  }
331
332
  return props;
332
333
  }
@@ -621,10 +622,10 @@ async function processCustomElementTags(template, context, filePath, options, de
621
622
  if (Object.keys(resolvedProps.clientReactive).length > 0)
622
623
  finalContent = emitClientReactiveAttrs(finalContent, resolvedProps.clientReactive);
623
624
  if (Object.keys(resolvedProps.events).length > 0) {
624
- const firstTagMatch = finalContent.match(/^(\s*<)([a-zA-Z][a-zA-Z0-9-]*)(\s|>|\/)/s);
625
- if (firstTagMatch) {
626
- const eventAttrs = Object.entries(resolvedProps.events).map(([event, handler]) => `${event}="${escapeAttribute(handler)}"`).join(" "), insertPos = firstTagMatch[1].length + firstTagMatch[2].length;
627
- finalContent = finalContent.substring(0, insertPos) + " " + eventAttrs + finalContent.substring(insertPos);
625
+ const root = findRenderedComponentRoot(finalContent);
626
+ if (root) {
627
+ const eventAttrs = Object.entries(resolvedProps.events).map(([event, handler]) => `${event}="${escapeAttribute(handler)}"`).join(" "), parentEvents = Object.keys(resolvedProps.events).map((event) => event.replace(/^@/, "").split(".")[0]).join(" ");
628
+ finalContent = finalContent.substring(0, root.insertPos) + ` data-stx-parent-events="${parentEvents}" ${eventAttrs}` + finalContent.substring(root.insertPos);
628
629
  }
629
630
  }
630
631
  result = result.substring(0, tag.startIndex) + finalContent + result.substring(tag.endIndex);
@@ -633,11 +634,48 @@ async function processCustomElementTags(template, context, filePath, options, de
633
634
  }
634
635
  }
635
636
  function emitClientReactiveAttrs(html, clientReactive) {
636
- const firstTagMatch = html.match(/^(\s*<)([a-zA-Z][a-zA-Z0-9-]*)(\s|>|\/)/s);
637
- if (!firstTagMatch)
637
+ const root = findRenderedComponentRoot(html);
638
+ if (!root)
638
639
  return html;
639
- const reactiveAttrs = Object.entries(clientReactive).map(([key, expr]) => `:${key}="${expr.replace(/"/g, "&quot;")}"`).join(" "), insertPos = firstTagMatch[1].length + firstTagMatch[2].length;
640
- return html.substring(0, insertPos) + " " + reactiveAttrs + html.substring(insertPos);
640
+ const structuralBindings = new Set(["if", "for", "show"]), reactiveAttrs = Object.entries(clientReactive).map(([key, expr]) => `:${pascalToKebab(key)}="${expr.replace(/"/g, "&quot;")}"`).join(" "), parentBindings = Object.keys(clientReactive).filter((key) => !structuralBindings.has(key)).map((key) => pascalToKebab(key)).join(" "), parentBindingAttr = parentBindings ? ` data-stx-parent-bindings="${parentBindings}"` : "";
641
+ return html.substring(0, root.insertPos) + `${parentBindingAttr} ${reactiveAttrs}` + html.substring(root.insertPos);
642
+ }
643
+ function findRenderedComponentRoot(html) {
644
+ const scopedRoot = /<([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*\bdata-stx-scope(?:\s|=|>)/i.exec(html);
645
+ if (scopedRoot)
646
+ return {
647
+ insertPos: scopedRoot.index + 1 + scopedRoot[1].length
648
+ };
649
+ let cursor = 0;
650
+ while (cursor < html.length) {
651
+ const rest = html.slice(cursor), whitespace = rest.match(/^\s+/);
652
+ if (whitespace) {
653
+ cursor += whitespace[0].length;
654
+ continue;
655
+ }
656
+ if (rest.startsWith("<!--")) {
657
+ const end = html.indexOf("-->", cursor + 4);
658
+ if (end === -1)
659
+ return null;
660
+ cursor = end + 3;
661
+ continue;
662
+ }
663
+ const nonRootTag = rest.match(/^<(script|style)\b[^>]*>/i);
664
+ if (nonRootTag) {
665
+ const closeTag = `</${nonRootTag[1].toLowerCase()}>`, end = html.toLowerCase().indexOf(closeTag, cursor + nonRootTag[0].length);
666
+ if (end === -1)
667
+ return null;
668
+ cursor = end + closeTag.length;
669
+ continue;
670
+ }
671
+ const root = rest.match(/^<([a-zA-Z][a-zA-Z0-9-]*)(?:\s|>|\/)/);
672
+ if (!root)
673
+ return null;
674
+ return {
675
+ insertPos: cursor + 1 + root[1].length
676
+ };
677
+ }
678
+ return null;
641
679
  }
642
680
  export async function processComponents(template, context, filePath, options, dependencies) {
643
681
  if (!builtinsRegistered) {
@@ -647,12 +685,17 @@ export async function processComponents(template, context, filePath, options, de
647
685
  let output = await processESImports(template, context, filePath, options, dependencies);
648
686
  output = await processImports(output, context, filePath, options, dependencies);
649
687
  output = await processComponentDirectives(output, context, filePath, options, dependencies);
688
+ const nestedComments = [];
650
689
  for (let pass = 0;pass < 3; pass++) {
690
+ const masked = maskAtElementPosition(output, matchHtmlComment, (_token, index) => `\x00STX_COMPONENT_COMMENT_${nestedComments.length + index}\x00`);
691
+ nestedComments.push(...masked.tokens);
651
692
  const before = output;
652
- output = await processCustomElementTags(output, context, filePath, options, dependencies);
693
+ output = await processCustomElementTags(masked.output, context, filePath, options, dependencies);
653
694
  if (output === before)
654
695
  break;
655
696
  }
697
+ if (nestedComments.length > 0)
698
+ output = output.replace(/\x00STX_COMPONENT_COMMENT_(\d+)\x00/g, (_match, index) => nestedComments[Number(index)] ?? "");
656
699
  if (options.debug) {
657
700
  const unresolved = findUnresolvedBuiltinTags(output);
658
701
  if (unresolved.length > 0)
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Discover composable files and bundle them into a single script that
3
+ * populates `window.__composables`.
4
+ *
5
+ * Every file is concatenated into ONE IIFE, so composables can call each other
6
+ * directly without importing — their declarations share a scope once the
7
+ * import statements are stripped.
8
+ *
9
+ * Returns `null` when there is nothing to load, so callers can skip injection.
10
+ */
11
+ export declare function getComposableScript(composablesDir?: string): Promise<string | null>;
12
+ /**
13
+ * Clear the cached composable script (for dev mode hot reload).
14
+ */
15
+ export declare function clearComposableCache(): void;
@@ -0,0 +1,117 @@
1
+ import path from "node:path";
2
+ import { loadStxConfig } from "./config.js";
3
+ import { getPublicEnvDefine } from "./public-env.js";
4
+ import { STX_RUNTIME_GLOBALS } from "./runtime-globals.js";
5
+ import { transformStoreImports } from "./store-imports.js";
6
+ const _cachedComposableScripts = new Map, _cachedDirs = new Map, DEFAULT_DIR_CANDIDATES = ["composables", "functions"];
7
+ function isSkippedFile(file) {
8
+ const name = path.basename(file, ".ts");
9
+ return name === "index" || name === "types" || file.endsWith(".d.ts");
10
+ }
11
+ async function resolveComposablesDir(composablesDir) {
12
+ if (composablesDir)
13
+ return path.resolve(composablesDir);
14
+ const cacheKey = `<default>:${process.cwd()}`, cachedDir = _cachedDirs.get(cacheKey);
15
+ if (cachedDir !== void 0)
16
+ return cachedDir;
17
+ const resolved = await probeDefaultDirs();
18
+ _cachedDirs.set(cacheKey, resolved);
19
+ return resolved;
20
+ }
21
+ async function probeDefaultDirs() {
22
+ const config = await loadStxConfig(), root = config.root || process.cwd(), configured = config.composablesDir;
23
+ if (configured)
24
+ return path.resolve(root, configured);
25
+ for (const candidate of DEFAULT_DIR_CANDIDATES) {
26
+ const dir = path.resolve(root, candidate);
27
+ try {
28
+ const glob = new Bun.Glob("**/*.ts");
29
+ for await (const file of glob.scan({ cwd: dir, absolute: !0 }))
30
+ if (!isSkippedFile(file))
31
+ return dir;
32
+ } catch {}
33
+ }
34
+ return null;
35
+ }
36
+ export async function getComposableScript(composablesDir) {
37
+ const resolvedDir = await resolveComposablesDir(composablesDir);
38
+ if (!resolvedDir)
39
+ return null;
40
+ const cached = _cachedComposableScripts.get(resolvedDir);
41
+ if (cached !== void 0)
42
+ return cached || null;
43
+ const composableFiles = [];
44
+ try {
45
+ const glob = new Bun.Glob("**/*.ts");
46
+ for await (const file of glob.scan({ cwd: resolvedDir, absolute: !0 })) {
47
+ if (isSkippedFile(file))
48
+ continue;
49
+ composableFiles.push(file);
50
+ }
51
+ } catch {
52
+ _cachedComposableScripts.set(resolvedDir, "");
53
+ return null;
54
+ }
55
+ if (composableFiles.length === 0) {
56
+ _cachedComposableScripts.set(resolvedDir, "");
57
+ return null;
58
+ }
59
+ composableFiles.sort();
60
+ const transpiler = new Bun.Transpiler({ loader: "ts", target: "browser", define: getPublicEnvDefine() }), chunks = [], exportedNames = new Set, declaredNames = new Set;
61
+ for (const file of composableFiles)
62
+ try {
63
+ let code = await Bun.file(file).text();
64
+ const composableName = path.basename(file, ".ts");
65
+ for (const match of code.matchAll(/\b(?:const|let|var|function|class)\s+([A-Z_a-z$][\w$]*)/g))
66
+ if (match[1])
67
+ declaredNames.add(match[1]);
68
+ try {
69
+ for (const name of transpiler.scan(code).exports)
70
+ if (name !== "default")
71
+ exportedNames.add(name);
72
+ } catch {}
73
+ code = transformStoreImports(code);
74
+ code = code.replace(/^import\s+.*from\s+['"][^'"]+['"]\s*;?\s*$/gm, "");
75
+ code = code.replace(/^export\s+(default\s+)?/gm, "");
76
+ code = transpiler.transformSync(code);
77
+ chunks.push(`// Composable: ${composableName}
78
+ ${code.trim()}`);
79
+ } catch (e) {
80
+ console.warn(`[stx] Failed to load composable ${file}:`, e);
81
+ }
82
+ if (chunks.length === 0 || exportedNames.size === 0) {
83
+ _cachedComposableScripts.set(resolvedDir, "");
84
+ return null;
85
+ }
86
+ const assignments = [...exportedNames].map((name) => ` try { __c[${JSON.stringify(name)}] = ${name}; } catch (e) {}`).join(`
87
+ `), globalNames = JSON.stringify([...exportedNames]), joinedChunks = chunks.join(`
88
+
89
+ `), code = `;(function(){
90
+ window.__composables = window.__composables || {};
91
+ var __c = window.__composables;
92
+ var __stx = window.stx || {};
93
+ ${STX_RUNTIME_GLOBALS.filter((name) => !declaredNames.has(name) && new RegExp(`\\b${name}\\b`).test(joinedChunks)).map((name) => `var ${name} = __stx[${JSON.stringify(name)}];`).join(`
94
+ `)}
95
+ ${joinedChunks}
96
+ ${assignments}
97
+ var __names = ${globalNames};
98
+ for (var __i = 0; __i < __names.length; __i++) {
99
+ var __n = __names[__i];
100
+ if (!(__n in __c)) continue;
101
+ var __takenByBuiltin = !!(window.stx && __n in window.stx);
102
+ if (__takenByBuiltin || __n in window) {
103
+ console.warn('[stx] composable "' + __n + '" is not available as a bare identifier: '
104
+ + (__takenByBuiltin ? 'a built-in of that name already exists' : 'window already defines that property')
105
+ + '. Rename it, or use: import { ' + __n + ' } from "@composables"');
106
+ continue;
107
+ }
108
+ window[__n] = __c[__n];
109
+ }
110
+ })();`;
111
+ _cachedComposableScripts.set(resolvedDir, code);
112
+ return code;
113
+ }
114
+ export function clearComposableCache() {
115
+ _cachedComposableScripts.clear();
116
+ _cachedDirs.clear();
117
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Native desktop APIs.
3
+ *
4
+ * stx bundles `@stacksjs/desktop` so an app needs one dependency rather than
5
+ * three. Import windows, system tray, preferences, power management, global
6
+ * shortcuts and the rest of the native surface from here:
7
+ *
8
+ * ```typescript
9
+ * import { caffeinate, createPreferences, createWindow } from '@stacksjs/stx/desktop'
10
+ * ```
11
+ *
12
+ * For a menu bar app, prefer `@stacksjs/stx/menubar`, which composes these into
13
+ * a single declarative call.
14
+ */
15
+ export * from '@stacksjs/desktop';
@@ -0,0 +1 @@
1
+ export * from "@stacksjs/desktop";
@@ -49,8 +49,8 @@ async function tryImportCrosswind(importPath) {
49
49
  } catch {}
50
50
  return null;
51
51
  }
52
- function findCrosswindPaths() {
53
- const paths = [], homeDir = process.env.HOME || process.env.USERPROFILE || "";
52
+ function findProjectCrosswindPaths() {
53
+ const paths = [];
54
54
  let currentDir = process.cwd();
55
55
  while (currentDir !== path.dirname(currentDir)) {
56
56
  paths.push(path.join(currentDir, "node_modules", "@cwcss", "crosswind", "dist", "index.js"));
@@ -61,6 +61,10 @@ function findCrosswindPaths() {
61
61
  paths.push(path.join(currentDir, "pantry", "@stacksjs", "crosswind", "dist", "index.js"));
62
62
  currentDir = path.dirname(currentDir);
63
63
  }
64
+ return paths;
65
+ }
66
+ function findDevCrosswindPaths() {
67
+ const paths = [], homeDir = process.env.HOME || process.env.USERPROFILE || "";
64
68
  if (homeDir) {
65
69
  const devPaths = [
66
70
  path.join(homeDir, "Code", "Tools", "crosswind", "packages", "crosswind", "dist", "index.js"),
@@ -81,6 +85,23 @@ export async function loadCrosswind() {
81
85
  return crosswindModule;
82
86
  crosswindLoadAttempted = !0;
83
87
  try {
88
+ const loadFrom = async (candidates) => {
89
+ for (const candidate of candidates) {
90
+ if (!await Bun.file(candidate).exists())
91
+ continue;
92
+ const result = await tryImportCrosswind(candidate);
93
+ if (result) {
94
+ if (!process.env.STACKS_DEV_QUIET)
95
+ console.log(`${colors.green}[Crosswind]${colors.reset} CSS engine loaded from ${path.dirname(path.dirname(candidate))}`);
96
+ return result;
97
+ }
98
+ }
99
+ return null;
100
+ }, projectModule = await loadFrom(findProjectCrosswindPaths());
101
+ if (projectModule) {
102
+ crosswindModule = projectModule;
103
+ return crosswindModule;
104
+ }
84
105
  const importPaths = [
85
106
  "@cwcss/crosswind",
86
107
  "@cwcss/crosswind/dist/index.js",
@@ -96,17 +117,11 @@ export async function loadCrosswind() {
96
117
  return crosswindModule;
97
118
  }
98
119
  }
99
- const localPaths = findCrosswindPaths();
100
- for (const localPath of localPaths)
101
- if (await Bun.file(localPath).exists()) {
102
- const result = await tryImportCrosswind(localPath);
103
- if (result) {
104
- crosswindModule = result;
105
- if (!process.env.STACKS_DEV_QUIET)
106
- console.log(`${colors.green}[Crosswind]${colors.reset} CSS engine loaded from ${path.dirname(path.dirname(localPath))}`);
107
- return crosswindModule;
108
- }
109
- }
120
+ const devModule = await loadFrom(findDevCrosswindPaths());
121
+ if (devModule) {
122
+ crosswindModule = devModule;
123
+ return crosswindModule;
124
+ }
110
125
  throw Error("Crosswind CSSGenerator not found in any location");
111
126
  } catch {
112
127
  console.warn(`${colors.yellow}[Crosswind] CSS engine not available, Tailwind styles will not be generated${colors.reset}`);
@@ -319,7 +319,7 @@ export async function serveApp(appDir = ".", options = {}) {
319
319
  actualHmrPort = hmrServer.start(desiredHmrPort);
320
320
  }
321
321
  const injectRouteParams = (content, params) => {
322
- const paramsScript = `<script>(function(){var s=typeof stx!=='undefined'?stx:{};s._rp=${JSON.stringify(params)};if(s.setRouteParams)s.setRouteParams(s._rp);})()</script>`;
322
+ const paramsScript = `<script data-stx-route-params>(function(){var p=${JSON.stringify(params).replace(/</g, "\\u003C").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029")};window.__stx_rp=p;if(window.stx){window.stx._rp=p;if(window.stx.setRouteParams)window.stx.setRouteParams(p)}})()</script>`;
323
323
  if (content.includes("</head>"))
324
324
  return content.replace("</head>", `${paramsScript}</head>`);
325
325
  if (content.includes("<body"))
@@ -352,7 +352,7 @@ export function processExpressions(template, context, filePath, options) {
352
352
  output = maskInto(output, styleMatcher, styleBlocks, (n) => `<!--__STX_STYLE_${n}__-->`);
353
353
  const scriptBlocks = [];
354
354
  output = maskInto(output, matchScriptElement, scriptBlocks, (n) => `<!--__STX_SCRIPT_EXPR_${n}__-->`);
355
- const hasSignals = options?.forceSignals === !0 || context?.__stx_force_signals === !0 || usesSignalsInScript(template);
355
+ const hasSignals = options?.forceSignals === !0 || context?.__stx_force_signals === !0 || Array.isArray(context?.__stx_client_signal_names) || usesSignalsInScript(template), clientSignalNames = new Set(Array.isArray(context?.__stx_client_signal_names) ? context.__stx_client_signal_names : []), preservedClientExpressions = new Set(Array.isArray(context?.__stx_preserved_client_expressions) ? context.__stx_preserved_client_expressions : []);
356
356
  output = output.replace(/\{\{\{([\s\S]*?)\}\}\}/g, (match, expr, offset) => {
357
357
  try {
358
358
  const value = evaluateExpression(expr, context);
@@ -378,17 +378,24 @@ export function processExpressions(template, context, filePath, options) {
378
378
  output = maskInto(output, matchScriptElement, scriptBlocks, (n) => `<!--__STX_SCRIPT_EXPR_${n}__-->`);
379
379
  output = maskInto(output, styleMatcher, styleBlocks, (n) => `<!--__STX_STYLE_${n}__-->`);
380
380
  output = output.replace(/\{\{([\s\S]*?)\}\}/g, (match, expr, offset) => {
381
- const trimmedExpr = expr.trim(), jsBuiltins = ["parseInt", "parseFloat", "String", "Number", "Boolean", "Array", "Object", "JSON", "Math", "Date", "encodeURIComponent", "decodeURIComponent", "encodeURI", "decodeURI", "true", "false", "null", "undefined"], identifierPattern = /^([a-zA-Z_$][a-zA-Z0-9_$]*)/, firstVarName = trimmedExpr.match(identifierPattern)?.[1], firstVarInContext = firstVarName && ((firstVarName in context) || jsBuiltins.includes(firstVarName)), directFunctionCall = /^([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/, directFuncMatch = trimmedExpr.match(directFunctionCall), isLikelySignal = directFuncMatch && !jsBuiltins.includes(directFuncMatch[1]) && !(directFuncMatch[1] in context);
381
+ const trimmedExpr = expr.trim();
382
+ if (preservedClientExpressions.has(trimmedExpr))
383
+ return match;
384
+ const jsBuiltins = ["parseInt", "parseFloat", "String", "Number", "Boolean", "Array", "Object", "JSON", "Math", "Date", "encodeURIComponent", "decodeURIComponent", "encodeURI", "decodeURI", "true", "false", "null", "undefined"], identifierPattern = /^([a-zA-Z_$][a-zA-Z0-9_$]*)/, firstVarName = trimmedExpr.match(identifierPattern)?.[1], firstVarInContext = firstVarName && ((firstVarName in context) || jsBuiltins.includes(firstVarName)), firstVarIsClientSignal = Boolean(firstVarName && clientSignalNames.has(firstVarName)), directFunctionCall = /^([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/, directFuncMatch = trimmedExpr.match(directFunctionCall), isLikelySignal = directFuncMatch && !jsBuiltins.includes(directFuncMatch[1]) && !(directFuncMatch[1] in context);
382
385
  if (/^__[A-Z_]+__$/.test(trimmedExpr))
383
386
  return "";
384
387
  try {
385
388
  const value = evaluateExpression(expr, context);
389
+ if (firstVarIsClientSignal)
390
+ return match;
386
391
  if (hasSignals && !expressionUsesOnlyContextVars(trimmedExpr, context))
387
392
  return match;
388
393
  if (value === void 0 && context.__stx_buildMode === "compile" && !hasSignals)
389
394
  return createPlaceholder("expr", trimmedExpr);
390
395
  return value !== void 0 && value !== null ? escapeHtml(String(value)) : "";
391
396
  } catch (error) {
397
+ if (firstVarIsClientSignal)
398
+ return match;
392
399
  if (hasSignals && !expressionUsesOnlyContextVars(trimmedExpr, context))
393
400
  return match;
394
401
  if (isLikelySignal)
package/dist/includes.js CHANGED
@@ -1,7 +1,9 @@
1
+ import { findSfcTemplateBlock } from "./sfc-template.js";
1
2
  import path from "node:path";
2
3
  import { processConditionals } from "./conditionals.js";
3
4
  import { isProduction, isTest } from "./env.js";
4
5
  import { processExpressions, usesSignalsInScript } from "./expressions.js";
6
+ import { buildRuntimeGlobalsDestructure } from "./runtime-globals.js";
5
7
  import { LRUCache } from "./performance-utils.js";
6
8
  import { createSafeFunction, isExpressionSafe, safeEvaluate, safeEvaluateObject } from "./safe-evaluator.js";
7
9
  import { transformStoreImports } from "./store-imports.js";
@@ -180,7 +182,7 @@ function extractExports(setupContent) {
180
182
  function wrapClientScript(scriptContent, tail = "") {
181
183
  return `
182
184
  (function() {
183
- var { state, derived, effect, batch, onMount, onDestroy, defineStore, useStore, useFetch, useRef, useQuery, useMutation, useOptimistic, useDebounce, useDebouncedValue, useThrottle, useInterval, useTimeout, useToggle, useCounter, useClickOutside, useFocus, useAsync, useLocalStorage, useSessionStorage, useCookie, useReactiveProp, useEventListener, useWebSocket, useColorMode, useDark, useHead, useSeoMeta, definePageMeta, useRoute, useSearchParams, navigate, goBack, goForward, provide, ref, reactive, computed, watch, watchEffect, defineProps, withDefaults, defineEmits, defineExpose, defineSlots } = window.stx;
185
+ ${buildRuntimeGlobalsDestructure("var")}
184
186
  var __destroyHooks = [];
185
187
  var __origOnDestroy = onDestroy;
186
188
  onDestroy = function(fn) { __origOnDestroy(fn); __destroyHooks.push(fn); };
@@ -545,9 +547,9 @@ export async function processIncludes(template, context, filePath, options, depe
545
547
  if (options.debug)
546
548
  console.warn(`[stx] partial ES import extraction skipped for ${includeFilePath}:`, err instanceof Error ? err.message : err);
547
549
  }
548
- const templateMatch = workingContent.match(/<template\b(?![^>]*(?:\b(?:id|x-for|x-if|x-else|@for|@if|@else|:for|:if|:else)\s*=|\s#[\w-]|\bv-slot|\bslot\s*=))[^>]*>([\s\S]*?)<\/template>/i);
549
- if (templateMatch)
550
- workingContent = templateMatch[1].trim();
550
+ const templateBlock = findSfcTemplateBlock(workingContent);
551
+ if (templateBlock)
552
+ workingContent = templateBlock.content.trim();
551
553
  const styleMatch = partialContent.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "").match(/<style\b([^>]*)>([\s\S]*?)<\/style>/i);
552
554
  let preservedStyle = "";
553
555
  if (styleMatch)