templa-js 0.10.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.
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <template src="_partials/common-head.html" title="Home"></template>
5
+ </head>
6
+ <body>
7
+ <template src="_partials/common-layout.html" page="index">
8
+ <template src="_partials/index-hero.html"></template>
9
+ <template src="_partials/index-features.html"></template>
10
+ <template src="_partials/index-cta.html"></template>
11
+ </template>
12
+ <script src="./js/templa.js"></script>
13
+ <script type="module">await templa.start();</script>
14
+ </body>
15
+ </html>
@@ -0,0 +1 @@
1
+ {"symlinks": true}
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "templa-js",
3
+ "version": "0.10.0",
4
+ "description": "A tiny HTML template loader using <template src>. Read as tempura.",
5
+ "main": "templa.js",
6
+ "scripts": {
7
+ "test": "bash scripts/test-init.sh"
8
+ },
9
+ "bin": {
10
+ "templa": "bin/templa.js"
11
+ },
12
+ "files": [
13
+ "templa.js",
14
+ "bin/",
15
+ "examples/",
16
+ "README.md",
17
+ "AGENTS.md",
18
+ "PLANNER.md",
19
+ "LICENSE"
20
+ ],
21
+ "keywords": [
22
+ "template",
23
+ "html",
24
+ "loader",
25
+ "include",
26
+ "partial",
27
+ "vanilla",
28
+ "tempura"
29
+ ],
30
+ "author": "yjmtmtk",
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/yjmtmtk/templa-js.git"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/yjmtmtk/templa-js/issues"
38
+ },
39
+ "homepage": "https://github.com/yjmtmtk/templa-js#readme"
40
+ }
package/templa.js ADDED
@@ -0,0 +1,266 @@
1
+ /**
2
+ * templa — HTML template loader (read as "tempura")
3
+ *
4
+ * A small bridge to the HTML we should have. templa expands
5
+ * <template src="..."> elements into real markup, both at runtime in the
6
+ * browser and at build time via the CLI. Pure ES, zero dependencies.
7
+ *
8
+ * Usage:
9
+ * <template src="partials/header.html" title="Home"></template>
10
+ * <script src="templa.js"></script>
11
+ * <script type="module">
12
+ * await templa.start();
13
+ * // any post-init: Alpine.initTree(document.body), etc.
14
+ * </script>
15
+ *
16
+ * `templa.start()` returns a Promise that resolves once head + body
17
+ * templates have been expanded. No callback API — `await` is the only
18
+ * pattern.
19
+ *
20
+ * Passing data:
21
+ * - Every attribute on <template> becomes a string data key, except
22
+ * src/slot/if/unless (reserved) and data-* attributes (skipped as
23
+ * metadata convention).
24
+ *
25
+ * <template src="card.html" title="Tiny" body="Light"></template>
26
+ *
27
+ * Syntax:
28
+ * {{key}} HTML-escaped variable
29
+ * {{{key}}} raw variable (no escape)
30
+ * <template if="key">…</template> keep block when data[key] is truthy
31
+ * <template unless="key">…</template> keep block when data[key] is falsy
32
+ *
33
+ * Conditionals are existence-based — no expressions, no helpers. For
34
+ * conditional attributes, write a plugin.
35
+ *
36
+ * Layouts:
37
+ * <!-- _layouts/main.html -->
38
+ * <header><slot name="nav"></slot></header><main><slot></slot></main>
39
+ *
40
+ * <!-- page.html -->
41
+ * <template src="_layouts/main.html">
42
+ * <template slot="nav">…</template>
43
+ * <h1>Hello</h1>
44
+ * </template>
45
+ *
46
+ * Repository: https://github.com/yjmtmtk/templa-js
47
+ * License: MIT
48
+ */
49
+ const templa = (() => {
50
+ const MAX_PASSES = 50;
51
+ const cache = new Map();
52
+
53
+ const fetchText = url => {
54
+ if (!cache.has(url)) {
55
+ cache.set(url, fetch(url).then(r => {
56
+ if (!r.ok) console.error('[templa] fetch failed:', url, r.status);
57
+ return r.ok ? r.text() : '';
58
+ }).catch(e => {
59
+ console.error('[templa] fetch error:', url, e);
60
+ return '';
61
+ }));
62
+ }
63
+ return cache.get(url);
64
+ };
65
+
66
+ // Co-located <style data-merge="..."> blocks: keep on first expansion of a
67
+ // partial, strip on subsequent expansions so the rules appear in the DOM
68
+ // exactly once. The target value is a build-only hint; at runtime the
69
+ // browser resolves all <style> blocks globally regardless of position.
70
+ const mergedSeen = new Set();
71
+ const STYLE_MERGE = /<style\b[^>]*\bdata-merge\b[^>]*>[\s\S]*?<\/style>\s*/gi;
72
+ const STRIP_MERGE_ATTR = /(<style\b[^>]*?)\s+data-merge\s*=\s*("[^"]*"|'[^']*')/gi;
73
+
74
+ const handleMergedStyles = (html, url) => {
75
+ if (mergedSeen.has(url)) return html.replace(STYLE_MERGE, '');
76
+ if (STYLE_MERGE.test(html)) {
77
+ mergedSeen.add(url);
78
+ STYLE_MERGE.lastIndex = 0;
79
+ }
80
+ return html.replace(STRIP_MERGE_ATTR, '$1');
81
+ };
82
+
83
+ const esc = s => String(s)
84
+ .replace(/&/g, '&amp;')
85
+ .replace(/</g, '&lt;')
86
+ .replace(/>/g, '&gt;')
87
+ .replace(/"/g, '&quot;')
88
+ .replace(/'/g, '&#39;');
89
+
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);
93
+
94
+ const rebase = (html, baseUrl) => html.replace(
95
+ /(<template\b[^>]*\bsrc\s*=\s*["'])([^"']+)/gi,
96
+ (_, pre, src) => pre + new URL(src, baseUrl).href
97
+ );
98
+
99
+ // Read an attribute value out of a raw attribute string. Tries double then
100
+ // single quoting so values containing the other quote survive intact.
101
+ const getAttr = (attrs, name) => {
102
+ const dq = attrs.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`));
103
+ if (dq) return dq[1];
104
+ const sq = attrs.match(new RegExp(`\\b${name}\\s*=\\s*'([^']*)'`));
105
+ return sq ? sq[1] : null;
106
+ };
107
+
108
+ // Find top-level <template>...</template> blocks in `html`, depth-aware so
109
+ // nested templates do not confuse the matching close. Scans against a
110
+ // length-preserving redacted copy where quoted string contents are blanked,
111
+ // so a literal `<template>` token inside an attribute value can't desync
112
+ // the depth counter.
113
+ const TEMPLATE_OPEN = /<template((?:\s+[\w:.-]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+))?)*)\s*(\/?)>/gi;
114
+ const TEMPLATE_TAG = /<template\b|<\/template\s*>/gi;
115
+ const redactStrings = s => s.replace(
116
+ /"[^"]*"|'[^']*'/g,
117
+ m => m[0] + ' '.repeat(m.length - 2) + m[m.length - 1]
118
+ );
119
+
120
+ const findTemplateBlocks = html => {
121
+ const scan = redactStrings(html);
122
+ const out = [];
123
+ TEMPLATE_OPEN.lastIndex = 0;
124
+ let m;
125
+ while ((m = TEMPLATE_OPEN.exec(scan))) {
126
+ const start = m.index;
127
+ const openEnd = start + m[0].length;
128
+ const attrs = html.substring(start + 9, start + 9 + m[1].length);
129
+ if (m[2] === '/') {
130
+ out.push({ start, end: openEnd, attrs, inner: '' });
131
+ continue;
132
+ }
133
+ TEMPLATE_TAG.lastIndex = openEnd;
134
+ let depth = 1, t;
135
+ while (depth > 0 && (t = TEMPLATE_TAG.exec(scan))) {
136
+ if (t[0][1] === '/') depth--;
137
+ else depth++;
138
+ if (depth === 0) {
139
+ out.push({ start, end: t.index + t[0].length, attrs, inner: html.slice(openEnd, t.index) });
140
+ TEMPLATE_OPEN.lastIndex = t.index + t[0].length;
141
+ break;
142
+ }
143
+ }
144
+ }
145
+ return out;
146
+ };
147
+
148
+ // Split inner content of a <template src> call into named slot fillers and
149
+ // remaining default content. <template slot="X">...</template> becomes
150
+ // named[X], everything else stays in default.
151
+ const parseSlots = innerHtml => {
152
+ const named = {};
153
+ const fillers = findTemplateBlocks(innerHtml)
154
+ .filter(b => getAttr(b.attrs, 'slot'))
155
+ .sort((a, b) => b.start - a.start);
156
+ let def = innerHtml;
157
+ for (const b of fillers) {
158
+ named[getAttr(b.attrs, 'slot')] = b.inner;
159
+ def = def.slice(0, b.start) + def.slice(b.end);
160
+ }
161
+ return { named, default: def };
162
+ };
163
+
164
+ // Replace <slot> / <slot name="X"> in partial HTML with provided fillers,
165
+ // falling back to the slot's own children when no filler is supplied.
166
+ const fillSlots = (html, slots) => html.replace(
167
+ /<slot(\s[^>]*?)?>([\s\S]*?)<\/slot>/gi,
168
+ (_, attrs, fallback) => {
169
+ const name = attrs ? getAttr(attrs, 'name') : null;
170
+ if (name) return name in slots.named ? slots.named[name] : fallback;
171
+ return slots.default.trim() ? slots.default : fallback;
172
+ }
173
+ );
174
+
175
+ // <template if="key"> / <template unless="key"> — existence-based
176
+ // conditional blocks. Iterates until stable so nested conditionals resolve.
177
+ const applyConditionals = (html, data) => {
178
+ let prev;
179
+ do {
180
+ prev = html;
181
+ const blocks = findTemplateBlocks(html);
182
+ for (let i = blocks.length - 1; i >= 0; i--) {
183
+ const b = blocks[i];
184
+ const ifKey = getAttr(b.attrs, 'if');
185
+ const unlessKey = getAttr(b.attrs, 'unless');
186
+ if (ifKey !== null) {
187
+ html = html.slice(0, b.start) + (data[ifKey] ? b.inner : '') + html.slice(b.end);
188
+ } else if (unlessKey !== null) {
189
+ html = html.slice(0, b.start) + (!data[unlessKey] ? b.inner : '') + html.slice(b.end);
190
+ }
191
+ }
192
+ } while (html !== prev);
193
+ return html;
194
+ };
195
+
196
+ // Every attribute is a string data key, except: src/slot/if/unless are
197
+ // reserved, and any data-* attribute is treated as metadata (skipped).
198
+ const RESERVED = new Set(['src', 'slot', 'if', 'unless']);
199
+ const collectData = el => {
200
+ const data = {};
201
+ for (const a of el.attributes) {
202
+ if (RESERVED.has(a.name) || a.name.startsWith('data-')) continue;
203
+ data[a.name] = a.value;
204
+ }
205
+ return data;
206
+ };
207
+
208
+ const expand = async el => {
209
+ const src = el.getAttribute('src');
210
+ const url = new URL(src, location.href).href;
211
+ const data = collectData(el);
212
+ // Resolve conditionals in slot payload using this call's data so a slot
213
+ // filler can be wrapped in <template if="key">.
214
+ const slots = parseSlots(applyConditionals(el.innerHTML, data));
215
+
216
+ const html = handleMergedStyles(await fetchText(url), url);
217
+ const conditional = applyConditionals(rebase(html, url), data);
218
+ let out = fillSlots(render(conditional, data), slots);
219
+ const frag = document.createRange().createContextualFragment(out);
220
+ const waits = [...frag.querySelectorAll('link[rel="stylesheet"], script[src]')]
221
+ .map(r => new Promise(done => { r.onload = r.onerror = done; }));
222
+
223
+ el.replaceWith(frag);
224
+ await Promise.all(waits);
225
+ };
226
+
227
+ const run = async (selector = 'template[src]') => {
228
+ for (let pass = 0; pass < MAX_PASSES; pass++) {
229
+ const targets = [...document.querySelectorAll(selector)];
230
+ if (!targets.length) return;
231
+ await Promise.all(targets.map(el =>
232
+ expand(el).catch(e => {
233
+ console.error('[templa] failed:', el.getAttribute('src'), e);
234
+ el.remove();
235
+ })
236
+ ));
237
+ }
238
+ console.warn('[templa] max passes reached; possible recursive include');
239
+ };
240
+
241
+ // start() returns a Promise that resolves after head + body templates
242
+ // have been expanded. Head expansion is kicked off synchronously so its
243
+ // fetches can run in parallel with the rest of HTML parsing — important
244
+ // when partials in <head> include <link rel="stylesheet"> and the script
245
+ // tag is itself in <head>. Body expansion waits for DOMContentLoaded.
246
+ const start = () => {
247
+ const headTask = run('head template[src]');
248
+ return new Promise(resolve => {
249
+ const finish = async () => {
250
+ await headTask;
251
+ await run('body template[src]');
252
+ resolve();
253
+ };
254
+ if (document.readyState === 'loading') {
255
+ addEventListener('DOMContentLoaded', finish);
256
+ } else {
257
+ finish();
258
+ }
259
+ });
260
+ };
261
+
262
+ return { run, start };
263
+ })();
264
+
265
+ if (typeof window !== 'undefined') window.templa = templa;
266
+ if (typeof module !== 'undefined' && module.exports) module.exports = templa;