templa-js 0.10.0 → 0.13.1
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/AGENTS.md +24 -9
- package/PLANNER.md +11 -9
- package/README.md +77 -10
- package/SECTION.md +37 -0
- package/bin/templa.js +196 -139
- package/examples/_partials/common-head.html +1 -1
- package/examples/_partials/common-header.html +4 -7
- package/examples/_partials/common-layout.html +1 -1
- package/examples/_partials/index-cta.html +1 -1
- package/examples/about.html +1 -1
- package/examples/css/style.css +1 -0
- package/examples/index.html +1 -1
- package/package.json +7 -2
- package/templa.js +231 -14
package/templa.js
CHANGED
|
@@ -24,6 +24,12 @@
|
|
|
24
24
|
*
|
|
25
25
|
* <template src="card.html" title="Tiny" body="Light"></template>
|
|
26
26
|
*
|
|
27
|
+
* - Keys are case-insensitive. HTML attribute names are case-
|
|
28
|
+
* insensitive in the spec and the browser DOM lowercases them, so
|
|
29
|
+
* templa normalises both sides: `<template ctaLabel="X">` and
|
|
30
|
+
* `{{ctaLabel}}` both resolve via the lowercased key `ctalabel`.
|
|
31
|
+
* Use kebab-case (`cta-label`) for HTML idiomaticity.
|
|
32
|
+
*
|
|
27
33
|
* Syntax:
|
|
28
34
|
* {{key}} HTML-escaped variable
|
|
29
35
|
* {{{key}}} raw variable (no escape)
|
|
@@ -43,6 +49,20 @@
|
|
|
43
49
|
* <h1>Hello</h1>
|
|
44
50
|
* </template>
|
|
45
51
|
*
|
|
52
|
+
* Active nav:
|
|
53
|
+
* After expansion, <a> elements inside <nav> whose href resolves to the
|
|
54
|
+
* current page get aria-current="page" automatically. Style with a single
|
|
55
|
+
* rule: nav a[aria-current="page"] { ... } — no per-page data threading.
|
|
56
|
+
*
|
|
57
|
+
* Asset paths:
|
|
58
|
+
* URLs inside a partial are relative to the PARTIAL, not the including page,
|
|
59
|
+
* so a partial shared across directory depths resolves the same everywhere.
|
|
60
|
+
* Covers <template src> plus asset URLs — src (any element), href on
|
|
61
|
+
* <link>/<use>, srcset, poster, and CSS url() in <style>/style="" —
|
|
62
|
+
* rewritten to root-absolute on expansion. NOT <a href> (write nav links
|
|
63
|
+
* root-absolute), {{templated}} URLs, data-*, or url(#id). See the
|
|
64
|
+
* rebaseAssets comment below.
|
|
65
|
+
*
|
|
46
66
|
* Repository: https://github.com/yjmtmtk/templa-js
|
|
47
67
|
* License: MIT
|
|
48
68
|
*/
|
|
@@ -87,21 +107,126 @@ const templa = (() => {
|
|
|
87
107
|
.replace(/"/g, '"')
|
|
88
108
|
.replace(/'/g, ''');
|
|
89
109
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
110
|
+
// Data keys are case-insensitive to mirror HTML's own attribute
|
|
111
|
+
// semantics (browsers lowercase attribute names in the DOM). Both
|
|
112
|
+
// <template ctaLabel="X"> and {{ctaLabel}} normalize to "ctalabel"
|
|
113
|
+
// so kebab-case, camelCase, and PascalCase all work the same.
|
|
114
|
+
// Single pass over both forms so a value injected by {{{raw}}} is never
|
|
115
|
+
// re-scanned by the {{escaped}} pass (a two-pass render would re-evaluate
|
|
116
|
+
// braces that appear inside raw data). {{{key}}} is tried first at each
|
|
117
|
+
// position, so it wins over the {{key}} alternative.
|
|
118
|
+
const render = (html, data) => html.replace(
|
|
119
|
+
/{{{\s*([\w-]+)\s*}}}|{{\s*([\w-]+)\s*}}/g,
|
|
120
|
+
(m, raw, dbl) => {
|
|
121
|
+
const lk = (raw != null ? raw : dbl).toLowerCase();
|
|
122
|
+
if (!(lk in data)) return m;
|
|
123
|
+
return raw != null ? data[lk] : esc(data[lk]);
|
|
124
|
+
}
|
|
125
|
+
);
|
|
93
126
|
|
|
94
127
|
const rebase = (html, baseUrl) => html.replace(
|
|
95
128
|
/(<template\b[^>]*\bsrc\s*=\s*["'])([^"']+)/gi,
|
|
96
129
|
(_, pre, src) => pre + new URL(src, baseUrl).href
|
|
97
130
|
);
|
|
98
131
|
|
|
132
|
+
// Asset URLs authored inside a partial are written relative to the PARTIAL's
|
|
133
|
+
// own location (the same rule <template src> already follows), then rewritten
|
|
134
|
+
// here to root-absolute paths. A partial shared by pages at different depths
|
|
135
|
+
// (/index.html and /blog/post.html) then resolves its assets identically from
|
|
136
|
+
// every page, instead of relative to whichever page included it.
|
|
137
|
+
//
|
|
138
|
+
// Scope: src (any element), href on <link>/<use>, srcset, poster. Deliberately
|
|
139
|
+
// NOT <a href> — link targets are pages, not co-located assets, so rebasing
|
|
140
|
+
// them to _partials/-relative would surprise; write nav links root-absolute
|
|
141
|
+
// (/about.html). CSS url() in <style> blocks and style="" attributes IS
|
|
142
|
+
// rebased too (co-located <style data-merge> is a first-class feature), but
|
|
143
|
+
// url(#frag) and url(data:…) stay put. Skipped values, left verbatim:
|
|
144
|
+
// #fragments, scheme URLs (https:/mailto:/tel:/data:…), //protocol-relative,
|
|
145
|
+
// already-/root-absolute, and {{templated}} (render fills those — you own
|
|
146
|
+
// them). data-* is metadata and untouched. The attr name is whitespace-
|
|
147
|
+
// anchored (like getAttr) so data-src and xlink:href aren't mistaken for
|
|
148
|
+
// src/href.
|
|
149
|
+
const TAG = /<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g;
|
|
150
|
+
const STYLE_BLOCK = /(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi;
|
|
151
|
+
const CSS_URL = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^'")\s]+))\s*\)/gi;
|
|
152
|
+
// Raw-text elements whose bodies are text, not markup: their contents must
|
|
153
|
+
// never be rewritten (a literal <img src> shown in <pre> or built by JS in
|
|
154
|
+
// <script> is content). split() with these 3 capture groups interleaves
|
|
155
|
+
// [text, openTag, body, closeTag, …]; only text and the open tag are rebased.
|
|
156
|
+
const RAW_ELEMENT = /(<(?:script|textarea|pre)\b[^>]*>)([\s\S]*?)(<\/(?:script|textarea|pre)>)/gi;
|
|
157
|
+
const ASSET_HREF = new Set(['link', 'use']);
|
|
158
|
+
const skipUrl = v => !v || v.startsWith('#') || v.startsWith('//') ||
|
|
159
|
+
v.startsWith('/') || v.includes('{{') || /^[a-z][a-z0-9+.-]*:/i.test(v);
|
|
160
|
+
|
|
161
|
+
// Build a URL-based resolver bound to a partial's location: a partial-relative
|
|
162
|
+
// value becomes a root-absolute path (a deploy sub-path rides along via the
|
|
163
|
+
// origin). skipUrl values and anything that won't parse pass through. The CLI
|
|
164
|
+
// supplies its own filesystem-path resolver; rebaseAssets/rebaseCss are shared.
|
|
165
|
+
const urlResolver = baseUrl => v => {
|
|
166
|
+
if (skipUrl(v)) return v;
|
|
167
|
+
try { const u = new URL(v, baseUrl); return u.pathname + u.search + u.hash; }
|
|
168
|
+
catch { return v; }
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Rewrite CSS url() targets through `resolve`, preserving the original quoting.
|
|
172
|
+
const rebaseCss = (cssText, resolve) => cssText.replace(CSS_URL, (m, dq, sq, uq) => {
|
|
173
|
+
const val = dq != null ? dq : sq != null ? sq : uq;
|
|
174
|
+
const r = resolve(val);
|
|
175
|
+
if (r === val) return m;
|
|
176
|
+
const q = dq != null ? '"' : sq != null ? "'" : '';
|
|
177
|
+
return `url(${q}${r}${q})`;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Rewrite asset URLs in `html` through `resolve` (URL-based in the browser,
|
|
181
|
+
// path-based in the CLI). split() with RAW_ELEMENT's 3 capture groups
|
|
182
|
+
// interleaves [text, openTag, body, closeTag, …]; only text and open tags are
|
|
183
|
+
// touched, so a literal <img src> inside <script>/<pre>/<textarea> survives.
|
|
184
|
+
const rebaseAssets = (html, resolve) => {
|
|
185
|
+
const srcset = v => v.split(',').map(p => {
|
|
186
|
+
const t = p.trim();
|
|
187
|
+
const sp = t && t.match(/^(\S+)(\s+.+)?$/);
|
|
188
|
+
return sp ? resolve(sp[1]) + (sp[2] || '') : p;
|
|
189
|
+
}).join(', ');
|
|
190
|
+
const css = v => rebaseCss(v, resolve);
|
|
191
|
+
const rw = (attrs, name, fn) => attrs.replace(
|
|
192
|
+
new RegExp(`(^|\\s)(${name})(\\s*=\\s*)("[^"]*"|'[^']*')`, 'gi'),
|
|
193
|
+
(_m, lead, nm, eq, q) => lead + nm + eq + q[0] + fn(q.slice(1, -1)) + q[0]
|
|
194
|
+
);
|
|
195
|
+
const tagPass = s => s.replace(TAG, (whole, tag, attrs) => {
|
|
196
|
+
if (tag.toLowerCase() === 'template') return whole;
|
|
197
|
+
let a = rw(rw(attrs, 'src', resolve), 'poster', resolve);
|
|
198
|
+
a = rw(rw(a, 'srcset', srcset), 'style', css);
|
|
199
|
+
if (ASSET_HREF.has(tag.toLowerCase())) a = rw(a, 'href', resolve);
|
|
200
|
+
return `<${tag}${a}>`;
|
|
201
|
+
});
|
|
202
|
+
return html.split(RAW_ELEMENT).map((seg, i) => {
|
|
203
|
+
const k = i % 4; // 0 text, 1 open, 2 body, 3 close
|
|
204
|
+
if (k === 0) return tagPass(seg).replace(STYLE_BLOCK, (_m, o, b, c) => o + css(b) + c);
|
|
205
|
+
if (k === 1) return tagPass(seg); // raw open tag → rebase its attrs
|
|
206
|
+
return seg; // body / close → untouched
|
|
207
|
+
}).join('');
|
|
208
|
+
};
|
|
209
|
+
|
|
99
210
|
// Read an attribute value out of a raw attribute string. Tries double then
|
|
100
211
|
// single quoting so values containing the other quote survive intact.
|
|
212
|
+
// The name is anchored to start-of-attribute (preceded by whitespace or
|
|
213
|
+
// the string start) rather than a bare \b word boundary: `-` and `:` form
|
|
214
|
+
// word boundaries too, so `\bif=` would wrongly match `x-if=` / `v-if=` /
|
|
215
|
+
// `data-if=`. Anchoring to whitespace keeps full-name matching, so
|
|
216
|
+
// framework directives like Alpine's <template x-if> coexist untouched.
|
|
217
|
+
// Compiled regexes are cached per attribute name — getAttr runs once per
|
|
218
|
+
// attribute lookup across every block, and the name set is tiny and fixed
|
|
219
|
+
// (src/slot/if/unless/name/href). Non-global, so reuse is state-free.
|
|
220
|
+
const attrRxCache = {};
|
|
221
|
+
const attrRx = name => attrRxCache[name] || (attrRxCache[name] = [
|
|
222
|
+
new RegExp(`(?:^|\\s)${name}\\s*=\\s*"([^"]*)"`, 'i'),
|
|
223
|
+
new RegExp(`(?:^|\\s)${name}\\s*=\\s*'([^']*)'`, 'i'),
|
|
224
|
+
]);
|
|
101
225
|
const getAttr = (attrs, name) => {
|
|
102
|
-
const
|
|
226
|
+
const [dqRx, sqRx] = attrRx(name);
|
|
227
|
+
const dq = attrs.match(dqRx);
|
|
103
228
|
if (dq) return dq[1];
|
|
104
|
-
const sq = attrs.match(
|
|
229
|
+
const sq = attrs.match(sqRx);
|
|
105
230
|
return sq ? sq[1] : null;
|
|
106
231
|
};
|
|
107
232
|
|
|
@@ -111,7 +236,10 @@ const templa = (() => {
|
|
|
111
236
|
// so a literal `<template>` token inside an attribute value can't desync
|
|
112
237
|
// the depth counter.
|
|
113
238
|
const TEMPLATE_OPEN = /<template((?:\s+[\w:.-]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+))?)*)\s*(\/?)>/gi;
|
|
114
|
-
|
|
239
|
+
// Matches a whole <template …> open tag (capturing a trailing /) OR a close.
|
|
240
|
+
// A self-closing <template …/> is a leaf, so it must not bump the depth
|
|
241
|
+
// counter — otherwise an enclosing block would never find its matching close.
|
|
242
|
+
const TEMPLATE_TAG = /<template\b(?:[^>"']|"[^"]*"|'[^']*')*?(\/?)>|<\/template\s*>/gi;
|
|
115
243
|
const redactStrings = s => s.replace(
|
|
116
244
|
/"[^"]*"|'[^']*'/g,
|
|
117
245
|
m => m[0] + ' '.repeat(m.length - 2) + m[m.length - 1]
|
|
@@ -133,8 +261,8 @@ const templa = (() => {
|
|
|
133
261
|
TEMPLATE_TAG.lastIndex = openEnd;
|
|
134
262
|
let depth = 1, t;
|
|
135
263
|
while (depth > 0 && (t = TEMPLATE_TAG.exec(scan))) {
|
|
136
|
-
if (t[0][1] === '/') depth--;
|
|
137
|
-
else depth++;
|
|
264
|
+
if (t[0][1] === '/') depth--; // </template>
|
|
265
|
+
else if (t[1] !== '/') depth++; // open tag (a self-closing /> is a leaf)
|
|
138
266
|
if (depth === 0) {
|
|
139
267
|
out.push({ start, end: t.index + t[0].length, attrs, inner: html.slice(openEnd, t.index) });
|
|
140
268
|
TEMPLATE_OPEN.lastIndex = t.index + t[0].length;
|
|
@@ -145,6 +273,28 @@ const templa = (() => {
|
|
|
145
273
|
return out;
|
|
146
274
|
};
|
|
147
275
|
|
|
276
|
+
// True if any <template …> open tag has no matching </template>. Such a block
|
|
277
|
+
// is silently dropped by findTemplateBlocks (its include never expands), so
|
|
278
|
+
// the CLI build reports it instead of emitting mysteriously missing output.
|
|
279
|
+
const hasUnclosed = html => {
|
|
280
|
+
const scan = redactStrings(html);
|
|
281
|
+
TEMPLATE_OPEN.lastIndex = 0;
|
|
282
|
+
let m;
|
|
283
|
+
while ((m = TEMPLATE_OPEN.exec(scan))) {
|
|
284
|
+
if (m[2] === '/') continue; // self-closing: complete
|
|
285
|
+
TEMPLATE_TAG.lastIndex = m.index + m[0].length;
|
|
286
|
+
let depth = 1, t, closedAt = -1;
|
|
287
|
+
while (depth > 0 && (t = TEMPLATE_TAG.exec(scan))) {
|
|
288
|
+
if (t[0][1] === '/') depth--;
|
|
289
|
+
else if (t[1] !== '/') depth++;
|
|
290
|
+
if (depth === 0) { closedAt = t.index + t[0].length; break; }
|
|
291
|
+
}
|
|
292
|
+
if (depth > 0) return true; // ran off the end without a close
|
|
293
|
+
TEMPLATE_OPEN.lastIndex = closedAt;
|
|
294
|
+
}
|
|
295
|
+
return false;
|
|
296
|
+
};
|
|
297
|
+
|
|
148
298
|
// Split inner content of a <template src> call into named slot fillers and
|
|
149
299
|
// remaining default content. <template slot="X">...</template> becomes
|
|
150
300
|
// named[X], everything else stays in default.
|
|
@@ -184,9 +334,9 @@ const templa = (() => {
|
|
|
184
334
|
const ifKey = getAttr(b.attrs, 'if');
|
|
185
335
|
const unlessKey = getAttr(b.attrs, 'unless');
|
|
186
336
|
if (ifKey !== null) {
|
|
187
|
-
html = html.slice(0, b.start) + (data[ifKey] ? b.inner : '') + html.slice(b.end);
|
|
337
|
+
html = html.slice(0, b.start) + (data[ifKey.toLowerCase()] ? b.inner : '') + html.slice(b.end);
|
|
188
338
|
} else if (unlessKey !== null) {
|
|
189
|
-
html = html.slice(0, b.start) + (!data[unlessKey] ? b.inner : '') + html.slice(b.end);
|
|
339
|
+
html = html.slice(0, b.start) + (!data[unlessKey.toLowerCase()] ? b.inner : '') + html.slice(b.end);
|
|
190
340
|
}
|
|
191
341
|
}
|
|
192
342
|
} while (html !== prev);
|
|
@@ -195,12 +345,16 @@ const templa = (() => {
|
|
|
195
345
|
|
|
196
346
|
// Every attribute is a string data key, except: src/slot/if/unless are
|
|
197
347
|
// reserved, and any data-* attribute is treated as metadata (skipped).
|
|
348
|
+
// Keys are stored lowercased — HTML attribute names are case-insensitive
|
|
349
|
+
// and the browser DOM already lowercases them, so we mirror that on
|
|
350
|
+
// the build side too.
|
|
198
351
|
const RESERVED = new Set(['src', 'slot', 'if', 'unless']);
|
|
199
352
|
const collectData = el => {
|
|
200
353
|
const data = {};
|
|
201
354
|
for (const a of el.attributes) {
|
|
202
|
-
|
|
203
|
-
|
|
355
|
+
const n = a.name.toLowerCase();
|
|
356
|
+
if (RESERVED.has(n) || n.startsWith('data-')) continue;
|
|
357
|
+
data[n] = a.value;
|
|
204
358
|
}
|
|
205
359
|
return data;
|
|
206
360
|
};
|
|
@@ -214,7 +368,7 @@ const templa = (() => {
|
|
|
214
368
|
const slots = parseSlots(applyConditionals(el.innerHTML, data));
|
|
215
369
|
|
|
216
370
|
const html = handleMergedStyles(await fetchText(url), url);
|
|
217
|
-
const conditional = applyConditionals(rebase(html, url), data);
|
|
371
|
+
const conditional = applyConditionals(rebaseAssets(rebase(html, url), urlResolver(url)), data);
|
|
218
372
|
let out = fillSlots(render(conditional, data), slots);
|
|
219
373
|
const frag = document.createRange().createContextualFragment(out);
|
|
220
374
|
const waits = [...frag.querySelectorAll('link[rel="stylesheet"], script[src]')]
|
|
@@ -238,17 +392,70 @@ const templa = (() => {
|
|
|
238
392
|
console.warn('[templa] max passes reached; possible recursive include');
|
|
239
393
|
};
|
|
240
394
|
|
|
395
|
+
// Auto-mark the current page's nav links: any <a> inside <nav> whose href
|
|
396
|
+
// resolves to the current page gets aria-current="page". CSS Selectors 4
|
|
397
|
+
// specced `:local-link` for exactly this and no browser shipped it — so
|
|
398
|
+
// active-nav styling needs no per-page data threading, just one rule:
|
|
399
|
+
// nav a[aria-current="page"] { ... }
|
|
400
|
+
// Skipped: pure-hash hrefs (same-page TOC links), cross-origin links, and
|
|
401
|
+
// any <a> whose author already wrote aria-current.
|
|
402
|
+
//
|
|
403
|
+
// Path comparison is hosting-convention tolerant: /index.html ≡ /,
|
|
404
|
+
// /about.html ≡ /about ≡ /about/ — so a nav written with .html hrefs still
|
|
405
|
+
// matches on pretty-URL hosts (Netlify, Vercel, `serve` cleanUrls) that
|
|
406
|
+
// serve the page at the extensionless path.
|
|
407
|
+
const normalizePagePath = p => {
|
|
408
|
+
p = p.replace(/\/index\.html$/i, '/').replace(/\.html$/i, '');
|
|
409
|
+
return p.length > 1 ? p.replace(/\/$/, '') : p;
|
|
410
|
+
};
|
|
411
|
+
const markCurrentNavLinks = () => {
|
|
412
|
+
const here = normalizePagePath(location.pathname);
|
|
413
|
+
for (const a of document.querySelectorAll('nav a[href]')) {
|
|
414
|
+
if (a.hasAttribute('aria-current')) continue;
|
|
415
|
+
const href = a.getAttribute('href');
|
|
416
|
+
if (href.startsWith('#')) continue;
|
|
417
|
+
let url;
|
|
418
|
+
try { url = new URL(href, location.href); } catch { continue; }
|
|
419
|
+
if (url.origin !== location.origin) continue;
|
|
420
|
+
if (normalizePagePath(url.pathname) === here) a.setAttribute('aria-current', 'page');
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
// Any <template if> / <template unless> still in the live DOM after
|
|
425
|
+
// expansion is page-level: it sits outside every <template src> so it has
|
|
426
|
+
// no calling data to resolve against, and the browser renders <template>
|
|
427
|
+
// content inertly — so it silently shows nothing. Warn instead of failing
|
|
428
|
+
// quietly. (Conditionals inside partials are resolved as text before the
|
|
429
|
+
// fragment is inserted, so they never reach the DOM to be matched here.)
|
|
430
|
+
const warnLeftoverConditionals = () => {
|
|
431
|
+
const leftover = document.querySelectorAll('template[if], template[unless]');
|
|
432
|
+
if (leftover.length) console.warn(
|
|
433
|
+
`[templa] ${leftover.length} unresolved <template if/unless> in the page —`,
|
|
434
|
+
'page-level conditionals have no data source and are not evaluated.',
|
|
435
|
+
'Move them inside a partial.'
|
|
436
|
+
);
|
|
437
|
+
};
|
|
438
|
+
|
|
241
439
|
// start() returns a Promise that resolves after head + body templates
|
|
242
440
|
// have been expanded. Head expansion is kicked off synchronously so its
|
|
243
441
|
// fetches can run in parallel with the rest of HTML parsing — important
|
|
244
442
|
// when partials in <head> include <link rel="stylesheet"> and the script
|
|
245
443
|
// tag is itself in <head>. Body expansion waits for DOMContentLoaded.
|
|
444
|
+
//
|
|
445
|
+
// Per-page state (fetch cache, merged-style bookkeeping) is reset up front
|
|
446
|
+
// so re-invoking start() after a client-side navigation assembles the new
|
|
447
|
+
// page cleanly. Standalone run() calls keep the cache, so dynamically
|
|
448
|
+
// loaded partials still dedupe their fetches.
|
|
246
449
|
const start = () => {
|
|
450
|
+
cache.clear();
|
|
451
|
+
mergedSeen.clear();
|
|
247
452
|
const headTask = run('head template[src]');
|
|
248
453
|
return new Promise(resolve => {
|
|
249
454
|
const finish = async () => {
|
|
250
455
|
await headTask;
|
|
251
456
|
await run('body template[src]');
|
|
457
|
+
markCurrentNavLinks();
|
|
458
|
+
warnLeftoverConditionals();
|
|
252
459
|
resolve();
|
|
253
460
|
};
|
|
254
461
|
if (document.readyState === 'loading') {
|
|
@@ -259,7 +466,17 @@ const templa = (() => {
|
|
|
259
466
|
});
|
|
260
467
|
};
|
|
261
468
|
|
|
262
|
-
|
|
469
|
+
// Public API is run/start. The pure, environment-agnostic transforms are
|
|
470
|
+
// exposed under `_` as the single source of truth the CLI (bin/templa.js)
|
|
471
|
+
// imports — so the parser/renderer lives once, not duplicated per target.
|
|
472
|
+
return {
|
|
473
|
+
run, start,
|
|
474
|
+
_: {
|
|
475
|
+
esc, render, getAttr, findTemplateBlocks, hasUnclosed, applyConditionals,
|
|
476
|
+
parseSlots, fillSlots, rebaseAssets, rebaseCss, urlResolver, skipUrl,
|
|
477
|
+
normalizePagePath, RESERVED,
|
|
478
|
+
},
|
|
479
|
+
};
|
|
263
480
|
})();
|
|
264
481
|
|
|
265
482
|
if (typeof window !== 'undefined') window.templa = templa;
|