@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/render.js CHANGED
@@ -27,24 +27,11 @@ function parseTemplate(content) {
27
27
  let sMatch;
28
28
  const removeRanges = [];
29
29
  while ((sMatch = serverScriptOpenRe.exec(templateContent)) !== null) {
30
- const tagEnd = sMatch.index + sMatch[0].length;
31
- let sDepth = 1, sPos = tagEnd;
32
- while (sPos < templateContent.length && sDepth > 0) {
33
- const nextOpen = templateContent.indexOf("<script", sPos), nextClose = templateContent.indexOf("</script>", sPos);
34
- if (nextClose === -1)
35
- break;
36
- if (nextOpen !== -1 && nextOpen < nextClose) {
37
- sDepth++;
38
- sPos = nextOpen + 7;
39
- } else {
40
- sDepth--;
41
- if (sDepth === 0) {
42
- removeRanges.push({ start: sMatch.index, end: nextClose + 9 });
43
- break;
44
- }
45
- sPos = nextClose + 9;
46
- }
47
- }
30
+ const tagEnd = sMatch.index + sMatch[0].length, closeAt = templateContent.indexOf("</script>", tagEnd);
31
+ if (closeAt === -1)
32
+ continue;
33
+ removeRanges.push({ start: sMatch.index, end: closeAt + 9 });
34
+ serverScriptOpenRe.lastIndex = closeAt + 9;
48
35
  }
49
36
  for (let ri = removeRanges.length - 1;ri >= 0; ri--)
50
37
  templateContent = templateContent.substring(0, removeRanges[ri].start) + templateContent.substring(removeRanges[ri].end);
@@ -1,10 +1,3 @@
1
- /**
2
- * Script validation module for STX templates.
3
- *
4
- * Validates client-side script content against prohibited DOM API patterns,
5
- * enforcing the use of STX composables and Vue-style alternatives instead
6
- * of raw browser APIs.
7
- */
8
1
  import type { StrictModeConfig } from './types';
9
2
  /**
10
3
  * Prohibited DOM API patterns in client scripts.
@@ -1,3 +1,4 @@
1
+ import { stripCommentsAndLiterals } from "./strip-literals.js";
1
2
  export const PROHIBITED_DOM_PATTERNS = [
2
3
  {
3
4
  pattern: /document\.getElementById\s*\(/g,
@@ -126,21 +127,22 @@ export const PROHIBITED_DOM_PATTERNS = [
126
127
  }
127
128
  ];
128
129
  export function validateClientScript(content, filePath, strict, originFilePath) {
129
- const strictConfig = typeof strict === "boolean" ? { enabled: strict, failOnViolation: strict } : strict ?? { enabled: !1 }, allowPatterns = strictConfig.allowPatterns ?? [], errors = [];
130
+ const strictConfig = typeof strict === "boolean" ? { enabled: strict, failOnViolation: strict } : strict ?? { enabled: !1 }, allowPatterns = strictConfig.allowPatterns ?? [], errors = [], scannable = stripCommentsAndLiterals(content.replace(/\{\{--[\s\S]*?--\}\}/g, (match) => match.replace(/[^\n]/g, " ")));
130
131
  for (const { pattern, message, suggestion } of PROHIBITED_DOM_PATTERNS) {
131
132
  if (allowPatterns.some((allowed) => /\w/.test(allowed) && message.includes(allowed)))
132
133
  continue;
133
134
  pattern.lastIndex = 0;
134
- const matches = content.match(pattern);
135
+ const matches = scannable.match(pattern);
135
136
  if (matches && matches.length > 0) {
136
- const lines = content.split(`
137
+ const lines = scannable.split(`
138
+ `), sourceLines = content.split(`
137
139
  `), lineNumbers = [];
138
140
  lines.forEach((line, index) => {
139
141
  pattern.lastIndex = 0;
140
142
  if (pattern.test(line))
141
143
  lineNumbers.push(index + 1);
142
144
  });
143
- const locationInfo = lineNumbers.length > 0 ? ` (script line${lineNumbers.length > 1 ? "s" : ""} ${lineNumbers.join(", ")})` : "", quoted = lineNumbers.slice(0, 3).map((n) => ` ${n} | ${(lines[n - 1] ?? "").trim().slice(0, 100)}`).join(`
145
+ const locationInfo = lineNumbers.length > 0 ? ` (script line${lineNumbers.length > 1 ? "s" : ""} ${lineNumbers.join(", ")})` : "", quoted = lineNumbers.slice(0, 3).map((n) => ` ${n} | ${(sourceLines[n - 1] ?? "").trim().slice(0, 100)}`).join(`
144
146
  `), snippet = quoted ? `
145
147
  ${quoted}` : "";
146
148
  errors.push(` \u2717 ${message}${locationInfo}
@@ -20,7 +20,50 @@ const BOOLEAN_ATTRS = new Set([
20
20
  "playsinline",
21
21
  "reversed",
22
22
  "selected"
23
+ ]), VOID_TAGS = new Set([
24
+ "area",
25
+ "base",
26
+ "br",
27
+ "col",
28
+ "embed",
29
+ "hr",
30
+ "img",
31
+ "input",
32
+ "link",
33
+ "meta",
34
+ "param",
35
+ "source",
36
+ "track",
37
+ "wbr"
23
38
  ]);
39
+ function reactiveLoopRanges(template) {
40
+ const ranges = [], openingTag = /<([a-zA-Z][a-zA-Z0-9-]*)\b((?:[^>"']|"[^"]*"|'[^']*')*)\s*\/?>/g;
41
+ let match;
42
+ while ((match = openingTag.exec(template)) !== null) {
43
+ const attributes = match[2] || "";
44
+ if (!/(?:^|\s)(?::for|x-for|v-for|@for)\s*=/.test(attributes))
45
+ continue;
46
+ const start = match.index, openingEnd = start + match[0].length, tagName = match[1].toLowerCase();
47
+ if (match[0].endsWith("/>") || VOID_TAGS.has(tagName)) {
48
+ ranges.push([start, openingEnd]);
49
+ continue;
50
+ }
51
+ const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), sameTag = new RegExp(`<\\/?${escapedTag}\\b(?:[^>"']|"[^"]*"|'[^']*')*>`, "gi");
52
+ sameTag.lastIndex = openingEnd;
53
+ let depth = 1, closingEnd = openingEnd, nested;
54
+ while ((nested = sameTag.exec(template)) !== null)
55
+ if (nested[0].startsWith("</")) {
56
+ depth--;
57
+ if (depth === 0) {
58
+ closingEnd = nested.index + nested[0].length;
59
+ break;
60
+ }
61
+ } else if (!nested[0].endsWith("/>"))
62
+ depth++;
63
+ ranges.push([start, closingEnd]);
64
+ }
65
+ return ranges;
66
+ }
24
67
  function resolveClassBinding(value) {
25
68
  if (typeof value === "string")
26
69
  return value;
@@ -38,10 +81,11 @@ export function processServerBindings(template, context) {
38
81
  let match;
39
82
  while ((match = scriptStyleRegex.exec(template)) !== null)
40
83
  protectedRanges.push([match.index, match.index + match[0].length]);
84
+ protectedRanges.push(...reactiveLoopRanges(template));
41
85
  function isProtected(pos) {
42
86
  return protectedRanges.some(([start, end]) => pos >= start && pos < end);
43
87
  }
44
- const tagRegex = /<([a-zA-Z][a-zA-Z0-9-]*)((?:\s+(?:[^>"']|"[^"]*"|'[^']*'))*)\s*\/?>/g, replacements = [];
88
+ const tagRegex = /<([a-zA-Z][a-zA-Z0-9-]*)\b((?:[^>"']|"[^"]*"|'[^']*')*)\s*\/?>/g, replacements = [];
45
89
  while ((match = tagRegex.exec(output)) !== null) {
46
90
  if (isProtected(match.index))
47
91
  continue;
package/dist/signals.js CHANGED
@@ -5637,6 +5637,36 @@ catch (e) {} }
5637
5637
  }, 300);
5638
5638
  }
5639
5639
 
5640
+ /*
5641
+ * Is the APP in dark mode, not just the OS?
5642
+ *
5643
+ * stx's own color-mode boot writes a class (default 'dark') and/or an
5644
+ * attribute (data-theme / data-color-mode) onto <html>, so an app pinned to
5645
+ * light on a dark OS must not get a dark overlay over a light page. An
5646
+ * explicit override wins over both, and the media query is the last resort.
5647
+ *
5648
+ * One function rather than the resolution inlined per surface. #1875 fixed
5649
+ * this at the two dialog call sites, and toast kept the OS-only version and
5650
+ * shipped the exact bug #1875 describes; that is what a per-call-site fix
5651
+ * costs. See #1912.
5652
+ */
5653
+ function _resolveDark(override) {
5654
+ if (override != null)
5655
+ return !!override;
5656
+
5657
+ var root = document.documentElement;
5658
+ if (root.classList.contains('dark'))
5659
+ return true;
5660
+
5661
+ var attr = root.getAttribute('data-theme') || root.getAttribute('data-color-mode');
5662
+ if (attr === 'dark')
5663
+ return true;
5664
+ if (attr === 'light')
5665
+ return false;
5666
+
5667
+ return !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
5668
+ }
5669
+
5640
5670
  function addToast(type, message, options) {
5641
5671
  var container = _getToastContainer();
5642
5672
  if (!container) {
@@ -5648,6 +5678,26 @@ catch (e) {} }
5648
5678
  var id = ++_toastId;
5649
5679
  var maxToasts = parseInt(container.getAttribute('data-stx-toast-max') || '5', 10);
5650
5680
 
5681
+ /*
5682
+ * A semantic id replaces its own toast in place (stacksjs/stx#1913).
5683
+ *
5684
+ * Without it, a persistent 'Publishing...' toast and the call that clears
5685
+ * it on completion have to thread the numeric return value between two
5686
+ * different functions by hand, which is the state management the primitive
5687
+ * exists to remove. With an id of 'publish' the second call simply replaces
5688
+ * the first, and toast.dismiss('publish') ends it.
5689
+ */
5690
+ var key = opts.id != null ? String(opts.id) : '';
5691
+ if (key) {
5692
+ var prior = container.querySelector('[data-stx-toast-key="' + key.replace(/"/g, '') + '"]');
5693
+ // Removed outright rather than through removeToast, which animates for
5694
+ // 300ms first. This is a REPLACEMENT, not a dismissal: animating the old
5695
+ // one out while the new one animates in shows both at once, in the same
5696
+ // slot, which is exactly the flicker a keyed toast exists to avoid.
5697
+ if (prior && prior.parentNode)
5698
+ prior.parentNode.removeChild(prior);
5699
+ }
5700
+
5651
5701
  // Enforce max toasts \u2014 remove oldest
5652
5702
  var existing = container.querySelectorAll('[data-stx-toast]');
5653
5703
  while (existing.length >= maxToasts && existing.length > 0) {
@@ -5659,7 +5709,7 @@ catch (e) {} }
5659
5709
  var anims = _getToastAnimClass(container);
5660
5710
  var borderColor = _toastColors[type] || _toastColors.info;
5661
5711
  var icon = _toastIcons[type] || _toastIcons.info;
5662
- var isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
5712
+ var isDark = _resolveDark(opts.dark);
5663
5713
  var bg = isDark ? '#1f2937' : '#ffffff';
5664
5714
  var textColor = isDark ? '#f3f4f6' : '#1f2937';
5665
5715
  var shadow = isDark ? '0 4px 12px rgba(0,0,0,0.4)' : '0 4px 12px rgba(0,0,0,0.15)';
@@ -5667,6 +5717,8 @@ catch (e) {} }
5667
5717
  var el = document.createElement('div');
5668
5718
  el.id = 'stx-toast-' + id;
5669
5719
  el.setAttribute('data-stx-toast', String(id));
5720
+ if (key)
5721
+ el.setAttribute('data-stx-toast-key', key);
5670
5722
  el.setAttribute('role', 'alert');
5671
5723
  el.setAttribute('aria-live', 'assertive');
5672
5724
  el.style.cssText = 'pointer-events:auto;display:flex;align-items:flex-start;gap:0.75rem;padding:0.875rem 1rem;border-radius:0.5rem;border-left:4px solid ' + borderColor + ';background:' + bg + ';color:' + textColor + ';box-shadow:' + shadow + ';animation:' + anims.inAnim + ' 0.3s ease;font-family:system-ui,-apple-system,sans-serif;font-size:0.875rem;line-height:1.4;max-width:100%';
@@ -5675,9 +5727,32 @@ catch (e) {} }
5675
5727
  iconSpan.style.cssText = 'flex-shrink:0;display:flex;align-items:center;margin-top:1px';
5676
5728
  iconSpan.innerHTML = icon;
5677
5729
 
5678
- var msgSpan = document.createElement('span');
5730
+ /*
5731
+ * Title above message, when one is given (#1913).
5732
+ *
5733
+ * Collapsing the two into one string loses the hierarchy that makes a toast
5734
+ * scannable, and every one of the 16 call sites in the reporting app passed
5735
+ * a title. A wrapper is used only when there IS a title, so the untitled
5736
+ * shape renders exactly as it did.
5737
+ */
5738
+ var msgSpan = document.createElement(opts.title ? 'div' : 'span');
5679
5739
  msgSpan.style.cssText = 'flex:1;word-wrap:break-word';
5680
- msgSpan.textContent = message;
5740
+
5741
+ if (opts.title) {
5742
+ var titleEl = document.createElement('div');
5743
+ titleEl.setAttribute('data-stx-toast-title', '');
5744
+ titleEl.style.cssText = 'font-weight:600;margin-bottom:0.125rem';
5745
+ titleEl.textContent = opts.title;
5746
+ msgSpan.appendChild(titleEl);
5747
+
5748
+ var bodyEl = document.createElement('div');
5749
+ bodyEl.style.cssText = 'opacity:0.9';
5750
+ bodyEl.textContent = message;
5751
+ msgSpan.appendChild(bodyEl);
5752
+ }
5753
+ else {
5754
+ msgSpan.textContent = message;
5755
+ }
5681
5756
 
5682
5757
  var closeBtn = document.createElement('button');
5683
5758
  closeBtn.type = 'button';
@@ -5704,7 +5779,20 @@ catch (e) {} }
5704
5779
  info: function(message, options) { return addToast('info', message, options); },
