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.
- package/AGENTS.md +413 -0
- package/LICENSE +21 -0
- package/PLANNER.md +285 -0
- package/README.md +326 -0
- package/bin/templa.js +549 -0
- package/examples/_partials/about-body.html +8 -0
- package/examples/_partials/common-footer.html +6 -0
- package/examples/_partials/common-head.html +4 -0
- package/examples/_partials/common-header.html +15 -0
- package/examples/_partials/common-layout.html +5 -0
- package/examples/_partials/common-subhero.html +7 -0
- package/examples/_partials/index-cta.html +8 -0
- package/examples/_partials/index-features.html +13 -0
- package/examples/_partials/index-hero.html +9 -0
- package/examples/about.html +14 -0
- package/examples/css/style.css +16 -0
- package/examples/index.html +15 -0
- package/examples/serve.json +1 -0
- package/package.json +40 -0
- package/templa.js +266 -0
|
@@ -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, '&')
|
|
85
|
+
.replace(/</g, '<')
|
|
86
|
+
.replace(/>/g, '>')
|
|
87
|
+
.replace(/"/g, '"')
|
|
88
|
+
.replace(/'/g, ''');
|
|
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;
|