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/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
- const render = (html, data) => html
101
- .replace(/{{{\s*([\w-]+)\s*}}}/g, (m, k) => {
102
- const lk = k.toLowerCase();
103
- return lk in data ? data[lk] : m;
104
- })
105
- .replace(/{{\s*([\w-]+)\s*}}/g, (m, k) => {
106
- const lk = k.toLowerCase();
107
- return lk in data ? esc(data[lk]) : m;
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 dq = attrs.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`, 'i'));
226
+ const [dqRx, sqRx] = attrRx(name);
227
+ const dq = attrs.match(dqRx);
119
228
  if (dq) return dq[1];
120
- const sq = attrs.match(new RegExp(`\\b${name}\\s*=\\s*'([^']*)'`, 'i'));
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
- 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;
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
- 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
+ };
283
480
  })();
284
481
 
285
482
  if (typeof window !== 'undefined') window.templa = templa;