@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/cli.js +925 -834
- package/dist/codemod.d.ts +1 -24
- package/dist/codemod.js +56 -9
- package/dist/craft.js +264 -185
- package/dist/production-builder.js +1 -0
- package/dist/pwa.js +210 -131
- package/dist/render.js +5 -18
- package/dist/script-validation.d.ts +0 -7
- package/dist/script-validation.js +6 -4
- package/dist/server-bindings.js +45 -1
- package/dist/signals.js +93 -15
- package/dist/ssg.js +223 -144
- package/dist/stx-virtual-ts.d.ts +13 -0
- package/dist/stx-virtual-ts.js +36 -7
- package/dist/typecheck.d.ts +1 -13
- package/dist/typecheck.js +98 -4
- package/dist/variable-extractor.js +1 -1
- package/package.json +5 -5
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 =
|
|
135
|
+
const matches = scannable.match(pattern);
|
|
135
136
|
if (matches && matches.length > 0) {
|
|
136
|
-
const lines =
|
|
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} | ${(
|
|
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}
|
package/dist/server-bindings.js
CHANGED
|
@@ -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-]*)((
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
|
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';
|