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/AGENTS.md +20 -7
- package/PLANNER.md +11 -9
- package/README.md +72 -7
- package/SECTION.md +37 -0
- package/bin/templa.js +195 -148
- 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 +213 -16
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,42 +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
|
-
//
|
|
54
|
-
function getAttr(attrs, name) {
|
|
55
|
-
const dq = attrs.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`, 'i'));
|
|
56
|
-
if (dq) return dq[1];
|
|
57
|
-
const sq = attrs.match(new RegExp(`\\b${name}\\s*=\\s*'([^']*)'`, 'i'));
|
|
58
|
-
return sq ? sq[1] : null;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Every attribute is a string data key, except: src/slot/if/unless are
|
|
62
|
-
// reserved, and any data-* attribute is treated as metadata (skipped).
|
|
63
|
-
const RESERVED = new Set(['src', 'slot', 'if', 'unless']);
|
|
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.
|
|
64
62
|
const ATTR = /(\w[\w:.-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'))?/g;
|
|
65
63
|
|
|
66
64
|
// ─── co-located styles via <style data-merge="..."> ─────────────────
|
|
@@ -79,7 +77,10 @@ function extractMergedStyles(html, partialPath) {
|
|
|
79
77
|
any = true;
|
|
80
78
|
const t = target.trim();
|
|
81
79
|
if (!mergedTargets.has(t)) mergedTargets.set(t, []);
|
|
82
|
-
|
|
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());
|
|
83
84
|
return '';
|
|
84
85
|
});
|
|
85
86
|
if (any) mergedSeen.add(partialPath);
|
|
@@ -101,6 +102,36 @@ function flushMergedStyles(distDir) {
|
|
|
101
102
|
mergedSeen.clear();
|
|
102
103
|
}
|
|
103
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
|
+
|
|
104
135
|
// ─── runtime-script stripper ─────────────────────────────────────────
|
|
105
136
|
// build output is fully expanded HTML; the runtime templa.js is no-op
|
|
106
137
|
// there. We remove the canonical bootstrap pair from output:
|
|
@@ -125,6 +156,12 @@ const STRIP_EMPTY_MODULE = /<script\s+type\s*=\s*["']module["']\s*>\s*<\/script>
|
|
|
125
156
|
const TEMPLA_ASSET = /^templa(\.min)?\.js$/;
|
|
126
157
|
const HAS_TEMPLA_SCRIPT_REF = /<script\b[^>]*\bsrc\s*=\s*["'][^"']*\btempla(\.min)?\.js[^"']*["']/i;
|
|
127
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
|
+
|
|
128
165
|
function stripRuntimeScripts(html) {
|
|
129
166
|
return html
|
|
130
167
|
.replace(STRIP_TEMPLA_SRC, '')
|
|
@@ -145,95 +182,26 @@ function collectData(attrs) {
|
|
|
145
182
|
return data;
|
|
146
183
|
}
|
|
147
184
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
const scan = redactStrings(html);
|
|
162
|
-
const out = [];
|
|
163
|
-
TEMPLATE_OPEN.lastIndex = 0;
|
|
164
|
-
let m;
|
|
165
|
-
while ((m = TEMPLATE_OPEN.exec(scan))) {
|
|
166
|
-
const start = m.index;
|
|
167
|
-
const openEnd = start + m[0].length;
|
|
168
|
-
const attrs = html.substring(start + 9, start + 9 + m[1].length);
|
|
169
|
-
if (m[2] === '/') {
|
|
170
|
-
out.push({ start, end: openEnd, attrs, inner: '' });
|
|
171
|
-
continue;
|
|
172
|
-
}
|
|
173
|
-
TEMPLATE_TAG.lastIndex = openEnd;
|
|
174
|
-
let depth = 1, t;
|
|
175
|
-
while (depth > 0 && (t = TEMPLATE_TAG.exec(scan))) {
|
|
176
|
-
if (t[0][1] === '/') depth--;
|
|
177
|
-
else depth++;
|
|
178
|
-
if (depth === 0) {
|
|
179
|
-
out.push({ start, end: t.index + t[0].length, attrs, inner: html.slice(openEnd, t.index) });
|
|
180
|
-
TEMPLATE_OPEN.lastIndex = t.index + t[0].length;
|
|
181
|
-
break;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
return out;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function parseSlots(innerHtml) {
|
|
189
|
-
const named = {};
|
|
190
|
-
const fillers = findTemplateBlocks(innerHtml)
|
|
191
|
-
.filter(b => getAttr(b.attrs, 'slot'))
|
|
192
|
-
.sort((a, b) => b.start - a.start);
|
|
193
|
-
let def = innerHtml;
|
|
194
|
-
for (const b of fillers) {
|
|
195
|
-
named[getAttr(b.attrs, 'slot')] = b.inner;
|
|
196
|
-
def = def.slice(0, b.start) + def.slice(b.end);
|
|
197
|
-
}
|
|
198
|
-
return { named, default: def };
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function fillSlots(html, slots) {
|
|
202
|
-
return html.replace(
|
|
203
|
-
/<slot(\s[^>]*?)?>([\s\S]*?)<\/slot>/gi,
|
|
204
|
-
(_, attrs, fallback) => {
|
|
205
|
-
const name = attrs ? getAttr(attrs, 'name') : null;
|
|
206
|
-
if (name) return name in slots.named ? slots.named[name] : fallback;
|
|
207
|
-
return slots.default.trim() ? slots.default : fallback;
|
|
208
|
-
}
|
|
209
|
-
);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// <template if="key"> / <template unless="key"> — existence-based conditional
|
|
213
|
-
// blocks. Iterates until stable so nested conditionals resolve.
|
|
214
|
-
function applyConditionals(html, data) {
|
|
215
|
-
let prev;
|
|
216
|
-
do {
|
|
217
|
-
prev = html;
|
|
218
|
-
const blocks = findTemplateBlocks(html);
|
|
219
|
-
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
220
|
-
const b = blocks[i];
|
|
221
|
-
const ifKey = getAttr(b.attrs, 'if');
|
|
222
|
-
const unlessKey = getAttr(b.attrs, 'unless');
|
|
223
|
-
if (ifKey !== null) {
|
|
224
|
-
html = html.slice(0, b.start) + (data[ifKey.toLowerCase()] ? b.inner : '') + html.slice(b.end);
|
|
225
|
-
} else if (unlessKey !== null) {
|
|
226
|
-
html = html.slice(0, b.start) + (!data[unlessKey.toLowerCase()] ? b.inner : '') + html.slice(b.end);
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
} while (html !== prev);
|
|
230
|
-
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;
|
|
231
198
|
}
|
|
232
199
|
|
|
233
200
|
// ─── recursive expansion ─────────────────────────────────────────────
|
|
234
201
|
function expand(html, baseDir, depth = 0) {
|
|
235
202
|
if (depth > MAX_DEPTH) {
|
|
236
203
|
console.warn('[templa] max include depth reached; possible recursion');
|
|
204
|
+
report('max include depth reached (possible recursive include)');
|
|
237
205
|
return html;
|
|
238
206
|
}
|
|
239
207
|
|
|
@@ -257,13 +225,19 @@ function expand(html, baseDir, depth = 0) {
|
|
|
257
225
|
let content = '';
|
|
258
226
|
if (fs.existsSync(partialPath)) {
|
|
259
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
|
+
}
|
|
260
232
|
content = extractMergedStyles(content, partialPath);
|
|
233
|
+
content = rebaseAssets(content, v => rebaseUrl(v, path.dirname(partialPath)));
|
|
261
234
|
content = applyConditionals(content, data);
|
|
262
235
|
content = render(content, data);
|
|
263
236
|
content = fillSlots(content, slots);
|
|
264
237
|
content = expand(content, path.dirname(partialPath), depth + 1);
|
|
265
238
|
} else {
|
|
266
239
|
console.error('[templa] partial not found:', partialPath);
|
|
240
|
+
report(`partial not found: ${path.relative(process.cwd(), partialPath)}`);
|
|
267
241
|
}
|
|
268
242
|
|
|
269
243
|
html = html.slice(0, b.start) + content + html.slice(b.end);
|
|
@@ -285,7 +259,8 @@ function isPartial(name) {
|
|
|
285
259
|
return name.startsWith('_');
|
|
286
260
|
}
|
|
287
261
|
|
|
288
|
-
function walk(srcDir, distDir) {
|
|
262
|
+
function walk(srcDir, distDir, opts = {}) {
|
|
263
|
+
const { dryRun = false } = opts;
|
|
289
264
|
const stats = { files: 0, partials: 0, copied: 0, stripped: 0 };
|
|
290
265
|
const distAbs = path.resolve(distDir);
|
|
291
266
|
const deferredTempla = [];
|
|
@@ -301,23 +276,40 @@ function walk(srcDir, distDir) {
|
|
|
301
276
|
|
|
302
277
|
if (entry.isDirectory()) {
|
|
303
278
|
if (isPartial(entry.name)) { stats.partials++; continue; }
|
|
304
|
-
fs.mkdirSync(outPath, { recursive: true });
|
|
279
|
+
if (!dryRun) fs.mkdirSync(outPath, { recursive: true });
|
|
305
280
|
visit(inPath, outPath);
|
|
306
281
|
} else if (entry.name.endsWith('.html')) {
|
|
307
282
|
if (isPartial(entry.name)) { stats.partials++; continue; }
|
|
308
|
-
|
|
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('/'));
|
|
309
291
|
html = stripRuntimeScripts(html);
|
|
310
292
|
if (HAS_TEMPLA_SCRIPT_REF.test(html)) anyHtmlKeptTempla = true;
|
|
311
|
-
|
|
312
|
-
|
|
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
|
+
}
|
|
313
303
|
stats.files++;
|
|
314
|
-
console.log(` ${
|
|
304
|
+
console.log(` ${currentPage}`);
|
|
315
305
|
} else if (TEMPLA_ASSET.test(entry.name)) {
|
|
316
306
|
// Defer: copied below only if a built HTML still references it.
|
|
317
307
|
deferredTempla.push({ src: inPath, dest: outPath });
|
|
318
308
|
} else {
|
|
319
|
-
|
|
320
|
-
|
|
309
|
+
if (!dryRun) {
|
|
310
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
311
|
+
fs.copyFileSync(inPath, outPath);
|
|
312
|
+
}
|
|
321
313
|
stats.copied++;
|
|
322
314
|
}
|
|
323
315
|
}
|
|
@@ -327,49 +319,72 @@ function walk(srcDir, distDir) {
|
|
|
327
319
|
|
|
328
320
|
if (anyHtmlKeptTempla) {
|
|
329
321
|
for (const a of deferredTempla) {
|
|
330
|
-
|
|
331
|
-
|
|
322
|
+
if (!dryRun) {
|
|
323
|
+
fs.mkdirSync(path.dirname(a.dest), { recursive: true });
|
|
324
|
+
fs.copyFileSync(a.src, a.dest);
|
|
325
|
+
}
|
|
332
326
|
stats.copied++;
|
|
333
327
|
}
|
|
334
328
|
} else {
|
|
335
329
|
stats.stripped = deferredTempla.length;
|
|
336
|
-
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
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
|
+
}
|
|
340
336
|
}
|
|
341
337
|
}
|
|
342
338
|
}
|
|
343
339
|
return stats;
|
|
344
340
|
}
|
|
345
341
|
|
|
346
|
-
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');
|
|
347
346
|
const inIdx = args.indexOf('-i');
|
|
348
347
|
const outIdx = args.indexOf('-o');
|
|
349
348
|
const SRC = path.resolve(process.cwd(), inIdx !== -1 ? args[inIdx + 1] : './src');
|
|
350
349
|
const DIST = path.resolve(process.cwd(), outIdx !== -1 ? args[outIdx + 1] : './dist');
|
|
350
|
+
srcRoot = SRC; // base for root-absolute asset rebasing inside partials
|
|
351
351
|
|
|
352
352
|
if (!fs.existsSync(SRC)) {
|
|
353
353
|
console.error(`Error: source directory not found: ${SRC}`);
|
|
354
354
|
process.exit(1);
|
|
355
355
|
}
|
|
356
356
|
|
|
357
|
-
if (
|
|
358
|
-
|
|
357
|
+
if (!dryRun) {
|
|
358
|
+
if (fs.existsSync(DIST)) fs.rmSync(DIST, { recursive: true, force: true });
|
|
359
|
+
fs.mkdirSync(DIST, { recursive: true });
|
|
360
|
+
}
|
|
359
361
|
|
|
360
|
-
console.log(
|
|
362
|
+
console.log(dryRun ? 'templa check' : 'templa build');
|
|
361
363
|
console.log(` src: ${path.relative(process.cwd(), SRC) || '.'}`);
|
|
362
|
-
console.log(` dist: ${path.relative(process.cwd(), DIST) || '.'}`);
|
|
364
|
+
if (!dryRun) console.log(` dist: ${path.relative(process.cwd(), DIST) || '.'}`);
|
|
363
365
|
console.log('');
|
|
364
366
|
|
|
365
367
|
const t0 = Date.now();
|
|
366
|
-
const stats = walk(SRC, DIST);
|
|
367
|
-
|
|
368
|
+
const stats = walk(SRC, DIST, { dryRun });
|
|
369
|
+
if (dryRun) {
|
|
370
|
+
mergedTargets.clear();
|
|
371
|
+
mergedSeen.clear();
|
|
372
|
+
} else {
|
|
373
|
+
flushMergedStyles(DIST);
|
|
374
|
+
}
|
|
368
375
|
const ms = Date.now() - t0;
|
|
369
376
|
|
|
370
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
|
+
}
|
|
371
384
|
const stripped = stats.stripped > 0 ? `, ${stats.stripped} stripped` : '';
|
|
372
|
-
|
|
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;
|
|
373
388
|
const distRel = path.relative(process.cwd(), DIST) || 'dist';
|
|
374
389
|
console.log('');
|
|
375
390
|
autoFormat(distRel);
|
|
@@ -410,6 +425,15 @@ function autoFormat(distRel) {
|
|
|
410
425
|
// ─── init command ────────────────────────────────────────────────────
|
|
411
426
|
const PKG_ROOT = path.resolve(__dirname, '..');
|
|
412
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
|
+
|
|
413
437
|
function listScaffoldFiles() {
|
|
414
438
|
const items = [];
|
|
415
439
|
const examplesRoot = path.join(PKG_ROOT, 'examples');
|
|
@@ -454,10 +478,12 @@ function init(args) {
|
|
|
454
478
|
if (withAi) {
|
|
455
479
|
items.push({ src: path.join(PKG_ROOT, 'AGENTS.md'), dest: 'AGENTS.md' });
|
|
456
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' });
|
|
457
483
|
}
|
|
458
484
|
|
|
459
485
|
for (const { src } of items) {
|
|
460
|
-
if (!fs.existsSync(src)) {
|
|
486
|
+
if (src && !fs.existsSync(src)) {
|
|
461
487
|
console.error(`Internal error: templa installation appears broken at ${src}`);
|
|
462
488
|
process.exit(1);
|
|
463
489
|
}
|
|
@@ -479,10 +505,11 @@ function init(args) {
|
|
|
479
505
|
process.exit(1);
|
|
480
506
|
}
|
|
481
507
|
|
|
482
|
-
for (const { src, dest } of items) {
|
|
508
|
+
for (const { src, content, dest } of items) {
|
|
483
509
|
const destAbs = path.join(cwd, dest);
|
|
484
510
|
fs.mkdirSync(path.dirname(destAbs), { recursive: true });
|
|
485
|
-
fs.copyFileSync(src, destAbs);
|
|
511
|
+
if (src) fs.copyFileSync(src, destAbs);
|
|
512
|
+
else fs.writeFileSync(destAbs, content);
|
|
486
513
|
console.log(` ${dest}`);
|
|
487
514
|
}
|
|
488
515
|
|
|
@@ -500,15 +527,26 @@ function help() {
|
|
|
500
527
|
templa v${VERSION} — tiny HTML template loader
|
|
501
528
|
|
|
502
529
|
Usage:
|
|
503
|
-
templa build [-i <src>] [-o <dist>]
|
|
530
|
+
templa build [-i <src>] [-o <dist>] [--strict] [--no-format]
|
|
531
|
+
templa check [-i <src>]
|
|
504
532
|
templa init [--ai] [--force]
|
|
505
533
|
|
|
506
534
|
Build options:
|
|
507
535
|
-i <dir> Source directory (default: ./src)
|
|
508
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).
|
|
509
545
|
|
|
510
546
|
Init options:
|
|
511
|
-
--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)
|
|
512
550
|
--force Overwrite existing files
|
|
513
551
|
|
|
514
552
|
-v, --version Show version
|
|
@@ -528,6 +566,14 @@ Template syntax:
|
|
|
528
566
|
Passing data:
|
|
529
567
|
<template src="card.html" title="Tiny" body="Light, ~3KB."></template>
|
|
530
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
|
+
|
|
531
577
|
Build also strips the runtime bootstrap from output HTML:
|
|
532
578
|
<script src="...templa.js"></script> (removed)
|
|
533
579
|
<script type="module">await templa.start(); (line removed)
|
|
@@ -549,6 +595,7 @@ Layouts (Web Components-style slots):
|
|
|
549
595
|
const [, , cmd, ...rest] = process.argv;
|
|
550
596
|
switch (cmd) {
|
|
551
597
|
case 'build': build(rest); break;
|
|
598
|
+
case 'check': build(rest, { dryRun: true }); break;
|
|
552
599
|
case 'init': init(rest); break;
|
|
553
600
|
case '-v': case '--version': console.log(VERSION); break;
|
|
554
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": [
|