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/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
- // ─── 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
- // Data keys are case-insensitive to mirror HTML's own attribute
38
- // semantics (browsers lowercase attribute names in the DOM). Both
39
- // <template ctaLabel="X"> and {{ctaLabel}} normalize to "ctalabel"
40
- // so kebab-case, camelCase, and PascalCase all work the same.
41
- function render(html, data) {
42
- return html
43
- .replace(/{{{\s*([\w-]+)\s*}}}/g, (m, k) => {
44
- const lk = k.toLowerCase();
45
- return lk in data ? data[lk] : m;
46
- })
47
- .replace(/{{\s*([\w-]+)\s*}}/g, (m, k) => {
48
- const lk = k.toLowerCase();
49
- return lk in data ? escHtml(data[lk]) : m;
50
- });
51
- }
52
-
53
- // ─── attribute / template / slot parsing ─────────────────────────────
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
- 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());
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
- const TEMPLATE_OPEN = /<template((?:\s+[\w:.-]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+))?)*)\s*(\/?)>/gi;
149
- const TEMPLATE_TAG = /<template\b|<\/template\s*>/gi;
150
-
151
- // Length-preserving redaction: blank out quoted string contents so a literal
152
- // `<template>` token inside an attribute value can't desync depth tracking.
153
- const redactStrings = s => s.replace(
154
- /"[^"]*"|'[^']*'/g,
155
- m => m[0] + ' '.repeat(m.length - 2) + m[m.length - 1]
156
- );
157
-
158
- // Top-level <template>...</template> blocks in `html`, depth-aware so
159
- // nested templates do not confuse the matching close.
160
- function findTemplateBlocks(html) {
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
- 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('/'));
309
291
  html = stripRuntimeScripts(html);
310
292
  if (HAS_TEMPLA_SCRIPT_REF.test(html)) anyHtmlKeptTempla = true;
311
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
312
- 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
+ }
313
303
  stats.files++;
314
- console.log(` ${path.relative(srcDir, inPath)}`);
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
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
320
- fs.copyFileSync(inPath, outPath);
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
- fs.mkdirSync(path.dirname(a.dest), { recursive: true });
331
- 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
+ }
332
326
  stats.copied++;
333
327
  }
334
328
  } else {
335
329
  stats.stripped = deferredTempla.length;
336
- for (const a of deferredTempla) {
337
- const d = path.dirname(a.dest);
338
- if (fs.existsSync(d) && fs.readdirSync(d).length === 0) {
339
- 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
+ }
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 (fs.existsSync(DIST)) fs.rmSync(DIST, { recursive: true, force: true });
358
- 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
+ }
359
361
 
360
- console.log(`templa build`);
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
- flushMergedStyles(DIST);
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
- 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;
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 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)
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,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.1",
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": [