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
package/bin/templa.js
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* templa CLI — static build for <template src> partials
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* templa build [-i <src>] [-o <dist>]
|
|
7
|
+
* templa --help
|
|
8
|
+
* templa --version
|
|
9
|
+
*
|
|
10
|
+
* Build expands every <template src="..."> in your source HTML files
|
|
11
|
+
* by inlining the referenced partial. Supports the same syntax as the
|
|
12
|
+
* runtime: {{key}}, {{{key}}}, <template if="key">…</template>,
|
|
13
|
+
* <template unless="key">…</template>, plus Web Components-style <slot>
|
|
14
|
+
* for layouts. Data is passed by plain HTML attributes (data-* attrs
|
|
15
|
+
* are reserved as metadata and skipped).
|
|
16
|
+
*
|
|
17
|
+
* Convention: files and directories starting with "_" are treated as
|
|
18
|
+
* partials and are never written to the output directory. Reference
|
|
19
|
+
* them from your pages via <template src="_partials/header.html">.
|
|
20
|
+
*/
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
|
|
26
|
+
const VERSION = require('../package.json').version;
|
|
27
|
+
const MAX_DEPTH = 50;
|
|
28
|
+
|
|
29
|
+
// ─── render: shared with browser runtime ─────────────────────────────
|
|
30
|
+
const escHtml = s => String(s)
|
|
31
|
+
.replace(/&/g, '&')
|
|
32
|
+
.replace(/</g, '<')
|
|
33
|
+
.replace(/>/g, '>')
|
|
34
|
+
.replace(/"/g, '"')
|
|
35
|
+
.replace(/'/g, ''');
|
|
36
|
+
|
|
37
|
+
function render(html, data) {
|
|
38
|
+
return html
|
|
39
|
+
.replace(/{{{\s*([\w-]+)\s*}}}/g, (m, k) => k in data ? data[k] : m)
|
|
40
|
+
.replace(/{{\s*([\w-]+)\s*}}/g, (m, k) => k in data ? escHtml(data[k]) : m);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ─── attribute / template / slot parsing ─────────────────────────────
|
|
44
|
+
function getAttr(attrs, name) {
|
|
45
|
+
const dq = attrs.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`));
|
|
46
|
+
if (dq) return dq[1];
|
|
47
|
+
const sq = attrs.match(new RegExp(`\\b${name}\\s*=\\s*'([^']*)'`));
|
|
48
|
+
return sq ? sq[1] : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Every attribute is a string data key, except: src/slot/if/unless are
|
|
52
|
+
// reserved, and any data-* attribute is treated as metadata (skipped).
|
|
53
|
+
const RESERVED = new Set(['src', 'slot', 'if', 'unless']);
|
|
54
|
+
const ATTR = /(\w[\w:.-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'))?/g;
|
|
55
|
+
|
|
56
|
+
// ─── co-located styles via <style data-merge="..."> ─────────────────
|
|
57
|
+
// First time a partial is processed, its merge styles are extracted and
|
|
58
|
+
// queued for the target file. Subsequent expansions of the same partial
|
|
59
|
+
// drop the styles silently. Flushed to disk after the source walk.
|
|
60
|
+
const STYLE_MERGE = /<style\s+([^>]*?)data-merge\s*=\s*["']([^"']+)["']([^>]*)>([\s\S]*?)<\/style>\s*/gi;
|
|
61
|
+
const STYLE_MERGE_STRIP = /<style\b[^>]*\bdata-merge\b[^>]*>[\s\S]*?<\/style>\s*/gi;
|
|
62
|
+
const mergedTargets = new Map(); // target path -> [css, ...]
|
|
63
|
+
const mergedSeen = new Set(); // partial path -> already extracted
|
|
64
|
+
|
|
65
|
+
function extractMergedStyles(html, partialPath) {
|
|
66
|
+
if (mergedSeen.has(partialPath)) return html.replace(STYLE_MERGE_STRIP, '');
|
|
67
|
+
let any = false;
|
|
68
|
+
const out = html.replace(STYLE_MERGE, (_, _pre, target, _post, body) => {
|
|
69
|
+
any = true;
|
|
70
|
+
const t = target.trim();
|
|
71
|
+
if (!mergedTargets.has(t)) mergedTargets.set(t, []);
|
|
72
|
+
mergedTargets.get(t).push(body.trim());
|
|
73
|
+
return '';
|
|
74
|
+
});
|
|
75
|
+
if (any) mergedSeen.add(partialPath);
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function flushMergedStyles(distDir) {
|
|
80
|
+
for (const [target, blocks] of mergedTargets) {
|
|
81
|
+
const file = path.join(distDir, target);
|
|
82
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
83
|
+
const merged = blocks.join('\n\n');
|
|
84
|
+
if (fs.existsSync(file)) {
|
|
85
|
+
fs.appendFileSync(file, '\n\n/* templa merged */\n' + merged + '\n');
|
|
86
|
+
} else {
|
|
87
|
+
fs.writeFileSync(file, merged + '\n');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
mergedTargets.clear();
|
|
91
|
+
mergedSeen.clear();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── runtime-script stripper ─────────────────────────────────────────
|
|
95
|
+
// build output is fully expanded HTML; the runtime templa.js is no-op
|
|
96
|
+
// there. We remove the canonical bootstrap pair from output:
|
|
97
|
+
// <script src="...templa.js"></script> (the loader tag)
|
|
98
|
+
// <script>(await) templa.start();</script> (the start call)
|
|
99
|
+
// The user can opt out of the loader-tag strip by adding `data-keep`.
|
|
100
|
+
//
|
|
101
|
+
// Statement-level strip handles two forms — the whole-script case (the
|
|
102
|
+
// tag's only content is templa.start()) and the line-level case (one
|
|
103
|
+
// statement inside a multi-statement script). Complex usages like
|
|
104
|
+
// `templa.start().then(...)` or `const p = templa.start()` are left
|
|
105
|
+
// alone — they are intentional and the user owns them.
|
|
106
|
+
//
|
|
107
|
+
// As a corollary, the templa.js / templa.min.js asset is dropped from
|
|
108
|
+
// dist when no built HTML references it any more — the file is dead
|
|
109
|
+
// weight once the loader tag is gone. If the user keeps a script via
|
|
110
|
+
// data-keep, the asset is copied through and the parent dir survives.
|
|
111
|
+
const STRIP_TEMPLA_SRC = /<script\b(?![^>]*\bdata-keep\b)[^>]*\bsrc\s*=\s*["'][^"']*\btempla(\.min)?\.js[^"']*["'][^>]*>\s*<\/script>\s*/gi;
|
|
112
|
+
const STRIP_TEMPLA_ONLY_SCRIPT = /<script(?:\s+type\s*=\s*["']module["'])?\s*>\s*(?:await\s+)?templa\.start\s*\(\s*\)\s*;?\s*<\/script>\s*/gi;
|
|
113
|
+
const STRIP_TEMPLA_LINE = /^[ \t]*(?:await\s+)?templa\.start\s*\(\s*\)\s*;?[ \t]*\r?\n?/gm;
|
|
114
|
+
const STRIP_EMPTY_MODULE = /<script\s+type\s*=\s*["']module["']\s*>\s*<\/script>\s*/gi;
|
|
115
|
+
const TEMPLA_ASSET = /^templa(\.min)?\.js$/;
|
|
116
|
+
const HAS_TEMPLA_SCRIPT_REF = /<script\b[^>]*\bsrc\s*=\s*["'][^"']*\btempla(\.min)?\.js[^"']*["']/i;
|
|
117
|
+
|
|
118
|
+
function stripRuntimeScripts(html) {
|
|
119
|
+
return html
|
|
120
|
+
.replace(STRIP_TEMPLA_SRC, '')
|
|
121
|
+
.replace(STRIP_TEMPLA_ONLY_SCRIPT, '')
|
|
122
|
+
.replace(STRIP_TEMPLA_LINE, '')
|
|
123
|
+
.replace(STRIP_EMPTY_MODULE, '');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function collectData(attrs) {
|
|
127
|
+
const data = {};
|
|
128
|
+
ATTR.lastIndex = 0;
|
|
129
|
+
let m;
|
|
130
|
+
while ((m = ATTR.exec(attrs))) {
|
|
131
|
+
const name = m[1];
|
|
132
|
+
if (RESERVED.has(name) || name.startsWith('data-')) continue;
|
|
133
|
+
data[name] = m[2] ?? m[3] ?? '';
|
|
134
|
+
}
|
|
135
|
+
return data;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const TEMPLATE_OPEN = /<template((?:\s+[\w:.-]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+))?)*)\s*(\/?)>/gi;
|
|
139
|
+
const TEMPLATE_TAG = /<template\b|<\/template\s*>/gi;
|
|
140
|
+
|
|
141
|
+
// Length-preserving redaction: blank out quoted string contents so a literal
|
|
142
|
+
// `<template>` token inside an attribute value can't desync depth tracking.
|
|
143
|
+
const redactStrings = s => s.replace(
|
|
144
|
+
/"[^"]*"|'[^']*'/g,
|
|
145
|
+
m => m[0] + ' '.repeat(m.length - 2) + m[m.length - 1]
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Top-level <template>...</template> blocks in `html`, depth-aware so
|
|
149
|
+
// nested templates do not confuse the matching close.
|
|
150
|
+
function findTemplateBlocks(html) {
|
|
151
|
+
const scan = redactStrings(html);
|
|
152
|
+
const out = [];
|
|
153
|
+
TEMPLATE_OPEN.lastIndex = 0;
|
|
154
|
+
let m;
|
|
155
|
+
while ((m = TEMPLATE_OPEN.exec(scan))) {
|
|
156
|
+
const start = m.index;
|
|
157
|
+
const openEnd = start + m[0].length;
|
|
158
|
+
const attrs = html.substring(start + 9, start + 9 + m[1].length);
|
|
159
|
+
if (m[2] === '/') {
|
|
160
|
+
out.push({ start, end: openEnd, attrs, inner: '' });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
TEMPLATE_TAG.lastIndex = openEnd;
|
|
164
|
+
let depth = 1, t;
|
|
165
|
+
while (depth > 0 && (t = TEMPLATE_TAG.exec(scan))) {
|
|
166
|
+
if (t[0][1] === '/') depth--;
|
|
167
|
+
else depth++;
|
|
168
|
+
if (depth === 0) {
|
|
169
|
+
out.push({ start, end: t.index + t[0].length, attrs, inner: html.slice(openEnd, t.index) });
|
|
170
|
+
TEMPLATE_OPEN.lastIndex = t.index + t[0].length;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function parseSlots(innerHtml) {
|
|
179
|
+
const named = {};
|
|
180
|
+
const fillers = findTemplateBlocks(innerHtml)
|
|
181
|
+
.filter(b => getAttr(b.attrs, 'slot'))
|
|
182
|
+
.sort((a, b) => b.start - a.start);
|
|
183
|
+
let def = innerHtml;
|
|
184
|
+
for (const b of fillers) {
|
|
185
|
+
named[getAttr(b.attrs, 'slot')] = b.inner;
|
|
186
|
+
def = def.slice(0, b.start) + def.slice(b.end);
|
|
187
|
+
}
|
|
188
|
+
return { named, default: def };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function fillSlots(html, slots) {
|
|
192
|
+
return html.replace(
|
|
193
|
+
/<slot(\s[^>]*?)?>([\s\S]*?)<\/slot>/gi,
|
|
194
|
+
(_, attrs, fallback) => {
|
|
195
|
+
const name = attrs ? getAttr(attrs, 'name') : null;
|
|
196
|
+
if (name) return name in slots.named ? slots.named[name] : fallback;
|
|
197
|
+
return slots.default.trim() ? slots.default : fallback;
|
|
198
|
+
}
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// <template if="key"> / <template unless="key"> — existence-based conditional
|
|
203
|
+
// blocks. Iterates until stable so nested conditionals resolve.
|
|
204
|
+
function applyConditionals(html, data) {
|
|
205
|
+
let prev;
|
|
206
|
+
do {
|
|
207
|
+
prev = html;
|
|
208
|
+
const blocks = findTemplateBlocks(html);
|
|
209
|
+
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
210
|
+
const b = blocks[i];
|
|
211
|
+
const ifKey = getAttr(b.attrs, 'if');
|
|
212
|
+
const unlessKey = getAttr(b.attrs, 'unless');
|
|
213
|
+
if (ifKey !== null) {
|
|
214
|
+
html = html.slice(0, b.start) + (data[ifKey] ? b.inner : '') + html.slice(b.end);
|
|
215
|
+
} else if (unlessKey !== null) {
|
|
216
|
+
html = html.slice(0, b.start) + (!data[unlessKey] ? b.inner : '') + html.slice(b.end);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} while (html !== prev);
|
|
220
|
+
return html;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ─── recursive expansion ─────────────────────────────────────────────
|
|
224
|
+
function expand(html, baseDir, depth = 0) {
|
|
225
|
+
if (depth > MAX_DEPTH) {
|
|
226
|
+
console.warn('[templa] max include depth reached; possible recursion');
|
|
227
|
+
return html;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const blocks = findTemplateBlocks(html);
|
|
231
|
+
if (blocks.length === 0) return html;
|
|
232
|
+
|
|
233
|
+
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
234
|
+
const b = blocks[i];
|
|
235
|
+
const src = getAttr(b.attrs, 'src');
|
|
236
|
+
|
|
237
|
+
if (src) {
|
|
238
|
+
const data = collectData(b.attrs);
|
|
239
|
+
|
|
240
|
+
// Resolve conditionals in the slot payload first (using this call's
|
|
241
|
+
// data), then recursively expand any partials inside it.
|
|
242
|
+
const conditionalPayload = applyConditionals(b.inner, data);
|
|
243
|
+
const expandedPayload = expand(conditionalPayload, baseDir, depth + 1);
|
|
244
|
+
const slots = parseSlots(expandedPayload);
|
|
245
|
+
|
|
246
|
+
const partialPath = path.resolve(baseDir, src);
|
|
247
|
+
let content = '';
|
|
248
|
+
if (fs.existsSync(partialPath)) {
|
|
249
|
+
content = fs.readFileSync(partialPath, 'utf8');
|
|
250
|
+
content = extractMergedStyles(content, partialPath);
|
|
251
|
+
content = applyConditionals(content, data);
|
|
252
|
+
content = render(content, data);
|
|
253
|
+
content = fillSlots(content, slots);
|
|
254
|
+
content = expand(content, path.dirname(partialPath), depth + 1);
|
|
255
|
+
} else {
|
|
256
|
+
console.error('[templa] partial not found:', partialPath);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
html = html.slice(0, b.start) + content + html.slice(b.end);
|
|
260
|
+
} else {
|
|
261
|
+
// Not a partial include (e.g. <template slot="x"> filler); recurse
|
|
262
|
+
// into its inner so any partials there get expanded in this context.
|
|
263
|
+
const expandedInner = expand(b.inner, baseDir, depth + 1);
|
|
264
|
+
if (expandedInner !== b.inner) {
|
|
265
|
+
const open = `<template${b.attrs}>`;
|
|
266
|
+
html = html.slice(0, b.start) + open + expandedInner + '</template>' + html.slice(b.end);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return html;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ─── build command ───────────────────────────────────────────────────
|
|
274
|
+
function isPartial(name) {
|
|
275
|
+
return name.startsWith('_');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function walk(srcDir, distDir) {
|
|
279
|
+
const stats = { files: 0, partials: 0, copied: 0, stripped: 0 };
|
|
280
|
+
const distAbs = path.resolve(distDir);
|
|
281
|
+
const deferredTempla = [];
|
|
282
|
+
let anyHtmlKeptTempla = false;
|
|
283
|
+
|
|
284
|
+
const visit = (dir, outDir) => {
|
|
285
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
286
|
+
if (entry.name.startsWith('.')) continue;
|
|
287
|
+
const inPath = path.join(dir, entry.name);
|
|
288
|
+
const outPath = path.join(outDir, entry.name);
|
|
289
|
+
|
|
290
|
+
if (path.resolve(inPath) === distAbs) continue;
|
|
291
|
+
|
|
292
|
+
if (entry.isDirectory()) {
|
|
293
|
+
if (isPartial(entry.name)) { stats.partials++; continue; }
|
|
294
|
+
fs.mkdirSync(outPath, { recursive: true });
|
|
295
|
+
visit(inPath, outPath);
|
|
296
|
+
} else if (entry.name.endsWith('.html')) {
|
|
297
|
+
if (isPartial(entry.name)) { stats.partials++; continue; }
|
|
298
|
+
let html = expand(fs.readFileSync(inPath, 'utf8'), dir);
|
|
299
|
+
html = stripRuntimeScripts(html);
|
|
300
|
+
if (HAS_TEMPLA_SCRIPT_REF.test(html)) anyHtmlKeptTempla = true;
|
|
301
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
302
|
+
fs.writeFileSync(outPath, html);
|
|
303
|
+
stats.files++;
|
|
304
|
+
console.log(` ${path.relative(srcDir, inPath)}`);
|
|
305
|
+
} else if (TEMPLA_ASSET.test(entry.name)) {
|
|
306
|
+
// Defer: copied below only if a built HTML still references it.
|
|
307
|
+
deferredTempla.push({ src: inPath, dest: outPath });
|
|
308
|
+
} else {
|
|
309
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
310
|
+
fs.copyFileSync(inPath, outPath);
|
|
311
|
+
stats.copied++;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
visit(srcDir, distDir);
|
|
317
|
+
|
|
318
|
+
if (anyHtmlKeptTempla) {
|
|
319
|
+
for (const a of deferredTempla) {
|
|
320
|
+
fs.mkdirSync(path.dirname(a.dest), { recursive: true });
|
|
321
|
+
fs.copyFileSync(a.src, a.dest);
|
|
322
|
+
stats.copied++;
|
|
323
|
+
}
|
|
324
|
+
} else {
|
|
325
|
+
stats.stripped = deferredTempla.length;
|
|
326
|
+
for (const a of deferredTempla) {
|
|
327
|
+
const d = path.dirname(a.dest);
|
|
328
|
+
if (fs.existsSync(d) && fs.readdirSync(d).length === 0) {
|
|
329
|
+
fs.rmdirSync(d);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return stats;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function build(args) {
|
|
337
|
+
const inIdx = args.indexOf('-i');
|
|
338
|
+
const outIdx = args.indexOf('-o');
|
|
339
|
+
const SRC = path.resolve(process.cwd(), inIdx !== -1 ? args[inIdx + 1] : './src');
|
|
340
|
+
const DIST = path.resolve(process.cwd(), outIdx !== -1 ? args[outIdx + 1] : './dist');
|
|
341
|
+
|
|
342
|
+
if (!fs.existsSync(SRC)) {
|
|
343
|
+
console.error(`Error: source directory not found: ${SRC}`);
|
|
344
|
+
process.exit(1);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (fs.existsSync(DIST)) fs.rmSync(DIST, { recursive: true, force: true });
|
|
348
|
+
fs.mkdirSync(DIST, { recursive: true });
|
|
349
|
+
|
|
350
|
+
console.log(`templa build`);
|
|
351
|
+
console.log(` src: ${path.relative(process.cwd(), SRC) || '.'}`);
|
|
352
|
+
console.log(` dist: ${path.relative(process.cwd(), DIST) || '.'}`);
|
|
353
|
+
console.log('');
|
|
354
|
+
|
|
355
|
+
const t0 = Date.now();
|
|
356
|
+
const stats = walk(SRC, DIST);
|
|
357
|
+
flushMergedStyles(DIST);
|
|
358
|
+
const ms = Date.now() - t0;
|
|
359
|
+
|
|
360
|
+
console.log('');
|
|
361
|
+
const stripped = stats.stripped > 0 ? `, ${stats.stripped} stripped` : '';
|
|
362
|
+
console.log(`✓ ${stats.files} page(s), ${stats.copied} asset(s)${stripped}, ${stats.partials} partial(s) skipped — ${ms}ms`);
|
|
363
|
+
const distRel = path.relative(process.cwd(), DIST) || 'dist';
|
|
364
|
+
console.log('');
|
|
365
|
+
autoFormat(distRel);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Auto-run prettier on dist when it is reachable, locally (devDep) or
|
|
369
|
+
// globally (on PATH); otherwise just print the equivalent command as a
|
|
370
|
+
// hint. Keeps templa itself dependency-free while opting users into
|
|
371
|
+
// formatting if they have it.
|
|
372
|
+
function findPrettier() {
|
|
373
|
+
const isWin = process.platform === 'win32';
|
|
374
|
+
// Local: project node_modules — invoke through npx so it picks up
|
|
375
|
+
// the right .bin shim across platforms.
|
|
376
|
+
try {
|
|
377
|
+
require.resolve('prettier', { paths: [process.cwd()] });
|
|
378
|
+
return { cmd: isWin ? 'npx.cmd' : 'npx', prefix: ['prettier'] };
|
|
379
|
+
} catch {}
|
|
380
|
+
// Global: anything on PATH (typically `npm i -g prettier`).
|
|
381
|
+
const { spawnSync } = require('child_process');
|
|
382
|
+
const which = spawnSync(isWin ? 'where' : 'which', ['prettier'], { stdio: 'ignore' });
|
|
383
|
+
if (which.status === 0) return { cmd: isWin ? 'prettier.cmd' : 'prettier', prefix: [] };
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function autoFormat(distRel) {
|
|
388
|
+
const runner = findPrettier();
|
|
389
|
+
if (!runner) {
|
|
390
|
+
console.log(` Tip: pretty-print with \`npx prettier --write ${distRel}\``);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
console.log(` Formatting ${distRel}/ with prettier...`);
|
|
395
|
+
const { spawnSync } = require('child_process');
|
|
396
|
+
const result = spawnSync(runner.cmd, [...runner.prefix, '--write', '--log-level=warn', distRel], { stdio: 'inherit' });
|
|
397
|
+
if (result.error) console.error(` prettier failed: ${result.error.message}`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ─── init command ────────────────────────────────────────────────────
|
|
401
|
+
const PKG_ROOT = path.resolve(__dirname, '..');
|
|
402
|
+
|
|
403
|
+
function listScaffoldFiles() {
|
|
404
|
+
const items = [];
|
|
405
|
+
const examplesRoot = path.join(PKG_ROOT, 'examples');
|
|
406
|
+
|
|
407
|
+
const walk = (dir, relBase) => {
|
|
408
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
409
|
+
if (entry.name.startsWith('.')) continue;
|
|
410
|
+
// serve.json is a preview-time config for `npx serve examples` and
|
|
411
|
+
// should not propagate into init'd projects.
|
|
412
|
+
if (!relBase && entry.name === 'serve.json') continue;
|
|
413
|
+
const abs = path.join(dir, entry.name);
|
|
414
|
+
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
|
|
415
|
+
// examples/js/templa.js is a symlink for local preview; it does not
|
|
416
|
+
// survive `npm pack`, and the package-root templa.js is the canonical
|
|
417
|
+
// source — pushed explicitly below.
|
|
418
|
+
if (rel === 'js/templa.js') continue;
|
|
419
|
+
if (entry.isDirectory()) walk(abs, rel);
|
|
420
|
+
else items.push({ src: abs, dest: `src/${rel}` });
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
walk(examplesRoot, '');
|
|
424
|
+
|
|
425
|
+
items.push({ src: path.join(PKG_ROOT, 'templa.js'), dest: 'src/js/templa.js' });
|
|
426
|
+
return items;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function init(args) {
|
|
430
|
+
let withAi = false;
|
|
431
|
+
let force = false;
|
|
432
|
+
for (const arg of args) {
|
|
433
|
+
if (arg === '--ai') withAi = true;
|
|
434
|
+
else if (arg === '--force') force = true;
|
|
435
|
+
else {
|
|
436
|
+
console.error(`Unknown init option: ${arg}\n`);
|
|
437
|
+
help();
|
|
438
|
+
process.exit(1);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const cwd = process.cwd();
|
|
443
|
+
const items = listScaffoldFiles();
|
|
444
|
+
if (withAi) {
|
|
445
|
+
items.push({ src: path.join(PKG_ROOT, 'AGENTS.md'), dest: 'AGENTS.md' });
|
|
446
|
+
items.push({ src: path.join(PKG_ROOT, 'PLANNER.md'), dest: 'PLANNER.md' });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
for (const { src } of items) {
|
|
450
|
+
if (!fs.existsSync(src)) {
|
|
451
|
+
console.error(`Internal error: templa installation appears broken at ${src}`);
|
|
452
|
+
process.exit(1);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const conflicts = items
|
|
457
|
+
.map(it => it.dest)
|
|
458
|
+
.filter(dest => fs.existsSync(path.join(cwd, dest)));
|
|
459
|
+
|
|
460
|
+
console.log(`templa init`);
|
|
461
|
+
console.log(` cwd: ${cwd}`);
|
|
462
|
+
console.log('');
|
|
463
|
+
|
|
464
|
+
if (!force && conflicts.length > 0) {
|
|
465
|
+
console.error(` Refusing to overwrite existing files:`);
|
|
466
|
+
for (const c of conflicts) console.error(` ${c}`);
|
|
467
|
+
console.error('');
|
|
468
|
+
console.error(' Re-run with --force to overwrite.');
|
|
469
|
+
process.exit(1);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
for (const { src, dest } of items) {
|
|
473
|
+
const destAbs = path.join(cwd, dest);
|
|
474
|
+
fs.mkdirSync(path.dirname(destAbs), { recursive: true });
|
|
475
|
+
fs.copyFileSync(src, destAbs);
|
|
476
|
+
console.log(` ${dest}`);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
console.log('');
|
|
480
|
+
console.log(`✓ ${items.length} file(s) written`);
|
|
481
|
+
console.log('');
|
|
482
|
+
console.log('Next steps:');
|
|
483
|
+
console.log(' 1. Open src/index.html in a browser to preview (runtime mode)');
|
|
484
|
+
console.log(' 2. Run `npx templa-js build` to produce ./dist');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ─── entry ───────────────────────────────────────────────────────────
|
|
488
|
+
function help() {
|
|
489
|
+
process.stdout.write(`
|
|
490
|
+
templa v${VERSION} — tiny HTML template loader
|
|
491
|
+
|
|
492
|
+
Usage:
|
|
493
|
+
templa build [-i <src>] [-o <dist>]
|
|
494
|
+
templa init [--ai] [--force]
|
|
495
|
+
|
|
496
|
+
Build options:
|
|
497
|
+
-i <dir> Source directory (default: ./src)
|
|
498
|
+
-o <dir> Output directory (default: ./dist)
|
|
499
|
+
|
|
500
|
+
Init options:
|
|
501
|
+
--ai Also write AGENTS.md and PLANNER.md (AI agent guides)
|
|
502
|
+
--force Overwrite existing files
|
|
503
|
+
|
|
504
|
+
-v, --version Show version
|
|
505
|
+
-h, --help Show this help
|
|
506
|
+
|
|
507
|
+
Convention:
|
|
508
|
+
Files and directories starting with "_" are treated as partials
|
|
509
|
+
and are not written to the output directory. Reference them via
|
|
510
|
+
<template src="_partials/header.html"></template>.
|
|
511
|
+
|
|
512
|
+
Template syntax:
|
|
513
|
+
{{key}} HTML-escaped variable
|
|
514
|
+
{{{key}}} raw variable
|
|
515
|
+
<template if="key">…</template> keep block when data[key] is truthy
|
|
516
|
+
<template unless="key">…</template> keep block when data[key] is falsy
|
|
517
|
+
|
|
518
|
+
Passing data:
|
|
519
|
+
<template src="card.html" title="Tiny" body="Light, ~3KB."></template>
|
|
520
|
+
|
|
521
|
+
Build also strips the runtime bootstrap from output HTML:
|
|
522
|
+
<script src="...templa.js"></script> (removed)
|
|
523
|
+
<script type="module">await templa.start(); (line removed)
|
|
524
|
+
Add the data-keep attribute to opt out of the script-src strip.
|
|
525
|
+
|
|
526
|
+
Layouts (Web Components-style slots):
|
|
527
|
+
<!-- _layouts/main.html -->
|
|
528
|
+
<header><slot name="nav"></slot></header><main><slot></slot></main>
|
|
529
|
+
|
|
530
|
+
<!-- page.html -->
|
|
531
|
+
<template src="_layouts/main.html">
|
|
532
|
+
<template slot="nav">…</template>
|
|
533
|
+
<h1>Hello</h1>
|
|
534
|
+
</template>
|
|
535
|
+
|
|
536
|
+
`);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const [, , cmd, ...rest] = process.argv;
|
|
540
|
+
switch (cmd) {
|
|
541
|
+
case 'build': build(rest); break;
|
|
542
|
+
case 'init': init(rest); break;
|
|
543
|
+
case '-v': case '--version': console.log(VERSION); break;
|
|
544
|
+
case '-h': case '--help': case undefined: help(); break;
|
|
545
|
+
default:
|
|
546
|
+
console.error(`Unknown command: ${cmd}\n`);
|
|
547
|
+
help();
|
|
548
|
+
process.exit(1);
|
|
549
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<style data-merge="css/style.css">
|
|
2
|
+
.about-body { padding: var(--space-5) 0; }
|
|
3
|
+
.about-body p { margin: 0 0 var(--space-3); }
|
|
4
|
+
</style>
|
|
5
|
+
<section class="about-body">
|
|
6
|
+
<p>This is a starter scaffold. Each page is a list of section partials, and each section lives in its own file under <code>_partials/</code>.</p>
|
|
7
|
+
<p>Edit <code>src/about.html</code> to add or remove sections; create new <code>_partials/about-*.html</code> files for new sections on this page.</p>
|
|
8
|
+
</section>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<style data-merge="css/style.css">
|
|
2
|
+
header { display: flex; justify-content: space-between; align-items: center; padding: var(--space-3) 0; }
|
|
3
|
+
header nav a { margin-left: var(--space-3); }
|
|
4
|
+
header[data-page="index"] a[data-page="index"],
|
|
5
|
+
header[data-page="about"] a[data-page="about"] {
|
|
6
|
+
font-weight: bold;
|
|
7
|
+
}
|
|
8
|
+
</style>
|
|
9
|
+
<header data-page="{{page}}">
|
|
10
|
+
<strong>My templa site</strong>
|
|
11
|
+
<nav>
|
|
12
|
+
<a href="./" data-page="index">Home</a>
|
|
13
|
+
<a href="./about.html" data-page="about">About</a>
|
|
14
|
+
</nav>
|
|
15
|
+
</header>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<style data-merge="css/style.css">
|
|
2
|
+
.index-cta { padding: var(--space-5) 0; text-align: center; }
|
|
3
|
+
.index-cta a { display: inline-block; padding: var(--space-2) var(--space-4); border: 1px solid currentColor; text-decoration: none; }
|
|
4
|
+
</style>
|
|
5
|
+
<section class="index-cta">
|
|
6
|
+
<p>Ready to start?</p>
|
|
7
|
+
<a href="./about.html">See the about page</a>
|
|
8
|
+
</section>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<style data-merge="css/style.css">
|
|
2
|
+
.index-features { padding: var(--space-5) 0; }
|
|
3
|
+
.index-features ul { list-style: none; padding: 0; margin: 0; }
|
|
4
|
+
.index-features li { padding: var(--space-2) 0; border-bottom: 1px solid #eee; }
|
|
5
|
+
</style>
|
|
6
|
+
<section class="index-features">
|
|
7
|
+
<h2>What you get</h2>
|
|
8
|
+
<ul>
|
|
9
|
+
<li>One section per file — easy to write in parallel.</li>
|
|
10
|
+
<li>Layout via <code><template src></code> + <code><slot></code>.</li>
|
|
11
|
+
<li>Build mode strips the runtime; output is plain static HTML.</li>
|
|
12
|
+
</ul>
|
|
13
|
+
</section>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<style data-merge="css/style.css">
|
|
2
|
+
.index-hero { padding: var(--space-6) 0 var(--space-5); text-align: center; }
|
|
3
|
+
.index-hero h1 { margin: 0 0 var(--space-2); font-size: 2rem; }
|
|
4
|
+
.index-hero p { margin: 0; color: #666; }
|
|
5
|
+
</style>
|
|
6
|
+
<section class="index-hero">
|
|
7
|
+
<h1>Hello, templa</h1>
|
|
8
|
+
<p>A tiny HTML template loader.</p>
|
|
9
|
+
</section>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<template src="_partials/common-head.html" title="About"></template>
|
|
5
|
+
</head>
|
|
6
|
+
<body>
|
|
7
|
+
<template src="_partials/common-layout.html" page="about">
|
|
8
|
+
<template src="_partials/common-subhero.html" title="About"></template>
|
|
9
|
+
<template src="_partials/about-body.html"></template>
|
|
10
|
+
</template>
|
|
11
|
+
<script src="./js/templa.js"></script>
|
|
12
|
+
<script type="module">await templa.start();</script>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--space-1: 0.25rem;
|
|
3
|
+
--space-2: 0.5rem;
|
|
4
|
+
--space-3: 1rem;
|
|
5
|
+
--space-4: 1.5rem;
|
|
6
|
+
--space-5: 2rem;
|
|
7
|
+
--space-6: 3rem;
|
|
8
|
+
--space-7: 4rem;
|
|
9
|
+
}
|
|
10
|
+
body {
|
|
11
|
+
font-family: system-ui, sans-serif;
|
|
12
|
+
max-width: 720px;
|
|
13
|
+
margin: 0 auto;
|
|
14
|
+
padding: 0 var(--space-3);
|
|
15
|
+
line-height: 1.6;
|
|
16
|
+
}
|