@erickxavier/no-js 1.0.0
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/LICENSE +21 -0
- package/README.md +159 -0
- package/dist/cjs/no.js +25 -0
- package/dist/cjs/no.js.map +7 -0
- package/dist/esm/no.js +25 -0
- package/dist/esm/no.js.map +7 -0
- package/dist/iife/no.js +25 -0
- package/dist/iife/no.js.map +7 -0
- package/package.json +64 -0
- package/src/animations.js +113 -0
- package/src/cdn.js +16 -0
- package/src/context.js +104 -0
- package/src/directives/binding.js +118 -0
- package/src/directives/conditionals.js +283 -0
- package/src/directives/events.js +153 -0
- package/src/directives/http.js +288 -0
- package/src/directives/i18n.js +29 -0
- package/src/directives/loops.js +235 -0
- package/src/directives/refs.js +144 -0
- package/src/directives/state.js +102 -0
- package/src/directives/styling.js +88 -0
- package/src/directives/validation.js +216 -0
- package/src/dom.js +232 -0
- package/src/evaluate.js +298 -0
- package/src/fetch.js +173 -0
- package/src/filters.js +137 -0
- package/src/globals.js +58 -0
- package/src/i18n.js +36 -0
- package/src/index.js +189 -0
- package/src/registry.js +60 -0
- package/src/router.js +253 -0
package/src/dom.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
2
|
+
// DOM HELPERS & REMOTE TEMPLATES
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
4
|
+
|
|
5
|
+
import { _config, _log, _warn } from "./globals.js";
|
|
6
|
+
import { createContext } from "./context.js";
|
|
7
|
+
import { resolveUrl } from "./fetch.js";
|
|
8
|
+
|
|
9
|
+
// ─── Template HTML cache: url → html string ────────────────────────────────
|
|
10
|
+
// Avoids re-fetching the same .tpl file on repeat navigation.
|
|
11
|
+
// Controlled by _config.templates.cache (default: true).
|
|
12
|
+
export const _templateHtmlCache = new Map();
|
|
13
|
+
|
|
14
|
+
export function findContext(el) {
|
|
15
|
+
let node = el;
|
|
16
|
+
while (node) {
|
|
17
|
+
if (node.__ctx) return node.__ctx;
|
|
18
|
+
node = node.parentElement;
|
|
19
|
+
}
|
|
20
|
+
return createContext();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function _clearDeclared(el) {
|
|
24
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_ELEMENT);
|
|
25
|
+
while (walker.nextNode()) walker.currentNode.__declared = false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function _cloneTemplate(id) {
|
|
29
|
+
if (!id) return null;
|
|
30
|
+
const cleanId = id.startsWith("#") ? id.slice(1) : id;
|
|
31
|
+
const tpl = document.getElementById(cleanId);
|
|
32
|
+
if (!tpl) return null;
|
|
33
|
+
return tpl.content ? tpl.content.cloneNode(true) : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Simple HTML sanitizer
|
|
37
|
+
export function _sanitizeHtml(html) {
|
|
38
|
+
if (!_config.sanitize) return html;
|
|
39
|
+
const safe = html
|
|
40
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
41
|
+
.replace(/on\w+\s*=/gi, "data-blocked=")
|
|
42
|
+
.replace(/javascript:/gi, "");
|
|
43
|
+
return safe;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Resolve a template src path.
|
|
47
|
+
// - "./foo.tpl" → relative to the parent template's folder (__srcBase)
|
|
48
|
+
// - "/foo.tpl" → absolute from server root (kept as-is for fetch)
|
|
49
|
+
// - "foo.tpl" → relative to page URL (kept as-is for fetch)
|
|
50
|
+
function _resolveTemplateSrc(src, tpl) {
|
|
51
|
+
if (src.startsWith("./")) {
|
|
52
|
+
// Walk up to find the nearest ancestor with __srcBase
|
|
53
|
+
let node = tpl.parentNode;
|
|
54
|
+
while (node) {
|
|
55
|
+
if (node.__srcBase) {
|
|
56
|
+
return node.__srcBase + src.slice(2);
|
|
57
|
+
}
|
|
58
|
+
node = node.parentNode;
|
|
59
|
+
}
|
|
60
|
+
// No ancestor base found — strip "./" and let fetch resolve from page
|
|
61
|
+
return src.slice(2);
|
|
62
|
+
}
|
|
63
|
+
// Absolute or plain relative — use the existing resolveUrl logic
|
|
64
|
+
return resolveUrl(src, tpl);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function _loadRemoteTemplates(root) {
|
|
68
|
+
const scope = root || document;
|
|
69
|
+
const templates = scope.querySelectorAll("template[src]");
|
|
70
|
+
_log("[LRT] called on", scope === document ? "document" : scope.nodeName || "fragment", "— found", templates.length, "template[src]", [...templates].map(t => t.getAttribute("src")));
|
|
71
|
+
if (!templates.length) return;
|
|
72
|
+
const promises = [...templates].map(async (tpl) => {
|
|
73
|
+
if (tpl.__srcLoaded) { _log("[LRT] SKIP (already loaded):", tpl.getAttribute("src")); return; }
|
|
74
|
+
tpl.__srcLoaded = true;
|
|
75
|
+
const src = tpl.getAttribute("src");
|
|
76
|
+
const resolvedUrl = _resolveTemplateSrc(src, tpl);
|
|
77
|
+
// Track the folder of this template so children can use "./" paths
|
|
78
|
+
const baseFolder = resolvedUrl.substring(0, resolvedUrl.lastIndexOf("/") + 1);
|
|
79
|
+
try {
|
|
80
|
+
const useCache = _config.templates.cache !== false;
|
|
81
|
+
let html;
|
|
82
|
+
if (useCache && _templateHtmlCache.has(resolvedUrl)) {
|
|
83
|
+
html = _templateHtmlCache.get(resolvedUrl);
|
|
84
|
+
_log("[LRT] CACHE HIT:", resolvedUrl);
|
|
85
|
+
} else {
|
|
86
|
+
const res = await fetch(resolvedUrl);
|
|
87
|
+
html = await res.text();
|
|
88
|
+
if (useCache) _templateHtmlCache.set(resolvedUrl, html);
|
|
89
|
+
}
|
|
90
|
+
tpl.innerHTML = html;
|
|
91
|
+
// Stamp the base folder onto the content so nested templates inherit it
|
|
92
|
+
if (tpl.content) {
|
|
93
|
+
tpl.content.__srcBase = baseFolder;
|
|
94
|
+
}
|
|
95
|
+
_log("Loaded remote template:", src, "→", resolvedUrl);
|
|
96
|
+
// Recursively load nested remote templates
|
|
97
|
+
await _loadRemoteTemplates(tpl.content || tpl);
|
|
98
|
+
// Non-route templates are content-includes: replace them with
|
|
99
|
+
// their loaded content so it actually renders (template elements
|
|
100
|
+
// are inert — the browser never displays their .content).
|
|
101
|
+
if (!tpl.hasAttribute("route") && tpl.parentNode) {
|
|
102
|
+
// Transfer __srcBase to a wrapper so descendants keep the reference
|
|
103
|
+
const frag = tpl.content;
|
|
104
|
+
const children = [...frag.childNodes];
|
|
105
|
+
const parent = tpl.parentNode;
|
|
106
|
+
const ref = tpl.nextSibling;
|
|
107
|
+
parent.removeChild(tpl);
|
|
108
|
+
for (const child of children) {
|
|
109
|
+
if (child.nodeType === 1) child.__srcBase = child.__srcBase || baseFolder;
|
|
110
|
+
parent.insertBefore(child, ref);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} catch (e) {
|
|
114
|
+
_warn("Failed to load template:", src, e.message);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
await Promise.all(promises);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Single-element loader (core primitive) ────────────────────────────────
|
|
121
|
+
export async function _loadTemplateElement(tpl) {
|
|
122
|
+
const src = tpl.getAttribute("src");
|
|
123
|
+
if (tpl.__srcLoaded) { _log("[LTE] SKIP (already loaded):", src); return; }
|
|
124
|
+
_log("[LTE] START fetch:", src, "| route:", tpl.hasAttribute("route"), "| inDOM:", document.contains(tpl), "| loading:", tpl.getAttribute("loading"));
|
|
125
|
+
tpl.__srcLoaded = true;
|
|
126
|
+
const resolvedUrl = _resolveTemplateSrc(src, tpl);
|
|
127
|
+
const baseFolder = resolvedUrl.substring(0, resolvedUrl.lastIndexOf("/") + 1);
|
|
128
|
+
|
|
129
|
+
// Synchronously insert loading placeholder before the fetch begins
|
|
130
|
+
let loadingMarker = null;
|
|
131
|
+
const loadingId = tpl.getAttribute("loading");
|
|
132
|
+
if (loadingId && tpl.parentNode) {
|
|
133
|
+
const cleanId = loadingId.startsWith("#") ? loadingId.slice(1) : loadingId;
|
|
134
|
+
const source = document.getElementById(cleanId);
|
|
135
|
+
if (source && source.content) {
|
|
136
|
+
loadingMarker = document.createElement("span");
|
|
137
|
+
loadingMarker.style.cssText = "display:contents";
|
|
138
|
+
loadingMarker.appendChild(source.content.cloneNode(true));
|
|
139
|
+
tpl.parentNode.insertBefore(loadingMarker, tpl);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const useCache = _config.templates.cache !== false;
|
|
145
|
+
let html;
|
|
146
|
+
if (useCache && _templateHtmlCache.has(resolvedUrl)) {
|
|
147
|
+
html = _templateHtmlCache.get(resolvedUrl);
|
|
148
|
+
_log("[LTE] CACHE HIT:", resolvedUrl);
|
|
149
|
+
} else {
|
|
150
|
+
const res = await fetch(resolvedUrl);
|
|
151
|
+
html = await res.text();
|
|
152
|
+
if (useCache) _templateHtmlCache.set(resolvedUrl, html);
|
|
153
|
+
}
|
|
154
|
+
tpl.innerHTML = html;
|
|
155
|
+
if (tpl.content) {
|
|
156
|
+
tpl.content.__srcBase = baseFolder;
|
|
157
|
+
}
|
|
158
|
+
_log("Loaded remote template:", src, "→", resolvedUrl);
|
|
159
|
+
// Route templates: defer nested loading until after DOM insertion
|
|
160
|
+
// (ensures loading="#id" placeholder lookup via getElementById works).
|
|
161
|
+
// Content-include templates: load nested ones now.
|
|
162
|
+
if (!tpl.hasAttribute("route")) {
|
|
163
|
+
await _loadRemoteTemplates(tpl.content || tpl);
|
|
164
|
+
}
|
|
165
|
+
// Remove loading placeholder once real content is ready
|
|
166
|
+
if (loadingMarker) loadingMarker.remove();
|
|
167
|
+
// Non-route templates are content-includes: inject content inline
|
|
168
|
+
if (!tpl.hasAttribute("route") && tpl.parentNode) {
|
|
169
|
+
const frag = tpl.content;
|
|
170
|
+
const children = [...frag.childNodes];
|
|
171
|
+
const parent = tpl.parentNode;
|
|
172
|
+
const ref = tpl.nextSibling;
|
|
173
|
+
parent.removeChild(tpl);
|
|
174
|
+
for (const child of children) {
|
|
175
|
+
if (child.nodeType === 1) child.__srcBase = child.__srcBase || baseFolder;
|
|
176
|
+
parent.insertBefore(child, ref);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} catch (e) {
|
|
180
|
+
if (loadingMarker) loadingMarker.remove();
|
|
181
|
+
_warn("Failed to load template:", src, e.message);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── Inline template includes (template[include="id"]) ────────────────────
|
|
186
|
+
// Synchronously clones a named inline template into every
|
|
187
|
+
// <template include="id"> placeholder. Called before any async fetch
|
|
188
|
+
// so skeleton/include content is stamped into the DOM immediately.
|
|
189
|
+
export function _processTemplateIncludes(root) {
|
|
190
|
+
const scope = root || document;
|
|
191
|
+
scope.querySelectorAll("template[include]").forEach((tpl) => {
|
|
192
|
+
const id = tpl.getAttribute("include");
|
|
193
|
+
const source = document.getElementById(id.startsWith("#") ? id.slice(1) : id);
|
|
194
|
+
if (!source || !source.content) return;
|
|
195
|
+
tpl.replaceWith(source.content.cloneNode(true));
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Phase 1 loader (priority + eager non-route + active route) ────────────
|
|
200
|
+
export async function _loadRemoteTemplatesPhase1(defaultRoutePath) {
|
|
201
|
+
const all = [...document.querySelectorAll("template[src]")];
|
|
202
|
+
|
|
203
|
+
// Phase 0: lazy="priority" templates — load first, regardless of route type
|
|
204
|
+
const phase0 = all.filter(
|
|
205
|
+
(tpl) => !tpl.__srcLoaded && tpl.getAttribute("lazy") === "priority"
|
|
206
|
+
);
|
|
207
|
+
await Promise.all(phase0.map(_loadTemplateElement));
|
|
208
|
+
|
|
209
|
+
// Phase 1: non-route templates + the route matching defaultRoutePath
|
|
210
|
+
// Skip lazy="ondemand" and lazy="priority" (already handled above)
|
|
211
|
+
const phase1 = all.filter((tpl) => {
|
|
212
|
+
if (tpl.__srcLoaded) return false;
|
|
213
|
+
const lazy = tpl.getAttribute("lazy");
|
|
214
|
+
if (lazy === "ondemand" || lazy === "priority") return false;
|
|
215
|
+
const isRoute = tpl.hasAttribute("route");
|
|
216
|
+
if (!isRoute) return true; // content-include: always Phase 1
|
|
217
|
+
// Route template: only include if it matches the current default path
|
|
218
|
+
return defaultRoutePath != null && tpl.getAttribute("route") === defaultRoutePath;
|
|
219
|
+
});
|
|
220
|
+
await Promise.all(phase1.map(_loadTemplateElement));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─── Phase 2 loader (background preload of remaining route templates) ──────
|
|
224
|
+
export function _loadRemoteTemplatesPhase2() {
|
|
225
|
+
const all = [...document.querySelectorAll("template[src]")];
|
|
226
|
+
const phase2 = all.filter((tpl) => {
|
|
227
|
+
if (tpl.__srcLoaded) return false;
|
|
228
|
+
if (tpl.getAttribute("lazy") === "ondemand") return false;
|
|
229
|
+
return tpl.hasAttribute("route");
|
|
230
|
+
});
|
|
231
|
+
return Promise.all(phase2.map(_loadTemplateElement));
|
|
232
|
+
}
|
package/src/evaluate.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
2
|
+
// EXPRESSION EVALUATOR
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
4
|
+
|
|
5
|
+
import { _stores, _routerInstance, _filters, _warn, _config } from "./globals.js";
|
|
6
|
+
import { _i18n } from "./i18n.js";
|
|
7
|
+
import { _collectKeys } from "./context.js";
|
|
8
|
+
|
|
9
|
+
const _exprCache = new Map();
|
|
10
|
+
|
|
11
|
+
// CSP-safe expression evaluator (no new Function / eval)
|
|
12
|
+
// Handles dot-notation paths, basic comparisons, boolean operators, negation, and literals.
|
|
13
|
+
function _cspSafeEval(expr, keys, vals) {
|
|
14
|
+
const scope = {};
|
|
15
|
+
for (let i = 0; i < keys.length; i++) scope[keys[i]] = vals[i];
|
|
16
|
+
|
|
17
|
+
function resolvePath(path, obj) {
|
|
18
|
+
return path.split(".").reduce((o, k) => o?.[k], obj);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseValue(token) {
|
|
22
|
+
const t = token.trim();
|
|
23
|
+
if (t === "true") return true;
|
|
24
|
+
if (t === "false") return false;
|
|
25
|
+
if (t === "null") return null;
|
|
26
|
+
if (t === "undefined") return undefined;
|
|
27
|
+
if (/^-?\d+(\.\d+)?$/.test(t)) return Number(t);
|
|
28
|
+
if (/^(['"`]).*\1$/.test(t)) return t.slice(1, -1);
|
|
29
|
+
// Treat as property path resolved from scope
|
|
30
|
+
return resolvePath(t, scope);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const trimmed = expr.trim();
|
|
34
|
+
|
|
35
|
+
// Handle ternary: condition ? trueExpr : falseExpr
|
|
36
|
+
const ternaryMatch = trimmed.match(/^(.+?)\s*\?\s*(.+?)\s*:\s*(.+)$/);
|
|
37
|
+
if (ternaryMatch) {
|
|
38
|
+
const cond = _cspSafeEval(ternaryMatch[1].trim(), keys, vals);
|
|
39
|
+
return cond
|
|
40
|
+
? _cspSafeEval(ternaryMatch[2].trim(), keys, vals)
|
|
41
|
+
: _cspSafeEval(ternaryMatch[3].trim(), keys, vals);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Handle logical OR (||)
|
|
45
|
+
if (trimmed.includes("||")) {
|
|
46
|
+
const parts = trimmed.split("||");
|
|
47
|
+
for (const part of parts) {
|
|
48
|
+
const val = _cspSafeEval(part.trim(), keys, vals);
|
|
49
|
+
if (val) return val;
|
|
50
|
+
}
|
|
51
|
+
return _cspSafeEval(parts[parts.length - 1].trim(), keys, vals);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Handle logical AND (&&)
|
|
55
|
+
if (trimmed.includes("&&")) {
|
|
56
|
+
const parts = trimmed.split("&&");
|
|
57
|
+
let last;
|
|
58
|
+
for (const part of parts) {
|
|
59
|
+
last = _cspSafeEval(part.trim(), keys, vals);
|
|
60
|
+
if (!last) return last;
|
|
61
|
+
}
|
|
62
|
+
return last;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Handle comparisons: ===, !==, ==, !=, >=, <=, >, <
|
|
66
|
+
const cmpMatch = trimmed.match(/^(.+?)\s*(===|!==|==|!=|>=|<=|>|<)\s*(.+)$/);
|
|
67
|
+
if (cmpMatch) {
|
|
68
|
+
const left = parseValue(cmpMatch[1]);
|
|
69
|
+
const right = parseValue(cmpMatch[3]);
|
|
70
|
+
switch (cmpMatch[2]) {
|
|
71
|
+
case "===": return left === right;
|
|
72
|
+
case "!==": return left !== right;
|
|
73
|
+
case "==": return left == right;
|
|
74
|
+
case "!=": return left != right;
|
|
75
|
+
case ">=": return left >= right;
|
|
76
|
+
case "<=": return left <= right;
|
|
77
|
+
case ">": return left > right;
|
|
78
|
+
case "<": return left < right;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Handle negation
|
|
83
|
+
if (trimmed.startsWith("!")) {
|
|
84
|
+
return !_cspSafeEval(trimmed.slice(1).trim(), keys, vals);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return parseValue(trimmed);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Parse pipe syntax: "expr | filter1 | filter2:arg"
|
|
91
|
+
function _parsePipes(exprStr) {
|
|
92
|
+
// Don't split on || (logical OR)
|
|
93
|
+
const parts = [];
|
|
94
|
+
let current = "";
|
|
95
|
+
let depth = 0;
|
|
96
|
+
let inStr = false;
|
|
97
|
+
let strChar = "";
|
|
98
|
+
for (let i = 0; i < exprStr.length; i++) {
|
|
99
|
+
const ch = exprStr[i];
|
|
100
|
+
if (inStr) {
|
|
101
|
+
current += ch;
|
|
102
|
+
if (ch === strChar && exprStr[i - 1] !== "\\") inStr = false;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (ch === "'" || ch === '"' || ch === "`") {
|
|
106
|
+
inStr = true;
|
|
107
|
+
strChar = ch;
|
|
108
|
+
current += ch;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (ch === "(" || ch === "[" || ch === "{") {
|
|
112
|
+
depth++;
|
|
113
|
+
current += ch;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (ch === ")" || ch === "]" || ch === "}") {
|
|
117
|
+
depth--;
|
|
118
|
+
current += ch;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (
|
|
122
|
+
ch === "|" &&
|
|
123
|
+
depth === 0 &&
|
|
124
|
+
exprStr[i + 1] !== "|" &&
|
|
125
|
+
exprStr[i - 1] !== "|"
|
|
126
|
+
) {
|
|
127
|
+
parts.push(current.trim());
|
|
128
|
+
current = "";
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
current += ch;
|
|
132
|
+
}
|
|
133
|
+
parts.push(current.trim());
|
|
134
|
+
return parts;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function _applyFilter(value, filterStr) {
|
|
138
|
+
const colonIdx = filterStr.indexOf(":");
|
|
139
|
+
let name, argStr;
|
|
140
|
+
if (colonIdx === -1) {
|
|
141
|
+
name = filterStr.trim();
|
|
142
|
+
argStr = null;
|
|
143
|
+
} else {
|
|
144
|
+
name = filterStr.substring(0, colonIdx).trim();
|
|
145
|
+
argStr = filterStr.substring(colonIdx + 1).trim();
|
|
146
|
+
}
|
|
147
|
+
const fn = _filters[name];
|
|
148
|
+
if (!fn) {
|
|
149
|
+
_warn(`Unknown filter: ${name}`);
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
// Parse args: split by comma but respect quotes
|
|
153
|
+
const args = argStr ? _parseFilterArgs(argStr) : [];
|
|
154
|
+
return fn(value, ...args);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function _parseFilterArgs(str) {
|
|
158
|
+
const args = [];
|
|
159
|
+
let current = "";
|
|
160
|
+
let inStr = false;
|
|
161
|
+
let strChar = "";
|
|
162
|
+
for (const ch of str) {
|
|
163
|
+
if (inStr) {
|
|
164
|
+
if (ch === strChar) {
|
|
165
|
+
inStr = false;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
current += ch;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (ch === "'" || ch === '"') {
|
|
172
|
+
inStr = true;
|
|
173
|
+
strChar = ch;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (ch === ",") {
|
|
177
|
+
args.push(current.trim());
|
|
178
|
+
current = "";
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
current += ch;
|
|
182
|
+
}
|
|
183
|
+
if (current.trim()) args.push(current.trim());
|
|
184
|
+
// Try to parse numbers
|
|
185
|
+
return args.map((a) => {
|
|
186
|
+
const n = Number(a);
|
|
187
|
+
return isNaN(n) ? a : n;
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function evaluate(expr, ctx) {
|
|
192
|
+
if (expr == null || expr === "") return undefined;
|
|
193
|
+
try {
|
|
194
|
+
const pipes = _parsePipes(expr);
|
|
195
|
+
const mainExpr = pipes[0];
|
|
196
|
+
const { keys, vals } = _collectKeys(ctx);
|
|
197
|
+
|
|
198
|
+
// Add special variables
|
|
199
|
+
const specialKeys = [
|
|
200
|
+
"$store",
|
|
201
|
+
"$route",
|
|
202
|
+
"$router",
|
|
203
|
+
"$i18n",
|
|
204
|
+
"$refs",
|
|
205
|
+
"$form",
|
|
206
|
+
];
|
|
207
|
+
for (const sk of specialKeys) {
|
|
208
|
+
if (!keys.includes(sk)) {
|
|
209
|
+
keys.push(sk);
|
|
210
|
+
vals[sk] = ctx[sk];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const keyArr = keys;
|
|
215
|
+
const valArr = keyArr.map((k) => vals[k]);
|
|
216
|
+
|
|
217
|
+
let result;
|
|
218
|
+
if (_config.csp === "strict") {
|
|
219
|
+
result = _cspSafeEval(mainExpr, keyArr, valArr);
|
|
220
|
+
} else {
|
|
221
|
+
let cacheKey = mainExpr + "|" + keyArr.join(",");
|
|
222
|
+
let fn = _exprCache.get(cacheKey);
|
|
223
|
+
if (!fn) {
|
|
224
|
+
fn = new Function(...keyArr, `return (${mainExpr})`);
|
|
225
|
+
_exprCache.set(cacheKey, fn);
|
|
226
|
+
}
|
|
227
|
+
result = fn(...valArr);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Apply filters
|
|
231
|
+
for (let i = 1; i < pipes.length; i++) {
|
|
232
|
+
result = _applyFilter(result, pipes[i]);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return result;
|
|
236
|
+
} catch (e) {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Execute a statement (for on:* handlers)
|
|
242
|
+
export function _execStatement(expr, ctx, extraVars = {}) {
|
|
243
|
+
try {
|
|
244
|
+
const { keys, vals } = _collectKeys(ctx);
|
|
245
|
+
// Add special vars
|
|
246
|
+
const specials = {
|
|
247
|
+
$store: _stores,
|
|
248
|
+
$route: _routerInstance?.current,
|
|
249
|
+
$router: _routerInstance,
|
|
250
|
+
$i18n: _i18n,
|
|
251
|
+
$refs: ctx.$refs,
|
|
252
|
+
};
|
|
253
|
+
Object.assign(specials, extraVars);
|
|
254
|
+
for (const [k, v] of Object.entries(specials)) {
|
|
255
|
+
if (!keys.includes(k)) {
|
|
256
|
+
keys.push(k);
|
|
257
|
+
vals[k] = v;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const keyArr = [...keys];
|
|
262
|
+
const valArr = keyArr.map((k) => vals[k]);
|
|
263
|
+
|
|
264
|
+
// Build setters to write back state through the full context chain.
|
|
265
|
+
// For each key in any ancestor context, find the owning context at runtime
|
|
266
|
+
// and call $set on it — so mutations inside `each` loops correctly
|
|
267
|
+
// propagate back to parent state (e.g. cart updated from a loop's on:click).
|
|
268
|
+
const chainKeys = new Set();
|
|
269
|
+
let _wCtx = ctx;
|
|
270
|
+
while (_wCtx && _wCtx.__isProxy) {
|
|
271
|
+
for (const k of Object.keys(_wCtx.__raw)) chainKeys.add(k);
|
|
272
|
+
_wCtx = _wCtx.$parent;
|
|
273
|
+
}
|
|
274
|
+
const setters = [...chainKeys]
|
|
275
|
+
.map(
|
|
276
|
+
(k) =>
|
|
277
|
+
`{let _c=__ctx;while(_c&&_c.__isProxy){if('${k}'in _c.__raw){_c.$set('${k}',typeof ${k}!=='undefined'?${k}:_c.__raw['${k}']);break;}_c=_c.$parent;}}`,
|
|
278
|
+
)
|
|
279
|
+
.join("\n");
|
|
280
|
+
|
|
281
|
+
const fn = new Function("__ctx", ...keyArr, `${expr};\n${setters}`);
|
|
282
|
+
fn(ctx, ...valArr);
|
|
283
|
+
} catch (e) {
|
|
284
|
+
_warn("Expression error:", expr, e.message);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function resolve(path, ctx) {
|
|
289
|
+
return path.split(".").reduce((o, k) => o?.[k], ctx);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Interpolate strings like "/users/{user.id}?q={search}"
|
|
293
|
+
export function _interpolate(str, ctx) {
|
|
294
|
+
return str.replace(/\{([^}]+)\}/g, (_, expr) => {
|
|
295
|
+
const val = evaluate(expr.trim(), ctx);
|
|
296
|
+
return val != null ? val : "";
|
|
297
|
+
});
|
|
298
|
+
}
|
package/src/fetch.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
2
|
+
// FETCH HELPER, URL RESOLUTION & CACHE
|
|
3
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
4
|
+
|
|
5
|
+
import { _config, _interceptors, _cache } from "./globals.js";
|
|
6
|
+
|
|
7
|
+
export function resolveUrl(url, el) {
|
|
8
|
+
if (
|
|
9
|
+
url.startsWith("http://") ||
|
|
10
|
+
url.startsWith("https://") ||
|
|
11
|
+
url.startsWith("//")
|
|
12
|
+
)
|
|
13
|
+
return url;
|
|
14
|
+
let node = el;
|
|
15
|
+
while (node) {
|
|
16
|
+
const base = node.getAttribute?.("base");
|
|
17
|
+
if (base) return base.replace(/\/+$/, "") + "/" + url.replace(/^\/+/, "");
|
|
18
|
+
node = node.parentElement;
|
|
19
|
+
}
|
|
20
|
+
if (_config.baseApiUrl)
|
|
21
|
+
return (
|
|
22
|
+
_config.baseApiUrl.replace(/\/+$/, "") + "/" + url.replace(/^\/+/, "")
|
|
23
|
+
);
|
|
24
|
+
return url;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function _doFetch(
|
|
28
|
+
url,
|
|
29
|
+
method = "GET",
|
|
30
|
+
body = null,
|
|
31
|
+
extraHeaders = {},
|
|
32
|
+
el = null,
|
|
33
|
+
externalSignal = null,
|
|
34
|
+
) {
|
|
35
|
+
const fullUrl = resolveUrl(url, el);
|
|
36
|
+
let opts = {
|
|
37
|
+
method: method.toUpperCase(),
|
|
38
|
+
headers: { ...(_config.headers || {}), ...extraHeaders },
|
|
39
|
+
credentials: _config.credentials,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (body && method !== "GET") {
|
|
43
|
+
if (typeof body === "string") {
|
|
44
|
+
try {
|
|
45
|
+
JSON.parse(body);
|
|
46
|
+
opts.headers["Content-Type"] = "application/json";
|
|
47
|
+
opts.body = body;
|
|
48
|
+
} catch {
|
|
49
|
+
opts.body = body;
|
|
50
|
+
}
|
|
51
|
+
} else if (body instanceof FormData) {
|
|
52
|
+
opts.body = body;
|
|
53
|
+
} else {
|
|
54
|
+
opts.headers["Content-Type"] = "application/json";
|
|
55
|
+
opts.body = JSON.stringify(body);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// CSRF
|
|
60
|
+
if (_config.csrf && method !== "GET") {
|
|
61
|
+
opts.headers[_config.csrf.header || "X-CSRF-Token"] =
|
|
62
|
+
_config.csrf.token || "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Request interceptors
|
|
66
|
+
for (const fn of _interceptors.request) {
|
|
67
|
+
opts = fn(fullUrl, opts) || opts;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Retry logic
|
|
71
|
+
const maxRetries = _config.retries || 0;
|
|
72
|
+
let lastError;
|
|
73
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
74
|
+
try {
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const timeout = setTimeout(
|
|
77
|
+
() => controller.abort(),
|
|
78
|
+
_config.timeout || 10000,
|
|
79
|
+
);
|
|
80
|
+
// Wire external abort signal (switchMap) to internal controller
|
|
81
|
+
if (externalSignal) {
|
|
82
|
+
if (externalSignal.aborted) {
|
|
83
|
+
clearTimeout(timeout);
|
|
84
|
+
throw new DOMException("Aborted", "AbortError");
|
|
85
|
+
}
|
|
86
|
+
externalSignal.addEventListener("abort", () => controller.abort(), {
|
|
87
|
+
once: true,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
opts.signal = controller.signal;
|
|
91
|
+
|
|
92
|
+
let response = await fetch(fullUrl, opts);
|
|
93
|
+
clearTimeout(timeout);
|
|
94
|
+
|
|
95
|
+
// Response interceptors
|
|
96
|
+
for (const fn of _interceptors.response) {
|
|
97
|
+
response = fn(response, fullUrl) || response;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
const errBody = await response.json().catch(() => ({}));
|
|
102
|
+
const err = new Error(errBody.message || `HTTP ${response.status}`);
|
|
103
|
+
err.status = response.status;
|
|
104
|
+
err.body = errBody;
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const text = await response.text();
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(text);
|
|
111
|
+
} catch {
|
|
112
|
+
return text;
|
|
113
|
+
}
|
|
114
|
+
} catch (e) {
|
|
115
|
+
if (e.name === "AbortError") throw e; // Don't retry aborted requests
|
|
116
|
+
lastError = e;
|
|
117
|
+
if (attempt < maxRetries) {
|
|
118
|
+
await new Promise((r) => setTimeout(r, _config.retryDelay || 1000));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
throw lastError;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function _cacheGet(key, strategy) {
|
|
126
|
+
if (strategy === "none") return null;
|
|
127
|
+
if (strategy === "memory") {
|
|
128
|
+
const entry = _cache.get(key);
|
|
129
|
+
if (entry && Date.now() - entry.time < (_config.cache.ttl || 300000))
|
|
130
|
+
return entry.data;
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
const store =
|
|
134
|
+
strategy === "local"
|
|
135
|
+
? localStorage
|
|
136
|
+
: strategy === "session"
|
|
137
|
+
? sessionStorage
|
|
138
|
+
: null;
|
|
139
|
+
if (!store) return null;
|
|
140
|
+
try {
|
|
141
|
+
const raw = store.getItem("nojs_cache_" + key);
|
|
142
|
+
if (!raw) return null;
|
|
143
|
+
const entry = JSON.parse(raw);
|
|
144
|
+
if (Date.now() - entry.time < (_config.cache.ttl || 300000))
|
|
145
|
+
return entry.data;
|
|
146
|
+
store.removeItem("nojs_cache_" + key);
|
|
147
|
+
} catch {
|
|
148
|
+
/* ignore */
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function _cacheSet(key, data, strategy) {
|
|
154
|
+
if (strategy === "none") return;
|
|
155
|
+
const entry = { data, time: Date.now() };
|
|
156
|
+
if (strategy === "memory") {
|
|
157
|
+
_cache.set(key, entry);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const store =
|
|
161
|
+
strategy === "local"
|
|
162
|
+
? localStorage
|
|
163
|
+
: strategy === "session"
|
|
164
|
+
? sessionStorage
|
|
165
|
+
: null;
|
|
166
|
+
if (store) {
|
|
167
|
+
try {
|
|
168
|
+
store.setItem("nojs_cache_" + key, JSON.stringify(entry));
|
|
169
|
+
} catch {
|
|
170
|
+
/* ignore */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|