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/AGENTS.md +24 -9
- package/PLANNER.md +11 -9
- package/README.md +77 -10
- package/SECTION.md +37 -0
- package/bin/templa.js +196 -139
- package/examples/_partials/common-head.html +1 -1
- package/examples/_partials/common-header.html +4 -7
- package/examples/_partials/common-layout.html +1 -1
- package/examples/_partials/index-cta.html +1 -1
- package/examples/about.html +1 -1
- package/examples/css/style.css +1 -0
- package/examples/index.html +1 -1
- package/package.json +7 -2
- package/templa.js +231 -14
package/bin/templa.js
CHANGED
|
@@ -12,7 +12,15 @@
|
|
|
12
12
|
* runtime: {{key}}, {{{key}}}, <template if="key">…</template>,
|
|
13
13
|
* <template unless="key">…</template>, plus Web Components-style <slot>
|
|
14
14
|
* for layouts. Data is passed by plain HTML attributes (data-* attrs
|
|
15
|
-
* are reserved as metadata and skipped).
|
|
15
|
+
* are reserved as metadata and skipped). <a> elements inside <nav>
|
|
16
|
+
* pointing at the page being built are marked aria-current="page".
|
|
17
|
+
*
|
|
18
|
+
* Asset URLs inside a partial (src on any element, href on <link>/<use>,
|
|
19
|
+
* srcset, poster, and CSS url() in <style>/style="") are treated as
|
|
20
|
+
* partial-relative and rewritten to root-absolute paths — matching the
|
|
21
|
+
* runtime — so a shared partial resolves its assets the same from pages at
|
|
22
|
+
* any depth. <a href>, {{templated}} URLs, data-* and url(#id) are left as
|
|
23
|
+
* authored (write those root-absolute).
|
|
16
24
|
*
|
|
17
25
|
* Convention: files and directories starting with "_" are treated as
|
|
18
26
|
* partials and are never written to the output directory. Reference
|
|
@@ -25,32 +33,32 @@ const path = require('path');
|
|
|
25
33
|
|
|
26
34
|
const VERSION = require('../package.json').version;
|
|
27
35
|
const MAX_DEPTH = 50;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
36
|
+
let srcRoot = ''; // set per-build; base for root-absolute asset rebasing
|
|
37
|
+
|
|
38
|
+
// Single source of truth: the pure parser/renderer transforms live in templa.js
|
|
39
|
+
// (the browser runtime) and are imported here, so build and runtime can't drift.
|
|
40
|
+
// Only CLI-specific concerns stay below — filesystem I/O, the path-based URL
|
|
41
|
+
// resolver, merged-style extraction to disk, build-time nav marking, script
|
|
42
|
+
// stripping. (templa.js touches no DOM at load, so requiring it in Node is safe.)
|
|
43
|
+
const {
|
|
44
|
+
render, getAttr, findTemplateBlocks, hasUnclosed, applyConditionals,
|
|
45
|
+
parseSlots, fillSlots, rebaseAssets, rebaseCss, skipUrl, normalizePagePath,
|
|
46
|
+
RESERVED,
|
|
47
|
+
} = require('../templa.js')._;
|
|
48
|
+
|
|
49
|
+
// ─── problem collection (--strict / check) ───────────────────────────
|
|
50
|
+
// Problems are reported as a summary after the walk; with --strict (or the
|
|
51
|
+
// `check` command, which implies it) any problem makes the process exit 1
|
|
52
|
+
// so agents and CI can gate on the exit code instead of scraping logs.
|
|
53
|
+
const problems = [];
|
|
54
|
+
let currentPage = '';
|
|
55
|
+
const report = msg => problems.push(`${currentPage}: ${msg}`);
|
|
56
|
+
const UNRESOLVED_KEY = /{{{?\s*([\w-]+)\s*}}}?/g;
|
|
57
|
+
|
|
58
|
+
// ─── CLI-only: collect data from a raw attribute string ──────────────
|
|
59
|
+
// The runtime reads el.attributes from the DOM; the build has only the attrs
|
|
60
|
+
// substring, so it parses that. Reserved names and data-* are skipped (shared
|
|
61
|
+
// RESERVED). render/getAttr and the rest of the parser are imported above.
|
|
54
62
|
const ATTR = /(\w[\w:.-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'))?/g;
|
|
55
63
|
|
|
56
64
|
// ─── co-located styles via <style data-merge="..."> ─────────────────
|
|
@@ -69,7 +77,10 @@ function extractMergedStyles(html, partialPath) {
|
|
|
69
77
|
any = true;
|
|
70
78
|
const t = target.trim();
|
|
71
79
|
if (!mergedTargets.has(t)) mergedTargets.set(t, []);
|
|
72
|
-
|
|
80
|
+
// url()s in co-located styles are partial-relative; rebase to root-absolute
|
|
81
|
+
// (works regardless of where the merged stylesheet lands in dist).
|
|
82
|
+
const dir = path.dirname(partialPath);
|
|
83
|
+
mergedTargets.get(t).push(rebaseCss(body, v => rebaseUrl(v, dir)).trim());
|
|
73
84
|
return '';
|
|
74
85
|
});
|
|
75
86
|
if (any) mergedSeen.add(partialPath);
|
|
@@ -91,6 +102,36 @@ function flushMergedStyles(distDir) {
|
|
|
91
102
|
mergedSeen.clear();
|
|
92
103
|
}
|
|
93
104
|
|
|
105
|
+
// ─── current-page nav marking ────────────────────────────────────────
|
|
106
|
+
// Any <a> inside <nav> whose href resolves to the page being built gets
|
|
107
|
+
// aria-current="page" — CSS Selectors 4 specced `:local-link` for exactly
|
|
108
|
+
// this and no browser shipped it. Active-nav styling becomes a single rule
|
|
109
|
+
// (nav a[aria-current="page"]) with no per-page data threading and no
|
|
110
|
+
// page-enumerating selectors. Skipped: pure-hash hrefs (same-page TOC
|
|
111
|
+
// links), scheme/protocol-relative URLs (external), and any <a> whose
|
|
112
|
+
// author already wrote aria-current. `pagePath` is the site-root-absolute
|
|
113
|
+
// path of the page being built (e.g. /about.html, /blog/post.html); hrefs
|
|
114
|
+
// resolve against it exactly like a browser would. Path comparison is
|
|
115
|
+
// hosting-convention tolerant: /index.html ≡ /, /about.html ≡ /about ≡
|
|
116
|
+
// /about/ — so pretty-URL hrefs (Netlify, Vercel) match the .html page
|
|
117
|
+
// being built, mirroring the runtime's behaviour on such hosts.
|
|
118
|
+
const NAV_BLOCK = /<nav\b[^>]*>[\s\S]*?<\/nav>/gi;
|
|
119
|
+
const A_TAG = /<a\b((?:[^>"']|"[^"]*"|'[^']*')*)>/gi;
|
|
120
|
+
|
|
121
|
+
function markCurrentNavLinks(html, pagePath) {
|
|
122
|
+
const here = normalizePagePath(pagePath);
|
|
123
|
+
return html.replace(NAV_BLOCK, nav => nav.replace(A_TAG, (tag, attrs) => {
|
|
124
|
+
if (/(?:^|\s)aria-current\s*=/i.test(attrs)) return tag;
|
|
125
|
+
const href = getAttr(attrs, 'href');
|
|
126
|
+
if (!href || href.startsWith('#') || href.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(href)) return tag;
|
|
127
|
+
let url;
|
|
128
|
+
try { url = new URL(href, 'https://templa.local' + pagePath); } catch { return tag; }
|
|
129
|
+
if (url.origin !== 'https://templa.local') return tag;
|
|
130
|
+
if (normalizePagePath(url.pathname) !== here) return tag;
|
|
131
|
+
return `<a${attrs} aria-current="page">`;
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
|
|
94
135
|
// ─── runtime-script stripper ─────────────────────────────────────────
|
|
95
136
|
// build output is fully expanded HTML; the runtime templa.js is no-op
|
|
96
137
|
// there. We remove the canonical bootstrap pair from output:
|
|
@@ -115,6 +156,12 @@ const STRIP_EMPTY_MODULE = /<script\s+type\s*=\s*["']module["']\s*>\s*<\/script>
|
|
|
115
156
|
const TEMPLA_ASSET = /^templa(\.min)?\.js$/;
|
|
116
157
|
const HAS_TEMPLA_SCRIPT_REF = /<script\b[^>]*\bsrc\s*=\s*["'][^"']*\btempla(\.min)?\.js[^"']*["']/i;
|
|
117
158
|
|
|
159
|
+
// After expansion, partial-internal conditionals are resolved; any remaining
|
|
160
|
+
// <template if/unless> is page-level — it has no calling data, so it is left
|
|
161
|
+
// untouched and ends up as inert (invisible) markup in the output. The \s
|
|
162
|
+
// before the name keeps this from matching framework directives like x-if.
|
|
163
|
+
const LEFTOVER_COND = /<template\b[^>]*\s(?:if|unless)\s*=/i;
|
|
164
|
+
|
|
118
165
|
function stripRuntimeScripts(html) {
|
|
119
166
|
return html
|
|
120
167
|
.replace(STRIP_TEMPLA_SRC, '')
|
|
@@ -128,102 +175,33 @@ function collectData(attrs) {
|
|
|
128
175
|
ATTR.lastIndex = 0;
|
|
129
176
|
let m;
|
|
130
177
|
while ((m = ATTR.exec(attrs))) {
|
|
131
|
-
const name = m[1];
|
|
178
|
+
const name = m[1].toLowerCase();
|
|
132
179
|
if (RESERVED.has(name) || name.startsWith('data-')) continue;
|
|
133
180
|
data[name] = m[2] ?? m[3] ?? '';
|
|
134
181
|
}
|
|
135
182
|
return data;
|
|
136
183
|
}
|
|
137
184
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
//
|
|
150
|
-
|
|
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;
|
|
185
|
+
// ─── CLI-only: filesystem URL resolver for asset rebasing ────────────
|
|
186
|
+
// rebaseAssets / rebaseCss (shared, imported) call this resolver to turn a
|
|
187
|
+
// partial-relative URL into a root-absolute path. The runtime resolves against
|
|
188
|
+
// new URL(); the build resolves against the partial's source dir mapped onto
|
|
189
|
+
// srcRoot. skipUrl values and anything outside the source tree pass through.
|
|
190
|
+
function rebaseUrl(v, partialDir) {
|
|
191
|
+
if (skipUrl(v)) return v;
|
|
192
|
+
const m = v.match(/^([^?#]*)([?#].*)?$/);
|
|
193
|
+
const p = m[1], suffix = m[2] || '';
|
|
194
|
+
if (!p) return v;
|
|
195
|
+
const rel = path.relative(srcRoot, path.resolve(partialDir, p));
|
|
196
|
+
if (rel.startsWith('..')) return v; // outside the source tree — leave as-is
|
|
197
|
+
return '/' + rel.split(path.sep).join('/') + suffix;
|
|
221
198
|
}
|
|
222
199
|
|
|
223
200
|
// ─── recursive expansion ─────────────────────────────────────────────
|
|
224
201
|
function expand(html, baseDir, depth = 0) {
|
|
225
202
|
if (depth > MAX_DEPTH) {
|
|
226
203
|
console.warn('[templa] max include depth reached; possible recursion');
|
|
204
|
+
report('max include depth reached (possible recursive include)');
|
|
227
205
|
return html;
|
|
228
206
|
}
|
|
229
207
|
|
|
@@ -247,13 +225,19 @@ function expand(html, baseDir, depth = 0) {
|
|
|
247
225
|
let content = '';
|
|
248
226
|
if (fs.existsSync(partialPath)) {
|
|
249
227
|
content = fs.readFileSync(partialPath, 'utf8');
|
|
228
|
+
if (hasUnclosed(content)) {
|
|
229
|
+
console.warn(` [templa] warning: unclosed <template> in ${path.relative(process.cwd(), partialPath)} — missing </template>; the block is dropped.`);
|
|
230
|
+
report(`unclosed <template> in ${path.relative(process.cwd(), partialPath)}`);
|
|
231
|
+
}
|
|
250
232
|
content = extractMergedStyles(content, partialPath);
|
|
233
|
+
content = rebaseAssets(content, v => rebaseUrl(v, path.dirname(partialPath)));
|
|
251
234
|
content = applyConditionals(content, data);
|
|
252
235
|
content = render(content, data);
|
|
253
236
|
content = fillSlots(content, slots);
|
|
254
237
|
content = expand(content, path.dirname(partialPath), depth + 1);
|
|
255
238
|
} else {
|
|
256
239
|
console.error('[templa] partial not found:', partialPath);
|
|
240
|
+
report(`partial not found: ${path.relative(process.cwd(), partialPath)}`);
|
|
257
241
|
}
|
|
258
242
|
|
|
259
243
|
html = html.slice(0, b.start) + content + html.slice(b.end);
|
|
@@ -275,7 +259,8 @@ function isPartial(name) {
|
|
|
275
259
|
return name.startsWith('_');
|
|
276
260
|
}
|
|
277
261
|
|
|
278
|
-
function walk(srcDir, distDir) {
|
|
262
|
+
function walk(srcDir, distDir, opts = {}) {
|
|
263
|
+
const { dryRun = false } = opts;
|
|
279
264
|
const stats = { files: 0, partials: 0, copied: 0, stripped: 0 };
|
|
280
265
|
const distAbs = path.resolve(distDir);
|
|
281
266
|
const deferredTempla = [];
|
|
@@ -291,23 +276,40 @@ function walk(srcDir, distDir) {
|
|
|
291
276
|
|
|
292
277
|
if (entry.isDirectory()) {
|
|
293
278
|
if (isPartial(entry.name)) { stats.partials++; continue; }
|
|
294
|
-
fs.mkdirSync(outPath, { recursive: true });
|
|
279
|
+
if (!dryRun) fs.mkdirSync(outPath, { recursive: true });
|
|
295
280
|
visit(inPath, outPath);
|
|
296
281
|
} else if (entry.name.endsWith('.html')) {
|
|
297
282
|
if (isPartial(entry.name)) { stats.partials++; continue; }
|
|
298
|
-
|
|
283
|
+
currentPage = path.relative(srcDir, inPath);
|
|
284
|
+
const rawHtml = fs.readFileSync(inPath, 'utf8');
|
|
285
|
+
if (hasUnclosed(rawHtml)) {
|
|
286
|
+
console.warn(` [templa] warning: unclosed <template> in ${currentPage} — missing </template>; the block is dropped.`);
|
|
287
|
+
report('unclosed <template> (missing </template>)');
|
|
288
|
+
}
|
|
289
|
+
let html = expand(rawHtml, dir);
|
|
290
|
+
html = markCurrentNavLinks(html, '/' + currentPage.split(path.sep).join('/'));
|
|
299
291
|
html = stripRuntimeScripts(html);
|
|
300
292
|
if (HAS_TEMPLA_SCRIPT_REF.test(html)) anyHtmlKeptTempla = true;
|
|
301
|
-
|
|
302
|
-
|
|
293
|
+
if (LEFTOVER_COND.test(html)) {
|
|
294
|
+
console.warn(` [templa] warning: unresolved <template if/unless> in ${currentPage} — page-level conditionals have no data source and are not evaluated; move them inside a partial.`);
|
|
295
|
+
report('unresolved <template if/unless> (page-level conditional)');
|
|
296
|
+
}
|
|
297
|
+
const leftoverKeys = [...new Set([...html.matchAll(UNRESOLVED_KEY)].map(m => m[1]))];
|
|
298
|
+
if (leftoverKeys.length) report(`unresolved {{${leftoverKeys.join('}}, {{')}}}`);
|
|
299
|
+
if (!dryRun) {
|
|
300
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
301
|
+
fs.writeFileSync(outPath, html);
|
|
302
|
+
}
|
|
303
303
|
stats.files++;
|
|
304
|
-
console.log(` ${
|
|
304
|
+
console.log(` ${currentPage}`);
|
|
305
305
|
} else if (TEMPLA_ASSET.test(entry.name)) {
|
|
306
306
|
// Defer: copied below only if a built HTML still references it.
|
|
307
307
|
deferredTempla.push({ src: inPath, dest: outPath });
|
|
308
308
|
} else {
|
|
309
|
-
|
|
310
|
-
|
|
309
|
+
if (!dryRun) {
|
|
310
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
311
|
+
fs.copyFileSync(inPath, outPath);
|
|
312
|
+
}
|
|
311
313
|
stats.copied++;
|
|
312
314
|
}
|
|
313
315
|
}
|
|
@@ -317,49 +319,72 @@ function walk(srcDir, distDir) {
|
|
|
317
319
|
|
|
318
320
|
if (anyHtmlKeptTempla) {
|
|
319
321
|
for (const a of deferredTempla) {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
+
if (!dryRun) {
|
|
323
|
+
fs.mkdirSync(path.dirname(a.dest), { recursive: true });
|
|
324
|
+
fs.copyFileSync(a.src, a.dest);
|
|
325
|
+
}
|
|
322
326
|
stats.copied++;
|
|
323
327
|
}
|
|
324
328
|
} else {
|
|
325
329
|
stats.stripped = deferredTempla.length;
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
fs.
|
|
330
|
+
if (!dryRun) {
|
|
331
|
+
for (const a of deferredTempla) {
|
|
332
|
+
const d = path.dirname(a.dest);
|
|
333
|
+
if (fs.existsSync(d) && fs.readdirSync(d).length === 0) {
|
|
334
|
+
fs.rmdirSync(d);
|
|
335
|
+
}
|
|
330
336
|
}
|
|
331
337
|
}
|
|
332
338
|
}
|
|
333
339
|
return stats;
|
|
334
340
|
}
|
|
335
341
|
|
|
336
|
-
function build(args) {
|
|
342
|
+
function build(args, { dryRun = false } = {}) {
|
|
343
|
+
// `check` (dryRun) implies --strict: it exists to gate, not to build.
|
|
344
|
+
const strict = dryRun || args.includes('--strict');
|
|
345
|
+
const noFormat = dryRun || args.includes('--no-format');
|
|
337
346
|
const inIdx = args.indexOf('-i');
|
|
338
347
|
const outIdx = args.indexOf('-o');
|
|
339
348
|
const SRC = path.resolve(process.cwd(), inIdx !== -1 ? args[inIdx + 1] : './src');
|
|
340
349
|
const DIST = path.resolve(process.cwd(), outIdx !== -1 ? args[outIdx + 1] : './dist');
|
|
350
|
+
srcRoot = SRC; // base for root-absolute asset rebasing inside partials
|
|
341
351
|
|
|
342
352
|
if (!fs.existsSync(SRC)) {
|
|
343
353
|
console.error(`Error: source directory not found: ${SRC}`);
|
|
344
354
|
process.exit(1);
|
|
345
355
|
}
|
|
346
356
|
|
|
347
|
-
if (
|
|
348
|
-
|
|
357
|
+
if (!dryRun) {
|
|
358
|
+
if (fs.existsSync(DIST)) fs.rmSync(DIST, { recursive: true, force: true });
|
|
359
|
+
fs.mkdirSync(DIST, { recursive: true });
|
|
360
|
+
}
|
|
349
361
|
|
|
350
|
-
console.log(
|
|
362
|
+
console.log(dryRun ? 'templa check' : 'templa build');
|
|
351
363
|
console.log(` src: ${path.relative(process.cwd(), SRC) || '.'}`);
|
|
352
|
-
console.log(` dist: ${path.relative(process.cwd(), DIST) || '.'}`);
|
|
364
|
+
if (!dryRun) console.log(` dist: ${path.relative(process.cwd(), DIST) || '.'}`);
|
|
353
365
|
console.log('');
|
|
354
366
|
|
|
355
367
|
const t0 = Date.now();
|
|
356
|
-
const stats = walk(SRC, DIST);
|
|
357
|
-
|
|
368
|
+
const stats = walk(SRC, DIST, { dryRun });
|
|
369
|
+
if (dryRun) {
|
|
370
|
+
mergedTargets.clear();
|
|
371
|
+
mergedSeen.clear();
|
|
372
|
+
} else {
|
|
373
|
+
flushMergedStyles(DIST);
|
|
374
|
+
}
|
|
358
375
|
const ms = Date.now() - t0;
|
|
359
376
|
|
|
360
377
|
console.log('');
|
|
378
|
+
if (problems.length) {
|
|
379
|
+
console.error(`✗ ${problems.length} problem(s):`);
|
|
380
|
+
for (const p of problems) console.error(` ${p}`);
|
|
381
|
+
if (strict) process.exit(1);
|
|
382
|
+
console.log('');
|
|
383
|
+
}
|
|
361
384
|
const stripped = stats.stripped > 0 ? `, ${stats.stripped} stripped` : '';
|
|
362
|
-
|
|
385
|
+
const suffix = dryRun ? ' — dry run, nothing written' : '';
|
|
386
|
+
console.log(`✓ ${stats.files} page(s), ${stats.copied} asset(s)${stripped}, ${stats.partials} partial(s) skipped — ${ms}ms${suffix}`);
|
|
387
|
+
if (noFormat) return;
|
|
363
388
|
const distRel = path.relative(process.cwd(), DIST) || 'dist';
|
|
364
389
|
console.log('');
|
|
365
390
|
autoFormat(distRel);
|
|
@@ -400,6 +425,15 @@ function autoFormat(distRel) {
|
|
|
400
425
|
// ─── init command ────────────────────────────────────────────────────
|
|
401
426
|
const PKG_ROOT = path.resolve(__dirname, '..');
|
|
402
427
|
|
|
428
|
+
// Claude Code auto-loads CLAUDE.md (not AGENTS.md), so init --ai writes a
|
|
429
|
+
// pointer file; Cursor/Codex-style agents pick up AGENTS.md directly.
|
|
430
|
+
const CLAUDE_MD = `# CLAUDE.md
|
|
431
|
+
|
|
432
|
+
This project is a templa static site (https://github.com/yjmtmtk/templa-js).
|
|
433
|
+
Read AGENTS.md before editing files. Section sub-agents read SECTION.md instead.
|
|
434
|
+
Build: \`npx templa-js build\` — Verify: \`npx templa-js check\` (exit 0 = sound)
|
|
435
|
+
`;
|
|
436
|
+
|
|
403
437
|
function listScaffoldFiles() {
|
|
404
438
|
const items = [];
|
|
405
439
|
const examplesRoot = path.join(PKG_ROOT, 'examples');
|
|
@@ -444,10 +478,12 @@ function init(args) {
|
|
|
444
478
|
if (withAi) {
|
|
445
479
|
items.push({ src: path.join(PKG_ROOT, 'AGENTS.md'), dest: 'AGENTS.md' });
|
|
446
480
|
items.push({ src: path.join(PKG_ROOT, 'PLANNER.md'), dest: 'PLANNER.md' });
|
|
481
|
+
items.push({ src: path.join(PKG_ROOT, 'SECTION.md'), dest: 'SECTION.md' });
|
|
482
|
+
items.push({ content: CLAUDE_MD, dest: 'CLAUDE.md' });
|
|
447
483
|
}
|
|
448
484
|
|
|
449
485
|
for (const { src } of items) {
|
|
450
|
-
if (!fs.existsSync(src)) {
|
|
486
|
+
if (src && !fs.existsSync(src)) {
|
|
451
487
|
console.error(`Internal error: templa installation appears broken at ${src}`);
|
|
452
488
|
process.exit(1);
|
|
453
489
|
}
|
|
@@ -469,10 +505,11 @@ function init(args) {
|
|
|
469
505
|
process.exit(1);
|
|
470
506
|
}
|
|
471
507
|
|
|
472
|
-
for (const { src, dest } of items) {
|
|
508
|
+
for (const { src, content, dest } of items) {
|
|
473
509
|
const destAbs = path.join(cwd, dest);
|
|
474
510
|
fs.mkdirSync(path.dirname(destAbs), { recursive: true });
|
|
475
|
-
fs.copyFileSync(src, destAbs);
|
|
511
|
+
if (src) fs.copyFileSync(src, destAbs);
|
|
512
|
+
else fs.writeFileSync(destAbs, content);
|
|
476
513
|
console.log(` ${dest}`);
|
|
477
514
|
}
|
|
478
515
|
|
|
@@ -490,15 +527,26 @@ function help() {
|
|
|
490
527
|
templa v${VERSION} — tiny HTML template loader
|
|
491
528
|
|
|
492
529
|
Usage:
|
|
493
|
-
templa build [-i <src>] [-o <dist>]
|
|
530
|
+
templa build [-i <src>] [-o <dist>] [--strict] [--no-format]
|
|
531
|
+
templa check [-i <src>]
|
|
494
532
|
templa init [--ai] [--force]
|
|
495
533
|
|
|
496
534
|
Build options:
|
|
497
535
|
-i <dir> Source directory (default: ./src)
|
|
498
536
|
-o <dir> Output directory (default: ./dist)
|
|
537
|
+
--strict Exit 1 on any problem: missing partial, unresolved
|
|
538
|
+
{{key}} in output, page-level <template if/unless>,
|
|
539
|
+
unclosed <template>
|
|
540
|
+
--no-format Skip the automatic prettier pass on the output
|
|
541
|
+
|
|
542
|
+
Check:
|
|
543
|
+
Runs the build pipeline without writing anything; always strict.
|
|
544
|
+
Use as a machine-readable gate (exit 0 = sound, exit 1 = problems).
|
|
499
545
|
|
|
500
546
|
Init options:
|
|
501
|
-
--ai Also write
|
|
547
|
+
--ai Also write the AI agent guides: AGENTS.md (orchestrator),
|
|
548
|
+
PLANNER.md (skeleton planning), SECTION.md (sub-agent
|
|
549
|
+
brief), CLAUDE.md (pointer for Claude Code)
|
|
502
550
|
--force Overwrite existing files
|
|
503
551
|
|
|
504
552
|
-v, --version Show version
|
|
@@ -518,6 +566,14 @@ Template syntax:
|
|
|
518
566
|
Passing data:
|
|
519
567
|
<template src="card.html" title="Tiny" body="Light, ~3KB."></template>
|
|
520
568
|
|
|
569
|
+
Asset paths:
|
|
570
|
+
URLs in a partial are relative to the partial and rewritten to
|
|
571
|
+
root-absolute (src, <link>/<use> href, srcset, poster, and CSS
|
|
572
|
+
url() in <style>/style="" — so co-located <style data-merge> works).
|
|
573
|
+
<link href="../css/style.css"> in _partials/ becomes /css/style.css
|
|
574
|
+
from any page. <a href>, {{templated}} URLs, data-* and url(#id) are
|
|
575
|
+
left as-is — write those root-absolute.
|
|
576
|
+
|
|
521
577
|
Build also strips the runtime bootstrap from output HTML:
|
|
522
578
|
<script src="...templa.js"></script> (removed)
|
|
523
579
|
<script type="module">await templa.start(); (line removed)
|
|
@@ -539,6 +595,7 @@ Layouts (Web Components-style slots):
|
|
|
539
595
|
const [, , cmd, ...rest] = process.argv;
|
|
540
596
|
switch (cmd) {
|
|
541
597
|
case 'build': build(rest); break;
|
|
598
|
+
case 'check': build(rest, { dryRun: true }); break;
|
|
542
599
|
case 'init': init(rest); break;
|
|
543
600
|
case '-v': case '--version': console.log(VERSION); break;
|
|
544
601
|
case '-h': case '--help': case undefined: help(); break;
|
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
<style data-merge="css/style.css">
|
|
2
2
|
header { display: flex; justify-content: space-between; align-items: center; padding: var(--space-3) 0; }
|
|
3
3
|
header nav a { margin-left: var(--space-3); }
|
|
4
|
-
|
|
5
|
-
header[data-page="about"] a[data-page="about"] {
|
|
6
|
-
font-weight: bold;
|
|
7
|
-
}
|
|
4
|
+
nav a[aria-current="page"] { font-weight: bold; }
|
|
8
5
|
</style>
|
|
9
|
-
<header
|
|
6
|
+
<header>
|
|
10
7
|
<strong>My templa site</strong>
|
|
11
8
|
<nav>
|
|
12
|
-
<a href="
|
|
13
|
-
<a href="
|
|
9
|
+
<a href="/">Home</a>
|
|
10
|
+
<a href="/about.html">About</a>
|
|
14
11
|
</nav>
|
|
15
12
|
</header>
|
package/examples/about.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<template src="_partials/common-head.html" title="About"></template>
|
|
5
5
|
</head>
|
|
6
6
|
<body>
|
|
7
|
-
<template src="_partials/common-layout.html"
|
|
7
|
+
<template src="_partials/common-layout.html">
|
|
8
8
|
<template src="_partials/common-subhero.html" title="About"></template>
|
|
9
9
|
<template src="_partials/about-body.html"></template>
|
|
10
10
|
</template>
|
package/examples/css/style.css
CHANGED
package/examples/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<template src="_partials/common-head.html" title="Home"></template>
|
|
5
5
|
</head>
|
|
6
6
|
<body>
|
|
7
|
-
<template src="_partials/common-layout.html"
|
|
7
|
+
<template src="_partials/common-layout.html">
|
|
8
8
|
<template src="_partials/index-hero.html"></template>
|
|
9
9
|
<template src="_partials/index-features.html"></template>
|
|
10
10
|
<template src="_partials/index-cta.html"></template>
|
package/package.json
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "templa-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"description": "A tiny HTML template loader using <template src>. Read as tempura.",
|
|
5
5
|
"main": "templa.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "bash scripts/test-init.sh"
|
|
7
|
+
"test": "bash scripts/test-init.sh && bash scripts/test-build.sh && bash scripts/test-runtime.sh",
|
|
8
|
+
"prepublishOnly": "npm test"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=14.14"
|
|
8
12
|
},
|
|
9
13
|
"bin": {
|
|
10
14
|
"templa": "bin/templa.js"
|
|
@@ -16,6 +20,7 @@
|
|
|
16
20
|
"README.md",
|
|
17
21
|
"AGENTS.md",
|
|
18
22
|
"PLANNER.md",
|
|
23
|
+
"SECTION.md",
|
|
19
24
|
"LICENSE"
|
|
20
25
|
],
|
|
21
26
|
"keywords": [
|