@thinkingsage/kanon 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/CHANGELOG.md +410 -0
  2. package/LICENSE +21 -0
  3. package/README.md +168 -0
  4. package/bridge/mcp-server.cjs +14171 -0
  5. package/package.json +98 -0
  6. package/src/adapters/capabilities.ts +178 -0
  7. package/src/adapters/claude-code.ts +110 -0
  8. package/src/adapters/cline.ts +98 -0
  9. package/src/adapters/codex.ts +173 -0
  10. package/src/adapters/copilot.ts +106 -0
  11. package/src/adapters/cursor.ts +97 -0
  12. package/src/adapters/degradation.ts +95 -0
  13. package/src/adapters/index.ts +324 -0
  14. package/src/adapters/kiro-frontmatter.ts +139 -0
  15. package/src/adapters/kiro-inclusion.ts +86 -0
  16. package/src/adapters/kiro.ts +412 -0
  17. package/src/adapters/qdeveloper.ts +115 -0
  18. package/src/adapters/types.ts +81 -0
  19. package/src/adapters/windsurf.ts +96 -0
  20. package/src/admin.ts +283 -0
  21. package/src/asset-conventions.ts +118 -0
  22. package/src/attribution-backfill.ts +319 -0
  23. package/src/attribution-report.ts +95 -0
  24. package/src/attribution.ts +239 -0
  25. package/src/backends/github.ts +194 -0
  26. package/src/backends/http.ts +122 -0
  27. package/src/backends/index.ts +39 -0
  28. package/src/backends/local.ts +47 -0
  29. package/src/backends/s3.ts +157 -0
  30. package/src/backends/types.ts +59 -0
  31. package/src/base-cache.ts +270 -0
  32. package/src/browse-ui.ts +3754 -0
  33. package/src/browse.ts +1038 -0
  34. package/src/build.ts +1108 -0
  35. package/src/catalog.ts +204 -0
  36. package/src/cli-deprecated.ts +29 -0
  37. package/src/cli.ts +773 -0
  38. package/src/collection-admin.ts +287 -0
  39. package/src/collection-builder.ts +464 -0
  40. package/src/collections.ts +116 -0
  41. package/src/compatibility.ts +105 -0
  42. package/src/config.ts +743 -0
  43. package/src/eval/rubrics/kiro-progressive-steering.ts +841 -0
  44. package/src/eval.ts +1169 -0
  45. package/src/file-writer.ts +61 -0
  46. package/src/format-registry.ts +141 -0
  47. package/src/guild/auto-updater.ts +163 -0
  48. package/src/guild/backend-resolver.ts +49 -0
  49. package/src/guild/cli.ts +592 -0
  50. package/src/guild/collection-expander.ts +47 -0
  51. package/src/guild/global-cache.ts +247 -0
  52. package/src/guild/hook-generator.ts +100 -0
  53. package/src/guild/manifest.ts +154 -0
  54. package/src/guild/path-utils.ts +12 -0
  55. package/src/guild/sync.ts +622 -0
  56. package/src/guild/version-resolver.ts +42 -0
  57. package/src/help/metadata.ts +445 -0
  58. package/src/help/renderer.ts +265 -0
  59. package/src/help/typo-suggester.ts +25 -0
  60. package/src/hooks/expression.ts +493 -0
  61. package/src/hooks/pipeline.ts +141 -0
  62. package/src/import.ts +773 -0
  63. package/src/importers/claude-code.ts +134 -0
  64. package/src/importers/cline.ts +103 -0
  65. package/src/importers/codex.ts +140 -0
  66. package/src/importers/copilot.ts +103 -0
  67. package/src/importers/cursor.ts +105 -0
  68. package/src/importers/index.ts +390 -0
  69. package/src/importers/kiro.ts +110 -0
  70. package/src/importers/qdeveloper.ts +103 -0
  71. package/src/importers/types.ts +54 -0
  72. package/src/importers/windsurf.ts +104 -0
  73. package/src/install.ts +1005 -0
  74. package/src/manifest-admin.ts +306 -0
  75. package/src/mcp-bridge.ts +240 -0
  76. package/src/mutation/delta.ts +50 -0
  77. package/src/mutation/history.ts +66 -0
  78. package/src/mutation/operators.ts +524 -0
  79. package/src/mutation/runner.ts +332 -0
  80. package/src/new.ts +106 -0
  81. package/src/outcomes/collision.ts +127 -0
  82. package/src/outcomes/normalize.ts +208 -0
  83. package/src/outcomes/registry.ts +173 -0
  84. package/src/parser.ts +446 -0
  85. package/src/provenance-backfill-cli.ts +319 -0
  86. package/src/provenance-backfill.ts +520 -0
  87. package/src/publish.ts +354 -0
  88. package/src/reconcile-orchestrator.ts +502 -0
  89. package/src/reconcile-report-renderer.ts +176 -0
  90. package/src/resolve-body.ts +15 -0
  91. package/src/rosetta/builtins/compatibility-profiles.ts +297 -0
  92. package/src/rosetta/builtins/contracts.ts +1033 -0
  93. package/src/rosetta/builtins/pretty-printers/claude-code-native.ts +122 -0
  94. package/src/rosetta/builtins/pretty-printers/cline-native.ts +50 -0
  95. package/src/rosetta/builtins/pretty-printers/codex-native.ts +127 -0
  96. package/src/rosetta/builtins/pretty-printers/copilot-native.ts +50 -0
  97. package/src/rosetta/builtins/pretty-printers/cursor-native.ts +50 -0
  98. package/src/rosetta/builtins/pretty-printers/index.ts +81 -0
  99. package/src/rosetta/builtins/pretty-printers/kiro-native.ts +166 -0
  100. package/src/rosetta/builtins/pretty-printers/kiro-power.ts +108 -0
  101. package/src/rosetta/builtins/pretty-printers/kiro-skill.ts +88 -0
  102. package/src/rosetta/builtins/pretty-printers/qdeveloper-native.ts +51 -0
  103. package/src/rosetta/builtins/pretty-printers/superpowers.ts +97 -0
  104. package/src/rosetta/builtins/pretty-printers/windsurf-native.ts +50 -0
  105. package/src/rosetta/builtins/sources/claude-code-native.ts +348 -0
  106. package/src/rosetta/builtins/sources/cline-native.ts +176 -0
  107. package/src/rosetta/builtins/sources/codex-native.ts +343 -0
  108. package/src/rosetta/builtins/sources/copilot-native.ts +178 -0
  109. package/src/rosetta/builtins/sources/cursor-native.ts +176 -0
  110. package/src/rosetta/builtins/sources/index.ts +95 -0
  111. package/src/rosetta/builtins/sources/kiro-native.ts +462 -0
  112. package/src/rosetta/builtins/sources/kiro-power.ts +285 -0
  113. package/src/rosetta/builtins/sources/kiro-skill.ts +230 -0
  114. package/src/rosetta/builtins/sources/qdeveloper-native.ts +181 -0
  115. package/src/rosetta/builtins/sources/superpowers.ts +240 -0
  116. package/src/rosetta/builtins/sources/windsurf-native.ts +176 -0
  117. package/src/rosetta/builtins/targets/claude-code.ts +181 -0
  118. package/src/rosetta/builtins/targets/cline.ts +87 -0
  119. package/src/rosetta/builtins/targets/codex.ts +226 -0
  120. package/src/rosetta/builtins/targets/copilot.ts +103 -0
  121. package/src/rosetta/builtins/targets/cursor.ts +87 -0
  122. package/src/rosetta/builtins/targets/index.ts +60 -0
  123. package/src/rosetta/builtins/targets/kiro.ts +278 -0
  124. package/src/rosetta/builtins/targets/qdeveloper.ts +103 -0
  125. package/src/rosetta/builtins/targets/windsurf.ts +87 -0
  126. package/src/rosetta/canonical.ts +729 -0
  127. package/src/rosetta/compatibility.ts +432 -0
  128. package/src/rosetta/contracts.ts +329 -0
  129. package/src/rosetta/detector.ts +724 -0
  130. package/src/rosetta/diagnostics.ts +630 -0
  131. package/src/rosetta/engine-bootstrap.ts +103 -0
  132. package/src/rosetta/engine.ts +744 -0
  133. package/src/rosetta/index.ts +381 -0
  134. package/src/rosetta/inspection.ts +530 -0
  135. package/src/rosetta/plan.ts +448 -0
  136. package/src/rosetta/provenance-digest.ts +369 -0
  137. package/src/rosetta/reconcile.ts +812 -0
  138. package/src/rosetta/redaction.ts +467 -0
  139. package/src/rosetta/registry.ts +712 -0
  140. package/src/rosetta/renderers.ts +571 -0
  141. package/src/rosetta/request-guard.ts +335 -0
  142. package/src/rosetta/resolution.ts +419 -0
  143. package/src/rosetta/source-accounting.ts +233 -0
  144. package/src/rosetta/templates.ts +129 -0
  145. package/src/rosetta-cli.ts +717 -0
  146. package/src/rosetta-docs-generator.ts +793 -0
  147. package/src/rosetta-profiles-cli.ts +367 -0
  148. package/src/schemas.ts +1712 -0
  149. package/src/spec-coordination.ts +1141 -0
  150. package/src/temper.ts +747 -0
  151. package/src/template-bundle-loader.ts +312 -0
  152. package/src/template-engine.ts +53 -0
  153. package/src/translation-application-policy.ts +496 -0
  154. package/src/translation-orchestrator.ts +1013 -0
  155. package/src/translation-plan-applier.ts +473 -0
  156. package/src/tutorial.ts +305 -0
  157. package/src/validate.ts +1093 -0
  158. package/src/versioning.ts +553 -0
  159. package/src/wizard.ts +660 -0
  160. package/src/workspace.ts +237 -0
  161. package/templates/eval-contexts/claude-code.md.njk +6 -0
  162. package/templates/eval-contexts/cline.md.njk +6 -0
  163. package/templates/eval-contexts/copilot.md.njk +6 -0
  164. package/templates/eval-contexts/cursor.md.njk +6 -0
  165. package/templates/eval-contexts/kiro.md.njk +10 -0
  166. package/templates/eval-contexts/qdeveloper.md.njk +6 -0
  167. package/templates/eval-contexts/windsurf.md.njk +6 -0
  168. package/templates/harness-adapters/_base/attribution-footer.md.njk +17 -0
  169. package/templates/harness-adapters/_base/base.md.njk +16 -0
  170. package/templates/harness-adapters/claude-code/claude.md.njk +1 -0
  171. package/templates/harness-adapters/claude-code/mcp.json.njk +1 -0
  172. package/templates/harness-adapters/claude-code/settings.json.njk +1 -0
  173. package/templates/harness-adapters/claude-code/skill-library-index.md.njk +13 -0
  174. package/templates/harness-adapters/claude-code/skill.md.njk +19 -0
  175. package/templates/harness-adapters/cline/hook.sh.njk +4 -0
  176. package/templates/harness-adapters/cline/mcp.json.njk +1 -0
  177. package/templates/harness-adapters/cline/rule.md.njk +1 -0
  178. package/templates/harness-adapters/codex/agents-md.md.njk +6 -0
  179. package/templates/harness-adapters/codex/agents-pointer.md.njk +16 -0
  180. package/templates/harness-adapters/codex/skill.md.njk +27 -0
  181. package/templates/harness-adapters/copilot/agents.md.njk +1 -0
  182. package/templates/harness-adapters/copilot/instructions.md.njk +1 -0
  183. package/templates/harness-adapters/copilot/scoped.md.njk +6 -0
  184. package/templates/harness-adapters/cursor/mcp.json.njk +1 -0
  185. package/templates/harness-adapters/cursor/rule.md.njk +6 -0
  186. package/templates/harness-adapters/kiro/hook.json.njk +1 -0
  187. package/templates/harness-adapters/kiro/mcp.json.njk +1 -0
  188. package/templates/harness-adapters/kiro/power-steering.md.njk +3 -0
  189. package/templates/harness-adapters/kiro/power.md.njk +12 -0
  190. package/templates/harness-adapters/kiro/steering.md.njk +16 -0
  191. package/templates/harness-adapters/qdeveloper/agent.md.njk +1 -0
  192. package/templates/harness-adapters/qdeveloper/mcp.json.njk +1 -0
  193. package/templates/harness-adapters/qdeveloper/rule.md.njk +1 -0
  194. package/templates/harness-adapters/windsurf/mcp.json.njk +1 -0
  195. package/templates/harness-adapters/windsurf/rule.md.njk +1 -0
  196. package/templates/harness-adapters/windsurf/workflow.md.njk +1 -0
  197. package/templates/knowledge/hooks.yaml.njk +4 -0
  198. package/templates/knowledge/knowledge.md.njk +53 -0
  199. package/templates/knowledge/mcp-servers.yaml.njk +2 -0
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Template Bundle Loader — Impure Template Loading and In-Memory Nunjucks Setup
3
+ *
4
+ * This module loads Nunjucks templates from the filesystem, validates
5
+ * inheritance and include references, computes a content digest, and
6
+ * creates an immutable in-memory bundle. Once created, the bundle never
7
+ * falls back to disk during rendering.
8
+ *
9
+ * This module is OUTSIDE the pure Rosetta Stone boundary — it imports
10
+ * `node:fs` and `node:path` for filesystem operations.
11
+ *
12
+ * Requirements: 1.3, 6.7, 12.2, 12.5, 12.7, 13.8
13
+ */
14
+
15
+ import { readdirSync, readFileSync, statSync } from "node:fs";
16
+ import { dirname, join, normalize, relative, sep } from "node:path";
17
+ import nunjucks from "nunjucks";
18
+ import { codePointCompare } from "./rosetta/contracts";
19
+ import type {
20
+ ImmutableTemplateBundle,
21
+ TemplateBundleOptions,
22
+ TemplateRenderError,
23
+ } from "./rosetta/templates";
24
+ import { computeBundleDigest } from "./rosetta/templates";
25
+
26
+ // ═══════════════════════════════════════════════════════════════════════════════
27
+ // In-Memory Nunjucks Loader
28
+ // ═══════════════════════════════════════════════════════════════════════════════
29
+
30
+ /**
31
+ * Custom Nunjucks loader that serves templates only from a pre-loaded map.
32
+ * Never falls back to disk. Throws clear errors for missing templates.
33
+ */
34
+ export class InMemoryNunjucksLoader extends nunjucks.Loader {
35
+ private readonly sources: ReadonlyMap<string, string>;
36
+
37
+ constructor(sources: ReadonlyMap<string, string>) {
38
+ super();
39
+ this.sources = sources;
40
+ }
41
+
42
+ /**
43
+ * Resolve a template name relative to its parent template.
44
+ * Handles relative references (../ and ./) by computing the path
45
+ * relative to the parent's directory, keeping everything relative.
46
+ */
47
+ resolve(parentName: string, name: string): string {
48
+ // If the name is not relative, return as-is
49
+ if (!name.startsWith("./") && !name.startsWith("../")) {
50
+ return name;
51
+ }
52
+
53
+ // Compute the relative path from the parent template's directory
54
+ const parentDir = parentName.includes("/")
55
+ ? parentName.substring(0, parentName.lastIndexOf("/"))
56
+ : "";
57
+
58
+ // Join parent dir with the relative reference and normalize
59
+ const segments = (parentDir ? `${parentDir}/${name}` : name).split("/");
60
+ const resolved: string[] = [];
61
+ for (const seg of segments) {
62
+ if (seg === "" || seg === ".") continue;
63
+ if (seg === "..") {
64
+ resolved.pop();
65
+ } else {
66
+ resolved.push(seg);
67
+ }
68
+ }
69
+ return resolved.join("/");
70
+ }
71
+
72
+ getSource(name: string): nunjucks.LoaderSource {
73
+ // Normalize path separators to forward slashes for lookup
74
+ const normalized = name.replace(/\\/g, "/");
75
+
76
+ const content = this.sources.get(normalized);
77
+ if (content === undefined) {
78
+ const available = [...this.sources.keys()].sort(codePointCompare);
79
+ throw new Error(
80
+ `Template "${normalized}" not found in immutable bundle. ` +
81
+ `Available templates: [${available.join(", ")}]`,
82
+ );
83
+ }
84
+
85
+ return {
86
+ src: content,
87
+ path: normalized,
88
+ noCache: true,
89
+ };
90
+ }
91
+ }
92
+
93
+ // ═══════════════════════════════════════════════════════════════════════════════
94
+ // Template Reference Resolution
95
+ // ═══════════════════════════════════════════════════════════════════════════════
96
+
97
+ /** Regex to find {% extends "..." %} and {% include "..." %} tags */
98
+ const NUNJUCKS_REF_PATTERN =
99
+ /\{%[-\s]*(?:extends|include)\s+["']([^"']+)["']\s*[-]?%\}/g;
100
+
101
+ /**
102
+ * Extract all template references (extends/include) from template source.
103
+ */
104
+ function extractTemplateReferences(source: string): string[] {
105
+ const refs: string[] = [];
106
+ let match: RegExpExecArray | null;
107
+ // Reset lastIndex for fresh search
108
+ NUNJUCKS_REF_PATTERN.lastIndex = 0;
109
+ while (true) {
110
+ match = NUNJUCKS_REF_PATTERN.exec(source);
111
+ if (match === null) break;
112
+ refs.push(match[1]);
113
+ }
114
+ return refs;
115
+ }
116
+
117
+ /**
118
+ * Resolve a template reference relative to the referencing template's directory.
119
+ * For example, if "cline/rule.md.njk" references "../_base/base.md.njk",
120
+ * resolve it to "_base/base.md.njk".
121
+ */
122
+ function resolveTemplateRef(fromTemplate: string, ref: string): string {
123
+ // If the reference is relative (starts with ./ or ../), resolve it
124
+ if (ref.startsWith("./") || ref.startsWith("../")) {
125
+ const fromDir = dirname(fromTemplate);
126
+ const resolved = normalize(join(fromDir, ref)).split(sep).join("/");
127
+ return resolved;
128
+ }
129
+ return ref;
130
+ }
131
+
132
+ /**
133
+ * Validate that all referenced templates exist in the loaded sources.
134
+ * Resolves relative paths (../ and ./) from the referencing template's location.
135
+ * Throws if any reference points to a missing template.
136
+ */
137
+ function validateTemplateReferences(
138
+ sources: ReadonlyMap<string, string>,
139
+ ): void {
140
+ for (const [templateName, content] of sources) {
141
+ const refs = extractTemplateReferences(content);
142
+ for (const ref of refs) {
143
+ const resolved = resolveTemplateRef(templateName, ref);
144
+ if (!sources.has(resolved) && !sources.has(ref)) {
145
+ throw new Error(
146
+ `Template "${templateName}" references "${ref}" ` +
147
+ `(via extends/include) which is not present in the bundle. ` +
148
+ `Ensure all referenced templates are in the templates directory.`,
149
+ );
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ // ═══════════════════════════════════════════════════════════════════════════════
156
+ // Recursive File Discovery
157
+ // ═══════════════════════════════════════════════════════════════════════════════
158
+
159
+ /**
160
+ * Recursively discover all `.njk` files in a directory.
161
+ * Returns a Map of relative path (forward-slash separated) → absolute path.
162
+ */
163
+ function discoverNjkFiles(
164
+ rootDir: string,
165
+ currentDir?: string,
166
+ ): Map<string, string> {
167
+ const dir = currentDir ?? rootDir;
168
+ const results = new Map<string, string>();
169
+
170
+ let entries: string[];
171
+ try {
172
+ entries = readdirSync(dir);
173
+ } catch {
174
+ throw new Error(
175
+ `Failed to read templates directory: ${dir}. ` +
176
+ `Ensure the directory exists and is readable.`,
177
+ );
178
+ }
179
+
180
+ for (const entry of entries) {
181
+ const fullPath = join(dir, entry);
182
+ let stat: ReturnType<typeof statSync>;
183
+ try {
184
+ stat = statSync(fullPath);
185
+ } catch {
186
+ continue;
187
+ }
188
+
189
+ if (stat.isDirectory()) {
190
+ const subResults = discoverNjkFiles(rootDir, fullPath);
191
+ for (const [relPath, absPath] of subResults) {
192
+ results.set(relPath, absPath);
193
+ }
194
+ } else if (entry.endsWith(".njk")) {
195
+ // Use forward slashes for template names regardless of OS
196
+ const relPath = relative(rootDir, fullPath).split(sep).join("/");
197
+ results.set(relPath, fullPath);
198
+ }
199
+ }
200
+
201
+ return results;
202
+ }
203
+
204
+ // ═══════════════════════════════════════════════════════════════════════════════
205
+ // Bundle Loading
206
+ // ═══════════════════════════════════════════════════════════════════════════════
207
+
208
+ /**
209
+ * Load all `.njk` templates from the given directory, validate references,
210
+ * compute a content digest, and return a frozen ImmutableTemplateBundle.
211
+ *
212
+ * This is the impure entry point — it reads the filesystem. The returned
213
+ * bundle is fully self-contained and can be safely passed into the pure
214
+ * translation boundary.
215
+ *
216
+ * @param templatesDir - Absolute or relative path to the templates root directory
217
+ * @param options - Optional bundle configuration
218
+ * @returns A frozen ImmutableTemplateBundle
219
+ * @throws If the directory cannot be read, or if referenced templates are missing
220
+ */
221
+ export function loadTemplateBundle(
222
+ templatesDir: string,
223
+ options?: TemplateBundleOptions,
224
+ ): ImmutableTemplateBundle {
225
+ // Discover all .njk files
226
+ const fileMap = discoverNjkFiles(templatesDir);
227
+
228
+ if (fileMap.size === 0) {
229
+ throw new Error(
230
+ `No .njk template files found in "${templatesDir}". ` +
231
+ `The templates directory must contain at least one template.`,
232
+ );
233
+ }
234
+
235
+ // Read all template sources into memory
236
+ const sources = new Map<string, string>();
237
+ for (const [relPath, absPath] of fileMap) {
238
+ const content = readFileSync(absPath, "utf-8");
239
+ sources.set(relPath, content);
240
+ }
241
+
242
+ // Validate that all extends/include references are resolvable
243
+ validateTemplateReferences(sources);
244
+
245
+ // Compute content digest
246
+ const digest = computeBundleDigest(sources);
247
+
248
+ // Create sorted template names
249
+ const templateNames = [...sources.keys()].sort(codePointCompare);
250
+
251
+ // Create in-memory Nunjucks environment with NO filesystem loader
252
+ const loader = new InMemoryNunjucksLoader(sources);
253
+ const env = new nunjucks.Environment(loader, {
254
+ autoescape: options?.autoEscape ?? false,
255
+ throwOnUndefined: options?.strictMode ?? false,
256
+ trimBlocks: true,
257
+ lstripBlocks: true,
258
+ });
259
+
260
+ // Add the titleCase filter to match existing template engine behavior
261
+ env.addFilter("titleCase", (str: string) => {
262
+ if (!str) return "";
263
+ return str
264
+ .split("-")
265
+ .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1))
266
+ .join(" ");
267
+ });
268
+
269
+ // Freeze the sources map
270
+ const frozenSources: ReadonlyMap<string, string> = sources;
271
+
272
+ // Build the immutable bundle
273
+ const bundle: ImmutableTemplateBundle = {
274
+ sources: frozenSources,
275
+ digest,
276
+ templateNames: Object.freeze(templateNames),
277
+
278
+ render(templateName: string, context: Record<string, unknown>): string {
279
+ if (!sources.has(templateName)) {
280
+ const err: TemplateRenderError = {
281
+ templateName,
282
+ message: `Template "${templateName}" not found in bundle`,
283
+ };
284
+ throw err;
285
+ }
286
+
287
+ try {
288
+ return env.render(templateName, context);
289
+ } catch (e: unknown) {
290
+ const msg = e instanceof Error ? e.message : String(e);
291
+ // Try to extract line number from Nunjucks error
292
+ let line: number | undefined;
293
+ if (e instanceof Error && "lineno" in e) {
294
+ line = (e as { lineno?: number }).lineno;
295
+ }
296
+ const err: TemplateRenderError = {
297
+ templateName,
298
+ message: msg,
299
+ line,
300
+ };
301
+ throw err;
302
+ }
303
+ },
304
+
305
+ has(templateName: string): boolean {
306
+ return sources.has(templateName);
307
+ },
308
+ };
309
+
310
+ // Freeze the bundle object itself
311
+ return Object.freeze(bundle);
312
+ }
@@ -0,0 +1,53 @@
1
+ import { resolve } from "node:path";
2
+ import nunjucks from "nunjucks";
3
+
4
+ export interface TemplateError {
5
+ templatePath: string;
6
+ message: string;
7
+ }
8
+
9
+ /**
10
+ * Create a Nunjucks environment rooted at the given templates directory.
11
+ * Supports template inheritance ({% extends %}, {% block %}).
12
+ */
13
+ export function createTemplateEnv(templatesDir: string): nunjucks.Environment {
14
+ const absPath = resolve(templatesDir);
15
+ const env = new nunjucks.Environment(
16
+ new nunjucks.FileSystemLoader(absPath, { noCache: true }),
17
+ {
18
+ autoescape: false,
19
+ throwOnUndefined: false,
20
+ trimBlocks: true,
21
+ lstripBlocks: true,
22
+ },
23
+ );
24
+
25
+ // Custom filter: kebab-case to Title Case
26
+ env.addFilter("titleCase", (str: string) => {
27
+ if (!str) return "";
28
+ return str
29
+ .split("-")
30
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
31
+ .join(" ");
32
+ });
33
+
34
+ return env;
35
+ }
36
+
37
+ /**
38
+ * Render a template with the given context.
39
+ * Returns the rendered string or throws a TemplateError.
40
+ */
41
+ export function renderTemplate(
42
+ env: nunjucks.Environment,
43
+ templatePath: string,
44
+ context: Record<string, unknown>,
45
+ ): string {
46
+ try {
47
+ return env.render(templatePath, context);
48
+ } catch (e: unknown) {
49
+ const msg = e instanceof Error ? e.message : String(e);
50
+ const err: TemplateError = { templatePath, message: msg };
51
+ throw err;
52
+ }
53
+ }