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/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
- // ─── render: shared with browser runtime ─────────────────────────────
30
- const escHtml = s => String(s)
31
- .replace(/&/g, '&amp;')
32
- .replace(/</g, '&lt;')
33
- .replace(/>/g, '&gt;')
34
- .replace(/"/g, '&quot;')
35
- .replace(/'/g, '&#39;');
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']);
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
- mergedTargets.get(t).push(body.trim());
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
- 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;
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
- let html = expand(fs.readFileSync(inPath, 'utf8'), dir);
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
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
302
- fs.writeFileSync(outPath, html);
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(` ${path.relative(srcDir, inPath)}`);
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
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
310
- fs.copyFileSync(inPath, outPath);
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
- fs.mkdirSync(path.dirname(a.dest), { recursive: true });
321
- fs.copyFileSync(a.src, a.dest);
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
- 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
+ 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 (fs.existsSync(DIST)) fs.rmSync(DIST, { recursive: true, force: true });
348
- fs.mkdirSync(DIST, { recursive: true });
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(`templa build`);
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
- flushMergedStyles(DIST);
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
- console.log(`✓ ${stats.files} page(s), ${stats.copied} asset(s)${stripped}, ${stats.partials} partial(s) skipped — ${ms}ms`);
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 AGENTS.md and PLANNER.md (AI agent guides)
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,4 +1,4 @@
1
1
  <meta charset="utf-8" />
2
2
  <meta name="viewport" content="width=device-width, initial-scale=1" />
3
3
  <title>{{title}}</title>
4
- <link rel="stylesheet" href="./css/style.css" />
4
+ <link rel="stylesheet" href="../css/style.css" />
@@ -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
- header[data-page="index"] a[data-page="index"],
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 data-page="{{page}}">
6
+ <header>
10
7
  <strong>My templa site</strong>
11
8
  <nav>
12
- <a href="./" data-page="index">Home</a>
13
- <a href="./about.html" data-page="about">About</a>
9
+ <a href="/">Home</a>
10
+ <a href="/about.html">About</a>
14
11
  </nav>
15
12
  </header>
@@ -1,4 +1,4 @@
1
- <template src="common-header.html" page="{{page}}"></template>
1
+ <template src="common-header.html"></template>
2
2
  <main>
3
3
  <slot></slot>
4
4
  </main>
@@ -4,5 +4,5 @@
4
4
  </style>
5
5
  <section class="index-cta">
6
6
  <p>Ready to start?</p>
7
- <a href="./about.html">See the about page</a>
7
+ <a href="/about.html">See the about page</a>
8
8
  </section>
@@ -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" page="about">
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>
@@ -7,6 +7,7 @@
7
7
  --space-6: 3rem;
8
8
  --space-7: 4rem;
9
9
  }
10
+ /* ── tokens end ── (sub-agents only need the :root block above) */
10
11
  body {
11
12
  font-family: system-ui, sans-serif;
12
13
  max-width: 720px;
@@ -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" page="index">
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.10.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": [