@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.
- package/CHANGELOG.md +410 -0
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/bridge/mcp-server.cjs +14171 -0
- package/package.json +98 -0
- package/src/adapters/capabilities.ts +178 -0
- package/src/adapters/claude-code.ts +110 -0
- package/src/adapters/cline.ts +98 -0
- package/src/adapters/codex.ts +173 -0
- package/src/adapters/copilot.ts +106 -0
- package/src/adapters/cursor.ts +97 -0
- package/src/adapters/degradation.ts +95 -0
- package/src/adapters/index.ts +324 -0
- package/src/adapters/kiro-frontmatter.ts +139 -0
- package/src/adapters/kiro-inclusion.ts +86 -0
- package/src/adapters/kiro.ts +412 -0
- package/src/adapters/qdeveloper.ts +115 -0
- package/src/adapters/types.ts +81 -0
- package/src/adapters/windsurf.ts +96 -0
- package/src/admin.ts +283 -0
- package/src/asset-conventions.ts +118 -0
- package/src/attribution-backfill.ts +319 -0
- package/src/attribution-report.ts +95 -0
- package/src/attribution.ts +239 -0
- package/src/backends/github.ts +194 -0
- package/src/backends/http.ts +122 -0
- package/src/backends/index.ts +39 -0
- package/src/backends/local.ts +47 -0
- package/src/backends/s3.ts +157 -0
- package/src/backends/types.ts +59 -0
- package/src/base-cache.ts +270 -0
- package/src/browse-ui.ts +3754 -0
- package/src/browse.ts +1038 -0
- package/src/build.ts +1108 -0
- package/src/catalog.ts +204 -0
- package/src/cli-deprecated.ts +29 -0
- package/src/cli.ts +773 -0
- package/src/collection-admin.ts +287 -0
- package/src/collection-builder.ts +464 -0
- package/src/collections.ts +116 -0
- package/src/compatibility.ts +105 -0
- package/src/config.ts +743 -0
- package/src/eval/rubrics/kiro-progressive-steering.ts +841 -0
- package/src/eval.ts +1169 -0
- package/src/file-writer.ts +61 -0
- package/src/format-registry.ts +141 -0
- package/src/guild/auto-updater.ts +163 -0
- package/src/guild/backend-resolver.ts +49 -0
- package/src/guild/cli.ts +592 -0
- package/src/guild/collection-expander.ts +47 -0
- package/src/guild/global-cache.ts +247 -0
- package/src/guild/hook-generator.ts +100 -0
- package/src/guild/manifest.ts +154 -0
- package/src/guild/path-utils.ts +12 -0
- package/src/guild/sync.ts +622 -0
- package/src/guild/version-resolver.ts +42 -0
- package/src/help/metadata.ts +445 -0
- package/src/help/renderer.ts +265 -0
- package/src/help/typo-suggester.ts +25 -0
- package/src/hooks/expression.ts +493 -0
- package/src/hooks/pipeline.ts +141 -0
- package/src/import.ts +773 -0
- package/src/importers/claude-code.ts +134 -0
- package/src/importers/cline.ts +103 -0
- package/src/importers/codex.ts +140 -0
- package/src/importers/copilot.ts +103 -0
- package/src/importers/cursor.ts +105 -0
- package/src/importers/index.ts +390 -0
- package/src/importers/kiro.ts +110 -0
- package/src/importers/qdeveloper.ts +103 -0
- package/src/importers/types.ts +54 -0
- package/src/importers/windsurf.ts +104 -0
- package/src/install.ts +1005 -0
- package/src/manifest-admin.ts +306 -0
- package/src/mcp-bridge.ts +240 -0
- package/src/mutation/delta.ts +50 -0
- package/src/mutation/history.ts +66 -0
- package/src/mutation/operators.ts +524 -0
- package/src/mutation/runner.ts +332 -0
- package/src/new.ts +106 -0
- package/src/outcomes/collision.ts +127 -0
- package/src/outcomes/normalize.ts +208 -0
- package/src/outcomes/registry.ts +173 -0
- package/src/parser.ts +446 -0
- package/src/provenance-backfill-cli.ts +319 -0
- package/src/provenance-backfill.ts +520 -0
- package/src/publish.ts +354 -0
- package/src/reconcile-orchestrator.ts +502 -0
- package/src/reconcile-report-renderer.ts +176 -0
- package/src/resolve-body.ts +15 -0
- package/src/rosetta/builtins/compatibility-profiles.ts +297 -0
- package/src/rosetta/builtins/contracts.ts +1033 -0
- package/src/rosetta/builtins/pretty-printers/claude-code-native.ts +122 -0
- package/src/rosetta/builtins/pretty-printers/cline-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/codex-native.ts +127 -0
- package/src/rosetta/builtins/pretty-printers/copilot-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/cursor-native.ts +50 -0
- package/src/rosetta/builtins/pretty-printers/index.ts +81 -0
- package/src/rosetta/builtins/pretty-printers/kiro-native.ts +166 -0
- package/src/rosetta/builtins/pretty-printers/kiro-power.ts +108 -0
- package/src/rosetta/builtins/pretty-printers/kiro-skill.ts +88 -0
- package/src/rosetta/builtins/pretty-printers/qdeveloper-native.ts +51 -0
- package/src/rosetta/builtins/pretty-printers/superpowers.ts +97 -0
- package/src/rosetta/builtins/pretty-printers/windsurf-native.ts +50 -0
- package/src/rosetta/builtins/sources/claude-code-native.ts +348 -0
- package/src/rosetta/builtins/sources/cline-native.ts +176 -0
- package/src/rosetta/builtins/sources/codex-native.ts +343 -0
- package/src/rosetta/builtins/sources/copilot-native.ts +178 -0
- package/src/rosetta/builtins/sources/cursor-native.ts +176 -0
- package/src/rosetta/builtins/sources/index.ts +95 -0
- package/src/rosetta/builtins/sources/kiro-native.ts +462 -0
- package/src/rosetta/builtins/sources/kiro-power.ts +285 -0
- package/src/rosetta/builtins/sources/kiro-skill.ts +230 -0
- package/src/rosetta/builtins/sources/qdeveloper-native.ts +181 -0
- package/src/rosetta/builtins/sources/superpowers.ts +240 -0
- package/src/rosetta/builtins/sources/windsurf-native.ts +176 -0
- package/src/rosetta/builtins/targets/claude-code.ts +181 -0
- package/src/rosetta/builtins/targets/cline.ts +87 -0
- package/src/rosetta/builtins/targets/codex.ts +226 -0
- package/src/rosetta/builtins/targets/copilot.ts +103 -0
- package/src/rosetta/builtins/targets/cursor.ts +87 -0
- package/src/rosetta/builtins/targets/index.ts +60 -0
- package/src/rosetta/builtins/targets/kiro.ts +278 -0
- package/src/rosetta/builtins/targets/qdeveloper.ts +103 -0
- package/src/rosetta/builtins/targets/windsurf.ts +87 -0
- package/src/rosetta/canonical.ts +729 -0
- package/src/rosetta/compatibility.ts +432 -0
- package/src/rosetta/contracts.ts +329 -0
- package/src/rosetta/detector.ts +724 -0
- package/src/rosetta/diagnostics.ts +630 -0
- package/src/rosetta/engine-bootstrap.ts +103 -0
- package/src/rosetta/engine.ts +744 -0
- package/src/rosetta/index.ts +381 -0
- package/src/rosetta/inspection.ts +530 -0
- package/src/rosetta/plan.ts +448 -0
- package/src/rosetta/provenance-digest.ts +369 -0
- package/src/rosetta/reconcile.ts +812 -0
- package/src/rosetta/redaction.ts +467 -0
- package/src/rosetta/registry.ts +712 -0
- package/src/rosetta/renderers.ts +571 -0
- package/src/rosetta/request-guard.ts +335 -0
- package/src/rosetta/resolution.ts +419 -0
- package/src/rosetta/source-accounting.ts +233 -0
- package/src/rosetta/templates.ts +129 -0
- package/src/rosetta-cli.ts +717 -0
- package/src/rosetta-docs-generator.ts +793 -0
- package/src/rosetta-profiles-cli.ts +367 -0
- package/src/schemas.ts +1712 -0
- package/src/spec-coordination.ts +1141 -0
- package/src/temper.ts +747 -0
- package/src/template-bundle-loader.ts +312 -0
- package/src/template-engine.ts +53 -0
- package/src/translation-application-policy.ts +496 -0
- package/src/translation-orchestrator.ts +1013 -0
- package/src/translation-plan-applier.ts +473 -0
- package/src/tutorial.ts +305 -0
- package/src/validate.ts +1093 -0
- package/src/versioning.ts +553 -0
- package/src/wizard.ts +660 -0
- package/src/workspace.ts +237 -0
- package/templates/eval-contexts/claude-code.md.njk +6 -0
- package/templates/eval-contexts/cline.md.njk +6 -0
- package/templates/eval-contexts/copilot.md.njk +6 -0
- package/templates/eval-contexts/cursor.md.njk +6 -0
- package/templates/eval-contexts/kiro.md.njk +10 -0
- package/templates/eval-contexts/qdeveloper.md.njk +6 -0
- package/templates/eval-contexts/windsurf.md.njk +6 -0
- package/templates/harness-adapters/_base/attribution-footer.md.njk +17 -0
- package/templates/harness-adapters/_base/base.md.njk +16 -0
- package/templates/harness-adapters/claude-code/claude.md.njk +1 -0
- package/templates/harness-adapters/claude-code/mcp.json.njk +1 -0
- package/templates/harness-adapters/claude-code/settings.json.njk +1 -0
- package/templates/harness-adapters/claude-code/skill-library-index.md.njk +13 -0
- package/templates/harness-adapters/claude-code/skill.md.njk +19 -0
- package/templates/harness-adapters/cline/hook.sh.njk +4 -0
- package/templates/harness-adapters/cline/mcp.json.njk +1 -0
- package/templates/harness-adapters/cline/rule.md.njk +1 -0
- package/templates/harness-adapters/codex/agents-md.md.njk +6 -0
- package/templates/harness-adapters/codex/agents-pointer.md.njk +16 -0
- package/templates/harness-adapters/codex/skill.md.njk +27 -0
- package/templates/harness-adapters/copilot/agents.md.njk +1 -0
- package/templates/harness-adapters/copilot/instructions.md.njk +1 -0
- package/templates/harness-adapters/copilot/scoped.md.njk +6 -0
- package/templates/harness-adapters/cursor/mcp.json.njk +1 -0
- package/templates/harness-adapters/cursor/rule.md.njk +6 -0
- package/templates/harness-adapters/kiro/hook.json.njk +1 -0
- package/templates/harness-adapters/kiro/mcp.json.njk +1 -0
- package/templates/harness-adapters/kiro/power-steering.md.njk +3 -0
- package/templates/harness-adapters/kiro/power.md.njk +12 -0
- package/templates/harness-adapters/kiro/steering.md.njk +16 -0
- package/templates/harness-adapters/qdeveloper/agent.md.njk +1 -0
- package/templates/harness-adapters/qdeveloper/mcp.json.njk +1 -0
- package/templates/harness-adapters/qdeveloper/rule.md.njk +1 -0
- package/templates/harness-adapters/windsurf/mcp.json.njk +1 -0
- package/templates/harness-adapters/windsurf/rule.md.njk +1 -0
- package/templates/harness-adapters/windsurf/workflow.md.njk +1 -0
- package/templates/knowledge/hooks.yaml.njk +4 -0
- package/templates/knowledge/knowledge.md.njk +53 -0
- 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
|
+
}
|