5705
5780
  warning: function(message, options) { return addToast('warning', message, options); },
5706
5781
  dismiss: function(id) {
5707
- if (id !== undefined) { removeToast(id); return; }
5782
+ if (id !== undefined) {
5783
+ // A string id is the semantic key the caller passed; a number is the
5784
+ // handle addToast returned. Both dismiss, so a caller does not have to
5785
+ // know which kind it is holding (#1913).
5786
+ if (typeof id === 'string') {
5787
+ var c = _getToastContainer();
5788
+ var match = c && c.querySelector('[data-stx-toast-key="' + id.replace(/"/g, '') + '"]');
5789
+ if (match)
5790
+ removeToast(match.getAttribute('data-stx-toast'));
5791
+ return;
5792
+ }
5793
+ removeToast(id);
5794
+ return;
5795
+ }
5708
5796
  var container = _getToastContainer();
5709
5797
  if (!container) return;
5710
5798
  var all = container.querySelectorAll('[data-stx-toast]');
@@ -5782,17 +5870,7 @@ catch (e) {} }
5782
5870
  // (e.g. data-theme) onto <html>, so an app pinned to light on a dark OS used
5783
5871
  // to get a dark dialog over a light page. An explicit opts.dark wins over
5784
5872
  // both, and the media query stays as the last resort. See #1875.
5785
- var _root = document.documentElement;
5786
- var _attrTheme = _root.getAttribute('data-theme') || _root.getAttribute('data-color-mode');
5787
- var isDark = opts.dark != null
5788
- ? !!opts.dark
5789
- : _root.classList.contains('dark')
5790
- ? true
5791
- : _attrTheme === 'dark'
5792
- ? true
5793
- : _attrTheme === 'light'
5794
- ? false
5795
- : !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
5873
+ var isDark = _resolveDark(opts.dark);
5796
5874
  var bg = isDark ? '#1f2937' : '#ffffff';
5797
5875
  var textColor = isDark ? '#f3f4f6' : '#1f2937';
5798
5876
  var subColor = isDark ? '#9ca3af' : '#6b7280';