templa-js 0.10.1 → 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 +20 -7
- package/PLANNER.md +11 -9
- package/README.md +72 -7
- package/SECTION.md +37 -0
- package/bin/templa.js +195 -148
- 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 +213 -16
package/templa.js
CHANGED
|
@@ -49,6 +49,20 @@
|
|
|
49
49
|
* <h1>Hello</h1>
|
|
50
50
|
* </template>
|
|
51
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
|
+
*
|
|
52
66
|
* Repository: https://github.com/yjmtmtk/templa-js
|
|
53
67
|
* License: MIT
|
|
54
68
|
*/
|
|
@@ -97,27 +111,122 @@ const templa = (() => {
|
|
|
97
111
|
// semantics (browsers lowercase attribute names in the DOM). Both
|
|
98
112
|
// <template ctaLabel="X"> and {{ctaLabel}} normalize to "ctalabel"
|
|
99
113
|
// so kebab-case, camelCase, and PascalCase all work the same.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
+
);
|
|
109
126
|
|
|
110
127
|
const rebase = (html, baseUrl) => html.replace(
|
|
111
128
|
/(<template\b[^>]*\bsrc\s*=\s*["'])([^"']+)/gi,
|
|
112
129
|
(_, pre, src) => pre + new URL(src, baseUrl).href
|
|
113
130
|
);
|
|
114
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
|
+
|
|
115
210
|
// Read an attribute value out of a raw attribute string. Tries double then
|
|
116
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
|
+
]);
|
|
117
225
|
const getAttr = (attrs, name) => {
|
|
118
|
-
const
|
|
226
|
+
const [dqRx, sqRx] = attrRx(name);
|
|
227
|
+
const dq = attrs.match(dqRx);
|
|
119
228
|
if (dq) return dq[1];
|
|
120
|
-
const sq = attrs.match(
|
|
229
|
+
const sq = attrs.match(sqRx);
|
|
121
230
|
return sq ? sq[1] : null;
|
|
122
231
|
};
|
|
123
232
|
|
|
@@ -127,7 +236,10 @@ const templa = (() => {
|
|
|
127
236
|
// so a literal `<template>` token inside an attribute value can't desync
|
|
128
237
|
// the depth counter.
|
|
129
238
|
const TEMPLATE_OPEN = /<template((?:\s+[\w:.-]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+))?)*)\s*(\/?)>/gi;
|
|
130
|
-
|
|
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;
|
|
131
243
|
const redactStrings = s => s.replace(
|
|
132
244
|
/"[^"]*"|'[^']*'/g,
|
|
133
245
|
m => m[0] + ' '.repeat(m.length - 2) + m[m.length - 1]
|
|
@@ -149,8 +261,8 @@ const templa = (() => {
|
|
|
149
261
|
TEMPLATE_TAG.lastIndex = openEnd;
|
|
150
262
|
let depth = 1, t;
|
|
151
263
|
while (depth > 0 && (t = TEMPLATE_TAG.exec(scan))) {
|
|
152
|
-
if (t[0][1] === '/') depth--;
|
|
153
|
-
else depth++;
|
|
264
|
+
if (t[0][1] === '/') depth--; // </template>
|
|
265
|
+
else if (t[1] !== '/') depth++; // open tag (a self-closing /> is a leaf)
|
|
154
266
|
if (depth === 0) {
|
|
155
267
|
out.push({ start, end: t.index + t[0].length, attrs, inner: html.slice(openEnd, t.index) });
|
|
156
268
|
TEMPLATE_OPEN.lastIndex = t.index + t[0].length;
|
|
@@ -161,6 +273,28 @@ const templa = (() => {
|
|
|
161
273
|
return out;
|
|
162
274
|
};
|
|
163
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
|
+
|
|
164
298
|
// Split inner content of a <template src> call into named slot fillers and
|
|
165
299
|
// remaining default content. <template slot="X">...</template> becomes
|
|
166
300
|
// named[X], everything else stays in default.
|
|
@@ -234,7 +368,7 @@ const templa = (() => {
|
|
|
234
368
|
const slots = parseSlots(applyConditionals(el.innerHTML, data));
|
|
235
369
|
|
|
236
370
|
const html = handleMergedStyles(await fetchText(url), url);
|
|
237
|
-
const conditional = applyConditionals(rebase(html, url), data);
|
|
371
|
+
const conditional = applyConditionals(rebaseAssets(rebase(html, url), urlResolver(url)), data);
|
|
238
372
|
let out = fillSlots(render(conditional, data), slots);
|
|
239
373
|
const frag = document.createRange().createContextualFragment(out);
|
|
240
374
|
const waits = [...frag.querySelectorAll('link[rel="stylesheet"], script[src]')]
|
|
@@ -258,17 +392,70 @@ const templa = (() => {
|
|
|
258
392
|
console.warn('[templa] max passes reached; possible recursive include');
|
|
259
393
|
};
|
|
260
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
|
+
|
|
261
439
|
// start() returns a Promise that resolves after head + body templates
|
|
262
440
|
// have been expanded. Head expansion is kicked off synchronously so its
|
|
263
441
|
// fetches can run in parallel with the rest of HTML parsing — important
|
|
264
442
|
// when partials in <head> include <link rel="stylesheet"> and the script
|
|
265
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.
|
|
266
449
|
const start = () => {
|
|
450
|
+
cache.clear();
|
|
451
|
+
mergedSeen.clear();
|
|
267
452
|
const headTask = run('head template[src]');
|
|
268
453
|
return new Promise(resolve => {
|
|
269
454
|
const finish = async () => {
|
|
270
455
|
await headTask;
|
|
271
456
|
await run('body template[src]');
|
|
457
|
+
markCurrentNavLinks();
|
|
458
|
+
warnLeftoverConditionals();
|
|
272
459
|
resolve();
|
|
273
460
|
};
|
|
274
461
|
if (document.readyState === 'loading') {
|
|
@@ -279,7 +466,17 @@ const templa = (() => {
|
|
|
279
466
|
});
|
|
280
467
|
};
|
|
281
468
|
|
|
282
|
-
|
|
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
|
+
};
|
|
283
480
|
})();
|
|
284
481
|
|
|
285
482
|
if (typeof window !== 'undefined') window.templa = templa;
|