spark-ssr 0.1.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/README.md +117 -0
- package/bin/cli.js +95 -0
- package/package.json +45 -0
- package/src/config.js +40 -0
- package/src/db.js +72 -0
- package/src/hydrate.js +161 -0
- package/src/index.js +14 -0
- package/src/parse.js +221 -0
- package/src/render.js +227 -0
- package/src/server.js +674 -0
package/src/parse.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTML template IS the spec — this module reads it.
|
|
3
|
+
*
|
|
4
|
+
* • extractBlocks: pull <spark-ssr> declarations out of a page.
|
|
5
|
+
* <spark-ssr table="todos" /> table mode (auto CRUD)
|
|
6
|
+
* <spark-ssr> GET /api/x → SELECT … </spark-ssr> explicit queries
|
|
7
|
+
* • rewriteParams: quote-aware `:param` → `?` placeholder rewrite.
|
|
8
|
+
* • analyze: what data the template needs, which binds are local state,
|
|
9
|
+
* which handlers play which structural role (insert / update / delete).
|
|
10
|
+
* • dataPlan: match template needs to the declared data sources.
|
|
11
|
+
*/
|
|
12
|
+
import { parseHTML } from 'linkedom';
|
|
13
|
+
|
|
14
|
+
// ── <spark-ssr> blocks ─────────────────────────────────────────────────
|
|
15
|
+
export function extractBlocks(source) {
|
|
16
|
+
const blocks = [];
|
|
17
|
+
const re = /<spark-ssr\b([^>]*?)\/>|<spark-ssr\b([^>]*)>([\s\S]*?)<\/spark-ssr>/gi;
|
|
18
|
+
const html = String(source).replace(re, (m, selfAttrs, attrs, inner) => {
|
|
19
|
+
const attrStr = selfAttrs ?? attrs ?? '';
|
|
20
|
+
const table = (attrStr.match(/\btable\s*=\s*"([^"]+)"/) || [])[1] || null;
|
|
21
|
+
blocks.push({ table, routes: inner ? parseRoutes(inner) : [] });
|
|
22
|
+
return '';
|
|
23
|
+
});
|
|
24
|
+
return { blocks, html };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Route lines: METHOD [/path] → SQL (SQL may continue on following lines).
|
|
28
|
+
// `->` is accepted alongside `→`.
|
|
29
|
+
function parseRoutes(text) {
|
|
30
|
+
const routes = [];
|
|
31
|
+
let cur = null;
|
|
32
|
+
for (const line of String(text).split('\n')) {
|
|
33
|
+
const m = line.match(/^\s*(GET|POST|PUT|PATCH|DELETE)\s*(\/\S*)?\s*(?:→|->)\s*([\s\S]*)$/);
|
|
34
|
+
if (m) {
|
|
35
|
+
if (cur) routes.push(cur);
|
|
36
|
+
cur = { method: m[1], path: m[2] || null, sql: m[3].trim() };
|
|
37
|
+
} else if (cur && line.trim()) {
|
|
38
|
+
cur.sql += '\n' + line.trim();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (cur) routes.push(cur);
|
|
42
|
+
return routes.filter((r) => r.sql);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── :param → ? rewrite ─────────────────────────────────────────────────
|
|
46
|
+
// Skips single-quoted SQL strings ('12:30', '' escapes) and `::` casts.
|
|
47
|
+
// Tokens keep dots and dashes (:body.title, :header.x-forwarded-for).
|
|
48
|
+
export function rewriteParams(sql) {
|
|
49
|
+
let out = '';
|
|
50
|
+
const tokens = [];
|
|
51
|
+
const s = String(sql);
|
|
52
|
+
for (let i = 0; i < s.length; i++) {
|
|
53
|
+
const ch = s[i];
|
|
54
|
+
if (ch === "'") {
|
|
55
|
+
let j = i + 1;
|
|
56
|
+
while (j < s.length) {
|
|
57
|
+
if (s[j] === "'" && s[j + 1] === "'") { j += 2; continue; }
|
|
58
|
+
if (s[j] === "'") break;
|
|
59
|
+
j++;
|
|
60
|
+
}
|
|
61
|
+
out += s.slice(i, j + 1);
|
|
62
|
+
i = j;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (ch === ':' && s[i + 1] === ':') { out += '::'; i++; continue; }
|
|
66
|
+
if (ch === ':' && /[a-zA-Z_$]/.test(s[i + 1] || '')) {
|
|
67
|
+
let j = i + 1;
|
|
68
|
+
while (j < s.length && /[\w$.-]/.test(s[j])) j++;
|
|
69
|
+
const tok = s.slice(i + 1, j).replace(/[.-]+$/, '');
|
|
70
|
+
tokens.push(tok);
|
|
71
|
+
out += '?';
|
|
72
|
+
i = i + tok.length; // resume after the token (loop ++ steps past it)
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
out += ch;
|
|
76
|
+
}
|
|
77
|
+
return { sql: out, tokens };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── template analysis ──────────────────────────────────────────────────
|
|
81
|
+
const rootOf = (expr) => (String(expr).trim().match(/^[a-zA-Z_$][\w$]*/) || [])[0] || null;
|
|
82
|
+
const BRACE = /\{\s*([a-zA-Z_$][\w$]*)/g;
|
|
83
|
+
|
|
84
|
+
export function analyze(html) {
|
|
85
|
+
const { document } = parseHTML('<html><body>' + html + '</body></html>');
|
|
86
|
+
const needs = new Set(); // root identifiers the template reads
|
|
87
|
+
const eachRoots = new Set(); // …used as list sources
|
|
88
|
+
const memberRoots = new Set(); // …accessed as objects ({post.title})
|
|
89
|
+
const topBinds = []; // bind:* to a plain var outside loops → local state
|
|
90
|
+
const rowBinds = []; // bind:* to a member of a loop var → editable fields
|
|
91
|
+
const handlers = []; // bare-ref on* handlers with structural context
|
|
92
|
+
let hasScript = false;
|
|
93
|
+
|
|
94
|
+
const addRoots = (text, loops) => {
|
|
95
|
+
const s = String(text);
|
|
96
|
+
for (const m of s.matchAll(BRACE)) {
|
|
97
|
+
if (loops.includes(m[1]) || m[1] === 'await' || m[1] === 'event') continue;
|
|
98
|
+
needs.add(m[1]);
|
|
99
|
+
const after = s[m.index + m[0].length];
|
|
100
|
+
if (after === '.' || after === '[') memberRoots.add(m[1]);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
(function walk(node, loops) {
|
|
105
|
+
if (node.nodeType === 3) return addRoots(node.data || '', loops);
|
|
106
|
+
if (node.nodeType !== 1) return;
|
|
107
|
+
const tag = (node.tagName || '').toLowerCase();
|
|
108
|
+
if (tag === 'script') { hasScript = true; return; }
|
|
109
|
+
if (tag === 'style') return;
|
|
110
|
+
|
|
111
|
+
let nextLoops = loops;
|
|
112
|
+
if (tag === 'template') {
|
|
113
|
+
const each = node.getAttribute('each');
|
|
114
|
+
const aw = node.getAttribute('await');
|
|
115
|
+
const iff = node.getAttribute('if') || node.getAttribute('else-if');
|
|
116
|
+
if (each) {
|
|
117
|
+
const em = each.match(/^\s*([\w$]+)\s*(?:,\s*([\w$]+))?\s+in\s+([\s\S]+)$/);
|
|
118
|
+
if (em) {
|
|
119
|
+
const src = rootOf(em[3]);
|
|
120
|
+
if (src && !loops.includes(src)) { needs.add(src); eachRoots.add(src); }
|
|
121
|
+
nextLoops = [...loops, em[1], ...(em[2] ? [em[2]] : [])];
|
|
122
|
+
}
|
|
123
|
+
} else if (aw) {
|
|
124
|
+
const src = rootOf(aw.replace(/^once\(/, ''));
|
|
125
|
+
if (src && !loops.includes(src)) needs.add(src);
|
|
126
|
+
} else if (iff) {
|
|
127
|
+
const src = rootOf(iff);
|
|
128
|
+
if (src && !loops.includes(src)) needs.add(src);
|
|
129
|
+
}
|
|
130
|
+
for (const c of (node.content || node).childNodes) walk(c, nextLoops);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const nodeHandlers = [];
|
|
135
|
+
let memberBind = false;
|
|
136
|
+
for (const attr of [...(node.attributes || [])]) {
|
|
137
|
+
const n = attr.name;
|
|
138
|
+
const v = String(attr.value || '');
|
|
139
|
+
if (n === 'bind:value' || n === 'bind:checked' || n === 'bind:group') {
|
|
140
|
+
const root = rootOf(v);
|
|
141
|
+
const dot = v.indexOf('.');
|
|
142
|
+
if (root && loops.includes(root) && dot > 0) {
|
|
143
|
+
rowBinds.push({ loopVar: root, field: v.slice(dot + 1).trim() });
|
|
144
|
+
memberBind = true;
|
|
145
|
+
} else if (root && dot === -1) {
|
|
146
|
+
if (!topBinds.some((b) => b.v === root)) topBinds.push({ v: root, kind: n.slice(5) });
|
|
147
|
+
}
|
|
148
|
+
} else if (/^on\w+$/.test(n) && /^\{[\s\S]*\}$/.test(v.trim())) {
|
|
149
|
+
const inner = v.trim().slice(1, -1).trim();
|
|
150
|
+
if (/^[a-zA-Z_$][\w$]*$/.test(inner)) {
|
|
151
|
+
nodeHandlers.push({ name: inner, event: n.slice(2), inEach: loops.length ? loops[loops.length - 1] : null });
|
|
152
|
+
} else {
|
|
153
|
+
addRoots(v, loops);
|
|
154
|
+
}
|
|
155
|
+
} else if (n.startsWith(':')) {
|
|
156
|
+
const src = rootOf(v);
|
|
157
|
+
if (src && !loops.includes(src)) needs.add(src);
|
|
158
|
+
} else {
|
|
159
|
+
addRoots(v, loops);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const h of nodeHandlers) handlers.push({ ...h, withMemberBind: memberBind });
|
|
163
|
+
|
|
164
|
+
for (const c of node.childNodes) walk(c, loops);
|
|
165
|
+
})(document.body, []);
|
|
166
|
+
|
|
167
|
+
// Local state and handler names aren't data the server must provide.
|
|
168
|
+
for (const b of topBinds) needs.delete(b.v);
|
|
169
|
+
for (const h of handlers) needs.delete(h.name);
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
needs, eachRoots, memberRoots, topBinds, rowBinds, handlers, hasScript,
|
|
173
|
+
interactive: handlers.length > 0 || topBinds.length > 0 || rowBinds.length > 0,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── data plan: match template needs to declared sources ───────────────
|
|
178
|
+
const singular = (s) =>
|
|
179
|
+
s.endsWith('ies') ? s.slice(0, -3) + 'y' : s.endsWith('s') ? s.slice(0, -1) : s;
|
|
180
|
+
|
|
181
|
+
export function dataPlan(analysis, blocks) {
|
|
182
|
+
const sources = [];
|
|
183
|
+
for (const b of blocks) {
|
|
184
|
+
if (b.table) sources.push({ kind: 'table', table: b.table, name: b.table });
|
|
185
|
+
for (const r of b.routes) {
|
|
186
|
+
if (r.method !== 'GET' || !r.path) continue;
|
|
187
|
+
const segs = r.path.split('/').filter(Boolean)
|
|
188
|
+
.filter((sg) => !sg.startsWith(':') && !sg.startsWith('['));
|
|
189
|
+
sources.push({ kind: 'query', route: r, name: segs[segs.length - 1] || null });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const plan = [];
|
|
193
|
+
const unresolved = [];
|
|
194
|
+
for (const name of analysis.needs) {
|
|
195
|
+
const shape = analysis.eachRoots.has(name) ? 'list' : 'row';
|
|
196
|
+
const src = sources.find((sg) => sg.name === name)
|
|
197
|
+
|| sources.find((sg) => sg.name && singular(sg.name) === name);
|
|
198
|
+
if (src) plan.push({ var: name, source: src, shape: src.kind === 'table' && shape !== 'list' ? 'list' : shape });
|
|
199
|
+
else unresolved.push(name);
|
|
200
|
+
}
|
|
201
|
+
// One leftover DATA-shaped need + one unmatched source → they belong
|
|
202
|
+
// together (the blog example: GET /api/blog feeds {post.title}). Bare
|
|
203
|
+
// scalars like {q} come from the request, not from a query — skip them.
|
|
204
|
+
const unmatched = sources.filter((sg) => !plan.some((p) => p.source === sg));
|
|
205
|
+
const dataShaped = unresolved.filter(
|
|
206
|
+
(n) => analysis.eachRoots.has(n) || (analysis.memberRoots && analysis.memberRoots.has(n)),
|
|
207
|
+
);
|
|
208
|
+
if (dataShaped.length === 1 && unmatched.length === 1) {
|
|
209
|
+
const name = dataShaped[0];
|
|
210
|
+
plan.push({ var: name, source: unmatched[0], shape: analysis.eachRoots.has(name) ? 'list' : 'row' });
|
|
211
|
+
}
|
|
212
|
+
return plan;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// A query whose result is one row by construction (aggregates without
|
|
216
|
+
// GROUP BY, or LIMIT 1) serves an object instead of an array.
|
|
217
|
+
export function singleShaped(sql) {
|
|
218
|
+
const s = String(sql);
|
|
219
|
+
if (/\blimit\s+1\b/i.test(s)) return true;
|
|
220
|
+
return /\b(count|sum|avg|min|max|total)\s*\(/i.test(s) && !/\bgroup\s+by\b/i.test(s);
|
|
221
|
+
}
|
package/src/render.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side renderer for Spark templates: {expr} interpolation,
|
|
3
|
+
* <template each/if/else-if/else/await>, :attr dynamics, and <div import>
|
|
4
|
+
* component composition — rendered to static HTML in one pass. Event
|
|
5
|
+
* handlers and bind: attributes are stripped (the hydration component
|
|
6
|
+
* re-attaches them client-side; static pages don't need them).
|
|
7
|
+
*/
|
|
8
|
+
import { parseHTML } from 'linkedom';
|
|
9
|
+
|
|
10
|
+
const FN_CACHE = new Map();
|
|
11
|
+
function compile(expr) {
|
|
12
|
+
let fn = FN_CACHE.get(expr);
|
|
13
|
+
if (!fn) {
|
|
14
|
+
try { fn = new Function('__scope__', 'with (__scope__) { return (' + expr + '); }'); }
|
|
15
|
+
catch { fn = () => undefined; }
|
|
16
|
+
FN_CACHE.set(expr, fn);
|
|
17
|
+
}
|
|
18
|
+
return fn;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Every identifier resolves through the scope proxy (undefined when absent),
|
|
22
|
+
// with the handful of globals expressions legitimately reach for.
|
|
23
|
+
const GLOBALS = {
|
|
24
|
+
JSON, Math, Date, Object, Array, String, Number, Boolean,
|
|
25
|
+
encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
|
|
26
|
+
};
|
|
27
|
+
function scopeProxy(scope) {
|
|
28
|
+
return new Proxy(scope, {
|
|
29
|
+
has: (t, k) => k !== Symbol.unscopables,
|
|
30
|
+
get: (t, k) => (k === Symbol.unscopables ? undefined : k in t ? t[k] : GLOBALS[k]),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export function evalExpr(expr, scope) {
|
|
34
|
+
try { return compile(expr)(scopeProxy(scope)); }
|
|
35
|
+
catch { return undefined; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const str = (v) => (v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v));
|
|
39
|
+
|
|
40
|
+
// Template children may live in .content or .childNodes depending on how
|
|
41
|
+
// linkedom parsed the (possibly nested) template — read both.
|
|
42
|
+
const kids = (node) => [
|
|
43
|
+
...(node.content ? node.content.childNodes : []),
|
|
44
|
+
...node.childNodes,
|
|
45
|
+
];
|
|
46
|
+
const interpolate = (text, scope) =>
|
|
47
|
+
String(text).replace(/\{([^{}]+)\}/g, (_, e) => str(evalExpr(e, scope)));
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Render an HTML fragment (a Spark page or component body) with `scope`.
|
|
51
|
+
* ctx: { loadComponent(spec) → html|null }
|
|
52
|
+
*/
|
|
53
|
+
export async function renderFragment(html, scope, ctx = {}, depth = 0) {
|
|
54
|
+
const { document } = parseHTML('<!doctype html><html><body>' + html + '</body></html>');
|
|
55
|
+
await walkChildren(document.body, scope, { maxDepth: 20, ...ctx, document }, depth);
|
|
56
|
+
return document.body.innerHTML;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function walkChildren(el, scope, ctx, depth) {
|
|
60
|
+
for (const child of [...el.childNodes]) await walkNode(child, scope, ctx, depth);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function walkNode(node, scope, ctx, depth) {
|
|
64
|
+
if (node.nodeType === 3) {
|
|
65
|
+
const t = String(node.data || '');
|
|
66
|
+
if (t.includes('{')) node.data = interpolate(t, scope);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (node.nodeType !== 1) return;
|
|
70
|
+
const tag = (node.tagName || '').toLowerCase();
|
|
71
|
+
if (node.hasAttribute && node.hasAttribute('spark-ignore')) return;
|
|
72
|
+
if (tag === 'script') { node.remove(); return; }
|
|
73
|
+
if (tag === 'style') return;
|
|
74
|
+
|
|
75
|
+
if (tag === 'template') {
|
|
76
|
+
if (node.hasAttribute('each')) return renderEach(node, scope, ctx, depth);
|
|
77
|
+
if (node.hasAttribute('await')) return renderAwait(node, scope, ctx, depth);
|
|
78
|
+
if (node.hasAttribute('if')) return renderIfChain(node, scope, ctx, depth);
|
|
79
|
+
if (node.hasAttribute('else-if') || node.hasAttribute('else')) { node.remove(); return; }
|
|
80
|
+
return; // plain template: leave inert
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (node.hasAttribute('import')) return renderImport(node, scope, ctx, depth);
|
|
84
|
+
|
|
85
|
+
renderAttrs(node, scope);
|
|
86
|
+
await walkChildren(node, scope, ctx, depth);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderAttrs(node, scope) {
|
|
90
|
+
for (const attr of [...node.attributes]) {
|
|
91
|
+
const n = attr.name;
|
|
92
|
+
const v = String(attr.value || '');
|
|
93
|
+
if (n.startsWith('bind:') || (/^on\w+$/.test(n) && v.trim().startsWith('{'))) {
|
|
94
|
+
node.removeAttribute(n);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (n.startsWith(':')) {
|
|
98
|
+
const val = evalExpr(v, scope);
|
|
99
|
+
node.removeAttribute(n);
|
|
100
|
+
if (val === false || val == null) continue;
|
|
101
|
+
if (n === ':class') {
|
|
102
|
+
node.setAttribute('class', ((node.getAttribute('class') || '') + ' ' + str(val)).trim());
|
|
103
|
+
} else {
|
|
104
|
+
node.setAttribute(n.slice(1), val === true ? '' : str(val));
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (v.includes('{')) attr.value = interpolate(v, scope);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Insert rendered clones of `nodes` before `anchor`, walking each with `scope`.
|
|
113
|
+
async function insertRendered(nodes, anchor, scope, ctx, depth) {
|
|
114
|
+
for (const n of nodes) {
|
|
115
|
+
const clone = n.cloneNode(true);
|
|
116
|
+
anchor.parentNode.insertBefore(clone, anchor);
|
|
117
|
+
await walkNode(clone, scope, ctx, depth);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function renderEach(node, scope, ctx, depth) {
|
|
122
|
+
const expr = node.getAttribute('each') || '';
|
|
123
|
+
const m = expr.match(/^\s*([\w$]+)\s*(?:,\s*([\w$]+))?\s+in\s+([\s\S]+)$/);
|
|
124
|
+
const arr = m ? evalExpr(m[3], scope) : null;
|
|
125
|
+
if (m && Array.isArray(arr)) {
|
|
126
|
+
const content = kids(node);
|
|
127
|
+
for (let i = 0; i < arr.length; i++) {
|
|
128
|
+
const rowScope = Object.create(scope);
|
|
129
|
+
rowScope[m[1]] = arr[i];
|
|
130
|
+
if (m[2]) rowScope[m[2]] = i;
|
|
131
|
+
await insertRendered(content, node, rowScope, ctx, depth);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
node.remove();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function renderAwait(node, scope, ctx, depth) {
|
|
138
|
+
let value;
|
|
139
|
+
let failed = null;
|
|
140
|
+
try {
|
|
141
|
+
value = evalExpr(node.getAttribute('await').replace(/^once\(([\s\S]*)\)$/, '$1'), scope);
|
|
142
|
+
if (value && typeof value.then === 'function') value = await value;
|
|
143
|
+
} catch (e) { failed = e; }
|
|
144
|
+
|
|
145
|
+
const content = kids(node);
|
|
146
|
+
const isTpl = (n, a) => n.nodeType === 1 && (n.tagName || '').toLowerCase() === 'template' && n.hasAttribute(a);
|
|
147
|
+
const thenNodes = [];
|
|
148
|
+
const catchNodes = [];
|
|
149
|
+
const direct = [];
|
|
150
|
+
for (const c of content) {
|
|
151
|
+
if (isTpl(c, 'then')) thenNodes.push(...kids(c));
|
|
152
|
+
else if (isTpl(c, 'catch')) catchNodes.push(...kids(c));
|
|
153
|
+
else direct.push(c);
|
|
154
|
+
}
|
|
155
|
+
const branchScope = Object.create(scope);
|
|
156
|
+
branchScope.await = failed || value;
|
|
157
|
+
const as = node.getAttribute('as');
|
|
158
|
+
if (as) branchScope[as] = failed || value;
|
|
159
|
+
// Resolved: the then-branch when declared, otherwise the direct content
|
|
160
|
+
// (the doc's `<template await="todos">…</template>` shorthand).
|
|
161
|
+
const branch = failed ? catchNodes : thenNodes.length ? thenNodes : direct;
|
|
162
|
+
await insertRendered(branch, node, branchScope, ctx, depth);
|
|
163
|
+
node.remove();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function renderIfChain(node, scope, ctx, depth) {
|
|
167
|
+
// Collect the chain: this template plus adjacent else-if / else templates
|
|
168
|
+
// (whitespace between them is fine).
|
|
169
|
+
const chain = [{ node, expr: node.getAttribute('if') }];
|
|
170
|
+
let probe = node.nextSibling;
|
|
171
|
+
while (probe) {
|
|
172
|
+
if (probe.nodeType === 3 && !String(probe.data).trim()) { probe = probe.nextSibling; continue; }
|
|
173
|
+
if (probe.nodeType === 1 && (probe.tagName || '').toLowerCase() === 'template'
|
|
174
|
+
&& (probe.hasAttribute('else-if') || probe.hasAttribute('else'))) {
|
|
175
|
+
chain.push({ node: probe, expr: probe.hasAttribute('else-if') ? probe.getAttribute('else-if') : null });
|
|
176
|
+
probe = probe.nextSibling;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
let winner = null;
|
|
182
|
+
for (const link of chain) {
|
|
183
|
+
if (link.expr === null || evalExpr(link.expr, scope)) { winner = link; break; }
|
|
184
|
+
}
|
|
185
|
+
if (winner) {
|
|
186
|
+
await insertRendered(kids(winner.node), winner.node, scope, ctx, depth);
|
|
187
|
+
}
|
|
188
|
+
for (const link of chain) link.node.remove();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function renderImport(node, scope, ctx, depth) {
|
|
192
|
+
const spec = node.getAttribute('import');
|
|
193
|
+
node.removeAttribute('import');
|
|
194
|
+
if (depth >= (ctx.maxDepth || 20)) { node.innerHTML = ''; return; }
|
|
195
|
+
|
|
196
|
+
// Slot content renders in the CALLER's scope, before the component swaps in.
|
|
197
|
+
await walkChildren(node, scope, ctx, depth);
|
|
198
|
+
const slotHtml = node.innerHTML;
|
|
199
|
+
|
|
200
|
+
// Props: attribute values; `{expr}` evaluates in the caller's scope
|
|
201
|
+
// (objects pass through intact). class/id stay on the host element.
|
|
202
|
+
const props = Object.create(null);
|
|
203
|
+
for (const attr of [...node.attributes]) {
|
|
204
|
+
const n = attr.name;
|
|
205
|
+
const v = String(attr.value || '');
|
|
206
|
+
if (n === 'class' || n === 'id') { if (v.includes('{')) attr.value = interpolate(v, scope); continue; }
|
|
207
|
+
const exact = v.trim().match(/^\{([\s\S]+)\}$/);
|
|
208
|
+
props[n] = exact ? evalExpr(exact[1], scope) : v.includes('{') ? interpolate(v, scope) : v;
|
|
209
|
+
node.removeAttribute(n);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const source = ctx.loadComponent ? await ctx.loadComponent(spec) : null;
|
|
213
|
+
if (source == null) { node.innerHTML = ''; return; }
|
|
214
|
+
// Components are pure UI: strip their <spark-ssr>/<script>, keep markup+style.
|
|
215
|
+
const clean = String(source)
|
|
216
|
+
.replace(/<spark-ssr\b[^>]*?\/>|<spark-ssr\b[^>]*>[\s\S]*?<\/spark-ssr>/gi, '')
|
|
217
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
|
|
218
|
+
node.innerHTML = clean;
|
|
219
|
+
await walkChildren(node, props, ctx, depth + 1);
|
|
220
|
+
|
|
221
|
+
// Default slot: replace <slot> with the caller's rendered content.
|
|
222
|
+
for (const slot of [...node.querySelectorAll('slot')]) {
|
|
223
|
+
const holder = ctx.document.createElement('template');
|
|
224
|
+
holder.innerHTML = slotHtml;
|
|
225
|
+
slot.replaceWith(...(holder.content || holder).childNodes);
|
|
226
|
+
}
|
|
227
|
+
}
|