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/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, '&quot;')
88
108
  .replace(/'/g, '&#39;');
89
109
 
90
- const render = (html, data) => html
91
- .replace(/{{{\s*([\w-]+)\s*}}}/g, (m, k) => k in data ? data[k] : m)
92
- .replace(/{{\s*([\w-]+)\s*}}/g, (m, k) => k in data ? esc(data[k]) : m);
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 dq = attrs.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`));
226
+ const [dqRx, sqRx] = attrRx(name);
227
+ const dq = attrs.match(dqRx);
103
228
  if (dq) return dq[1];
104
- const sq = attrs.match(new RegExp(`\\b${name}\\s*=\\s*'([^']*)'`));
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
- const TEMPLATE_TAG = /<template\b|<\/template\s*>/gi;
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
- if (RESERVED.has(a.name) || a.name.startsWith('data-')) continue;
203
- data[a.name] = a.value;
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
- return { run, start };
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;