@kenjura/ursa 0.85.0 → 0.87.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.
@@ -17,6 +17,16 @@
17
17
  */
18
18
 
19
19
  import { dirname, join, relative, resolve } from "path";
20
+ import { existsSync } from "fs";
21
+ import { mkdir, readFile, writeFile } from "fs/promises";
22
+ import { getUrsaDir } from "./contentHash.js";
23
+
24
+ const DEP_GRAPH_FILE = "dependency-graph.json";
25
+ const DEP_GRAPH_VERSION = 1;
26
+
27
+ // Static assets in meta (copied verbatim to output/public by copyMetaAssets);
28
+ // these are never embedded in document HTML, so no regeneration is needed.
29
+ const META_STATIC_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|ico|woff|woff2|ttf|eot|pdf|mp3|mp4|webm|ogg)$/i;
20
30
 
21
31
  export class DependencyTracker {
22
32
  constructor() {
@@ -216,7 +226,13 @@ export class DependencyTracker {
216
226
 
217
227
  // Template file changed → regenerate all documents using that template
218
228
  if (fileName.endsWith(".html")) {
219
- const templateName = fileName.replace(".html", "");
229
+ // New structure: templates/{templateName}/index.html → name is the folder;
230
+ // legacy flat structure: {templateName}.html at the meta root
231
+ const parts = relativePath.split("/");
232
+ const templateName =
233
+ parts[0] === "templates" && parts.length >= 3
234
+ ? parts[1]
235
+ : fileName.replace(".html", "");
220
236
  const affected = this.getDocumentsUsingTemplate(templateName);
221
237
  if (affected.size > 0) {
222
238
  return {
@@ -244,6 +260,17 @@ export class DependencyTracker {
244
260
  };
245
261
  }
246
262
 
263
+ // Static asset in meta (image, font, PDF, media) → copyMetaAssets already
264
+ // re-copied it to output/public; documents reference it by URL, so no
265
+ // document regeneration (and no full rebuild) is needed.
266
+ if (META_STATIC_EXTENSIONS.test(fileName)) {
267
+ return {
268
+ affectedDocuments: [],
269
+ reason: `Meta static asset copied: ${relativePath}`,
270
+ requiresFullRebuild: false,
271
+ };
272
+ }
273
+
247
274
  // Other meta file → full rebuild to be safe
248
275
  return {
249
276
  affectedDocuments: [],
@@ -263,7 +290,95 @@ export class DependencyTracker {
263
290
  uniqueFiles: this.fileToDocuments.size,
264
291
  };
265
292
  }
293
+
294
+ /**
295
+ * Serialize the tracker for persistence to .ursa/dependency-graph.json.
296
+ * @returns {{ version: number, sourceDir: string, documents: Object<string, string[]> }}
297
+ */
298
+ serialize() {
299
+ return {
300
+ version: DEP_GRAPH_VERSION,
301
+ sourceDir: this.sourceDir,
302
+ documents: Object.fromEntries(
303
+ [...this.documentToFiles.entries()].map(([doc, deps]) => [doc, [...deps]])
304
+ ),
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Load persisted registrations, merging with the current run: documents
310
+ * already registered in this run keep their (fresher) edges; persisted
311
+ * edges only fill in documents not yet registered (e.g. hash-skipped docs).
312
+ * Rejects data from a different source directory or schema version.
313
+ * @param {object} data - Previously serialized tracker
314
+ * @returns {boolean} Whether the data was loaded
315
+ */
316
+ load(data) {
317
+ if (!data || data.version !== DEP_GRAPH_VERSION) return false;
318
+ if (data.sourceDir && this.sourceDir && data.sourceDir !== this.sourceDir) return false;
319
+ for (const [doc, deps] of Object.entries(data.documents || {})) {
320
+ if (this.documentToFiles.has(doc)) continue; // live registrations win
321
+ if (!Array.isArray(deps)) continue;
322
+ for (const dep of deps) {
323
+ this.addDependency(doc, dep);
324
+ }
325
+ }
326
+ return true;
327
+ }
328
+
329
+ /**
330
+ * Drop registrations for documents not in the given set (e.g. deleted or
331
+ * excluded files), so persisted state doesn't accumulate stale entries.
332
+ * @param {Set<string>} keepDocuments - Document paths that should survive
333
+ */
334
+ prune(keepDocuments) {
335
+ for (const doc of [...this.documentToFiles.keys()]) {
336
+ if (!keepDocuments.has(doc)) this.clearDocument(doc);
337
+ }
338
+ }
266
339
  }
267
340
 
268
341
  // Singleton instance
269
342
  export const dependencyTracker = new DependencyTracker();
343
+
344
+ /** Path to the persisted dependency graph for a source directory. */
345
+ export function getDependencyGraphPath(sourceDir) {
346
+ return join(getUrsaDir(sourceDir), DEP_GRAPH_FILE);
347
+ }
348
+
349
+ /**
350
+ * Load the persisted dependency tracker state from .ursa/dependency-graph.json
351
+ * and merge it into the tracker (current-run registrations win).
352
+ * @param {string} sourceDir - Source directory root
353
+ * @param {DependencyTracker} [tracker] - Defaults to the singleton
354
+ * @returns {Promise<boolean>} Whether a valid graph was loaded
355
+ */
356
+ export async function loadDependencyTracker(sourceDir, tracker = dependencyTracker) {
357
+ const path = getDependencyGraphPath(sourceDir);
358
+ try {
359
+ if (!existsSync(path)) return false;
360
+ const data = JSON.parse(await readFile(path, "utf8"));
361
+ return tracker.load(data);
362
+ } catch (e) {
363
+ console.warn(`Could not load dependency graph: ${e.message}`);
364
+ return false;
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Persist the dependency tracker to .ursa/dependency-graph.json so that
370
+ * hash-skipped documents keep their edges across warm starts.
371
+ * @param {string} sourceDir - Source directory root
372
+ * @param {DependencyTracker} [tracker] - Defaults to the singleton
373
+ * @returns {Promise<boolean>} Whether the graph was saved
374
+ */
375
+ export async function saveDependencyTracker(sourceDir, tracker = dependencyTracker) {
376
+ try {
377
+ await mkdir(getUrsaDir(sourceDir), { recursive: true });
378
+ await writeFile(getDependencyGraphPath(sourceDir), JSON.stringify(tracker.serialize()));
379
+ return true;
380
+ } catch (e) {
381
+ console.warn(`Could not save dependency graph: ${e.message}`);
382
+ return false;
383
+ }
384
+ }
@@ -0,0 +1,454 @@
1
+ /**
2
+ * Document Templates
3
+ *
4
+ * Allows users to create _templates/ folders anywhere in the docroot tree.
5
+ * Template .md files inside those folders serve as reusable stubs (e.g. a City
6
+ * page with headings and placeholder text). An instance is a normal document
7
+ * whose frontmatter contains `template-source: <relative-path-to-template>`.
8
+ *
9
+ * When a template changes, Ursa performs a 3-way merge (git-style) against
10
+ * every instance:
11
+ * base = template body at the time the instance was last synced
12
+ * ours = current instance body (user edits)
13
+ * theirs = new template body
14
+ *
15
+ * If the merge succeeds the instance is updated silently. If it fails,
16
+ * git-style conflict markers are written and the user is warned.
17
+ *
18
+ * Base snapshots live in <sourceRoot>/.ursa/template-bases/ so they survive
19
+ * across builds.
20
+ */
21
+
22
+ import { readFile, writeFile, mkdir } from 'fs/promises';
23
+ import { existsSync } from 'fs';
24
+ import { join, dirname, relative, resolve } from 'path';
25
+ import { merge as diff3Merge } from 'node-diff3';
26
+ import { extractMetadata } from './metadataExtractor.js';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Constants
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export const TEMPLATES_FOLDER = '_templates';
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Path helpers
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * True when any segment of `filePath` is `_templates`.
40
+ * Works with both / and \ separators.
41
+ */
42
+ export function isInsideTemplatesFolder(filePath) {
43
+ return filePath.split(/[/\\]/).includes(TEMPLATES_FOLDER);
44
+ }
45
+
46
+ /**
47
+ * Return the on-disk directory that stores template-base snapshots.
48
+ */
49
+ function templateBasesDir(sourceRoot) {
50
+ return join(sourceRoot.replace(/\/$/, ''), '.ursa', 'template-bases');
51
+ }
52
+
53
+ /**
54
+ * Deterministic filename for a document's stored base snapshot.
55
+ * We encode slashes so everything lives in one flat directory.
56
+ */
57
+ function baseSnapshotPath(sourceRoot, documentRelPath) {
58
+ const safeName = documentRelPath.replace(/[/\\]/g, '__');
59
+ return join(templateBasesDir(sourceRoot), safeName);
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Frontmatter helpers
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /**
67
+ * Extract the raw frontmatter block (including delimiters + trailing newline)
68
+ * from the beginning of a markdown string. Returns '' if there is none.
69
+ */
70
+ export function extractFrontmatterBlock(content) {
71
+ const match = content.match(/^---\r?\n[\s\S]+?\r?\n---(?:\r?\n|$)/);
72
+ return match ? match[0] : '';
73
+ }
74
+
75
+ /**
76
+ * Return everything *after* the frontmatter block.
77
+ */
78
+ export function extractBody(content) {
79
+ return content.slice(extractFrontmatterBlock(content).length);
80
+ }
81
+
82
+ /**
83
+ * Build a YAML frontmatter string from a plain object.
84
+ * Produces `---\nkey: value\n---\n`.
85
+ */
86
+ export function buildFrontmatter(meta) {
87
+ if (!meta || Object.keys(meta).length === 0) return '';
88
+ const lines = Object.entries(meta).map(([k, v]) => {
89
+ if (typeof v === 'string') return `${k}: ${v}`;
90
+ // For non-string values use JSON-compatible representation that YAML also accepts
91
+ return `${k}: ${JSON.stringify(v)}`;
92
+ });
93
+ return `---\n${lines.join('\n')}\n---\n`;
94
+ }
95
+
96
+ /**
97
+ * Ensure the document content has a `template-source` frontmatter field.
98
+ * Preserves any existing frontmatter, adding or updating the field.
99
+ */
100
+ export function ensureTemplateSourceFrontmatter(content, templateRelPath) {
101
+ const fmBlock = extractFrontmatterBlock(content);
102
+ const body = extractBody(content);
103
+
104
+ if (fmBlock) {
105
+ // Frontmatter exists — check if template-source is already there
106
+ if (/^template-source:/m.test(fmBlock)) {
107
+ // Update existing value
108
+ const updated = fmBlock.replace(
109
+ /^template-source:.*$/m,
110
+ `template-source: ${templateRelPath}`
111
+ );
112
+ return updated + body;
113
+ }
114
+ // Insert before closing ---
115
+ const insertPos = fmBlock.lastIndexOf('---');
116
+ const before = fmBlock.slice(0, insertPos);
117
+ const after = fmBlock.slice(insertPos);
118
+ return `${before}template-source: ${templateRelPath}\n${after}${body}`;
119
+ }
120
+
121
+ // No frontmatter at all — create one
122
+ return `---\ntemplate-source: ${templateRelPath}\n---\n${content}`;
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Discovery
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /**
130
+ * From the full list of source files, return a Map of
131
+ * templateRelPath → absolutePath
132
+ * for every .md/.mdx file inside a _templates folder.
133
+ */
134
+ export function findAllDocumentTemplates(allFiles, sourceRoot) {
135
+ const normalizedRoot = sourceRoot.replace(/\/$/, '');
136
+ const templates = new Map();
137
+ for (const file of allFiles) {
138
+ if (isInsideTemplatesFolder(file) && /\.(md|mdx)$/.test(file)) {
139
+ const relPath = relative(normalizedRoot, file);
140
+ templates.set(relPath, file);
141
+ }
142
+ }
143
+ return templates;
144
+ }
145
+
146
+ /**
147
+ * From the full list of source files, return a Map of
148
+ * documentAbsPath → templateRelPath
149
+ * for every document whose frontmatter contains `template-source`.
150
+ *
151
+ * `rawBodyCache` is an optional Map<absPath, string> of already-read file
152
+ * contents to avoid double I/O during the build.
153
+ */
154
+ export async function findTemplatedDocuments(articlePaths, sourceRoot, rawBodyCache) {
155
+ const normalizedRoot = sourceRoot.replace(/\/$/, '');
156
+ const result = new Map(); // docAbsPath → templateRelPath
157
+
158
+ for (const absPath of articlePaths) {
159
+ try {
160
+ // Skip files that are themselves inside _templates
161
+ if (isInsideTemplatesFolder(absPath)) continue;
162
+
163
+ const content = rawBodyCache?.get(absPath) ?? await readFile(absPath, 'utf8');
164
+ const meta = extractMetadata(content);
165
+ const tplSrc = meta?.['template-source'];
166
+ if (tplSrc) {
167
+ result.set(absPath, tplSrc);
168
+ }
169
+ } catch {
170
+ // Unreadable file — skip silently
171
+ }
172
+ }
173
+ return result;
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Base snapshot I/O
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /**
181
+ * Load the stored base snapshot for a document.
182
+ * Returns `null` if no snapshot exists yet.
183
+ */
184
+ export async function loadBaseSnapshot(sourceRoot, documentRelPath) {
185
+ const p = baseSnapshotPath(sourceRoot, documentRelPath);
186
+ if (!existsSync(p)) return null;
187
+ return readFile(p, 'utf8');
188
+ }
189
+
190
+ /**
191
+ * Persist the base snapshot for a document.
192
+ */
193
+ export async function saveBaseSnapshot(sourceRoot, documentRelPath, body) {
194
+ const p = baseSnapshotPath(sourceRoot, documentRelPath);
195
+ await mkdir(dirname(p), { recursive: true });
196
+ await writeFile(p, body, 'utf8');
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // 3-way merge
201
+ // ---------------------------------------------------------------------------
202
+
203
+ /**
204
+ * Perform a git-style 3-way merge.
205
+ *
206
+ * @param {string} base – template body when instance was last synced
207
+ * @param {string} ours – current instance body (user edits)
208
+ * @param {string} theirs – new template body
209
+ * @returns {{ merged: string, conflict: boolean }}
210
+ */
211
+ export function threeWayMerge(base, ours, theirs) {
212
+ const baseLines = base.split('\n');
213
+ const ourLines = ours.split('\n');
214
+ const theirLines = theirs.split('\n');
215
+
216
+ const result = diff3Merge(ourLines, baseLines, theirLines);
217
+
218
+ if (!result.conflict) {
219
+ return { merged: result.result.join('\n'), conflict: false };
220
+ }
221
+
222
+ // Has conflicts — build output with conflict markers
223
+ const lines = [];
224
+ for (const chunk of result.result) {
225
+ if (typeof chunk === 'string') {
226
+ lines.push(chunk);
227
+ }
228
+ }
229
+ // The `merge` function from node-diff3 returns { conflict: bool, result: string[] }
230
+ // When conflict is true, result already contains conflict markers by default
231
+ return { merged: result.result.join('\n'), conflict: true };
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Reconciliation (the main entry point for the build)
236
+ // ---------------------------------------------------------------------------
237
+
238
+ /**
239
+ * Reconcile a single templated document against its parent template.
240
+ *
241
+ * @param {string} docAbsPath – absolute path to the document
242
+ * @param {string} templateAbsPath – absolute path to the template .md
243
+ * @param {string} sourceRoot – docroot (with or without trailing /)
244
+ * @returns {Promise<{ action: string, conflict: boolean, message: string }>}
245
+ * action: 'none' | 'updated' | 'initialized' | 'conflict' | 'error'
246
+ */
247
+ export async function reconcileDocument(docAbsPath, templateAbsPath, sourceRoot) {
248
+ const normalizedRoot = sourceRoot.replace(/\/$/, '');
249
+ const docRelPath = relative(normalizedRoot, docAbsPath);
250
+
251
+ try {
252
+ const [docContent, templateContent] = await Promise.all([
253
+ readFile(docAbsPath, 'utf8'),
254
+ readFile(templateAbsPath, 'utf8'),
255
+ ]);
256
+
257
+ const templateBody = extractBody(templateContent);
258
+ const docBody = extractBody(docContent);
259
+ const docFmBlock = extractFrontmatterBlock(docContent);
260
+
261
+ // Load stored base snapshot
262
+ const storedBase = await loadBaseSnapshot(normalizedRoot, docRelPath);
263
+
264
+ if (storedBase === null) {
265
+ // First encounter — save current template body as the base.
266
+ // No merge needed; the document is already an instance.
267
+ await saveBaseSnapshot(normalizedRoot, docRelPath, templateBody);
268
+ return { action: 'initialized', conflict: false, message: `Initialized base snapshot for ${docRelPath}` };
269
+ }
270
+
271
+ // Check if template actually changed since last sync
272
+ if (storedBase === templateBody) {
273
+ return { action: 'none', conflict: false, message: `Template unchanged for ${docRelPath}` };
274
+ }
275
+
276
+ // Template changed — 3-way merge
277
+ const { merged, conflict } = threeWayMerge(storedBase, docBody, templateBody);
278
+
279
+ // Write the reconciled document (frontmatter + merged body)
280
+ await writeFile(docAbsPath, docFmBlock + merged, 'utf8');
281
+
282
+ // Update the base snapshot to the new template body
283
+ // (even on conflict — the user will resolve, and next run should be clean)
284
+ await saveBaseSnapshot(normalizedRoot, docRelPath, templateBody);
285
+
286
+ if (conflict) {
287
+ return { action: 'conflict', conflict: true, message: `Conflict in ${docRelPath} — manual resolution required` };
288
+ }
289
+ return { action: 'updated', conflict: false, message: `Auto-merged template changes into ${docRelPath}` };
290
+ } catch (e) {
291
+ return { action: 'error', conflict: false, message: `Error reconciling ${docRelPath}: ${e.message}` };
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Reconcile ALL templated documents in the source tree.
297
+ *
298
+ * Returns a summary object:
299
+ * { initialized, updated, conflicts, unchanged, errors, affectedPaths }
300
+ *
301
+ * `affectedPaths` is the set of absolute document paths that were written to
302
+ * disk (so the caller knows which files to regenerate).
303
+ */
304
+ export async function reconcileAll(articlePaths, allFiles, sourceRoot) {
305
+ const normalizedRoot = sourceRoot.replace(/\/$/, '');
306
+
307
+ // 1. Discover templates and templated documents
308
+ const templateMap = findAllDocumentTemplates(allFiles, normalizedRoot);
309
+ const templatedDocs = await findTemplatedDocuments(articlePaths, normalizedRoot);
310
+
311
+ const summary = {
312
+ initialized: 0,
313
+ updated: 0,
314
+ conflicts: 0,
315
+ unchanged: 0,
316
+ errors: 0,
317
+ affectedPaths: new Set(),
318
+ messages: [],
319
+ };
320
+
321
+ if (templatedDocs.size === 0) {
322
+ return summary;
323
+ }
324
+
325
+ // 2. For each templated document, reconcile
326
+ for (const [docAbsPath, templateRelPath] of templatedDocs) {
327
+ const templateAbsPath = templateMap.get(templateRelPath)
328
+ ?? resolve(normalizedRoot, templateRelPath);
329
+
330
+ if (!existsSync(templateAbsPath)) {
331
+ summary.errors++;
332
+ summary.messages.push(`Template not found: ${templateRelPath} (referenced by ${relative(normalizedRoot, docAbsPath)})`);
333
+ continue;
334
+ }
335
+
336
+ const result = await reconcileDocument(docAbsPath, templateAbsPath, normalizedRoot);
337
+ summary.messages.push(result.message);
338
+
339
+ switch (result.action) {
340
+ case 'initialized':
341
+ summary.initialized++;
342
+ break;
343
+ case 'updated':
344
+ summary.updated++;
345
+ summary.affectedPaths.add(docAbsPath);
346
+ break;
347
+ case 'conflict':
348
+ summary.conflicts++;
349
+ summary.affectedPaths.add(docAbsPath);
350
+ break;
351
+ case 'none':
352
+ summary.unchanged++;
353
+ break;
354
+ case 'error':
355
+ summary.errors++;
356
+ break;
357
+ }
358
+ }
359
+
360
+ return summary;
361
+ }
362
+
363
+ /**
364
+ * Reconcile only documents that reference a specific template.
365
+ * Used by serve mode when a single template file changes.
366
+ *
367
+ * @param {string} changedTemplateAbsPath – the template that was saved
368
+ * @param {string[]} articlePaths – all known article paths
369
+ * @param {string} sourceRoot – docroot
370
+ * @returns same shape as reconcileAll's summary
371
+ */
372
+ export async function reconcileByTemplate(changedTemplateAbsPath, articlePaths, sourceRoot) {
373
+ const normalizedRoot = sourceRoot.replace(/\/$/, '');
374
+ const templateRelPath = relative(normalizedRoot, changedTemplateAbsPath);
375
+
376
+ const summary = {
377
+ initialized: 0,
378
+ updated: 0,
379
+ conflicts: 0,
380
+ unchanged: 0,
381
+ errors: 0,
382
+ affectedPaths: new Set(),
383
+ messages: [],
384
+ };
385
+
386
+ // Find documents that reference this specific template
387
+ for (const docPath of articlePaths) {
388
+ if (isInsideTemplatesFolder(docPath)) continue;
389
+ try {
390
+ const content = await readFile(docPath, 'utf8');
391
+ const meta = extractMetadata(content);
392
+ if (meta?.['template-source'] !== templateRelPath) continue;
393
+
394
+ const result = await reconcileDocument(docPath, changedTemplateAbsPath, normalizedRoot);
395
+ summary.messages.push(result.message);
396
+
397
+ switch (result.action) {
398
+ case 'initialized': summary.initialized++; break;
399
+ case 'updated':
400
+ summary.updated++;
401
+ summary.affectedPaths.add(docPath);
402
+ break;
403
+ case 'conflict':
404
+ summary.conflicts++;
405
+ summary.affectedPaths.add(docPath);
406
+ break;
407
+ case 'none': summary.unchanged++; break;
408
+ case 'error': summary.errors++; break;
409
+ }
410
+ } catch {
411
+ // skip unreadable
412
+ }
413
+ }
414
+
415
+ return summary;
416
+ }
417
+
418
+ // ---------------------------------------------------------------------------
419
+ // Template instantiation (CLI helper)
420
+ // ---------------------------------------------------------------------------
421
+
422
+ /**
423
+ * Create a new document from a template.
424
+ *
425
+ * @param {string} templateAbsPath – absolute path to the template .md file
426
+ * @param {string} destAbsPath – absolute path for the new document
427
+ * @param {string} sourceRoot – docroot
428
+ * @returns {{ templateRelPath: string, destRelPath: string }}
429
+ */
430
+ export async function instantiateTemplate(templateAbsPath, destAbsPath, sourceRoot) {
431
+ const normalizedRoot = sourceRoot.replace(/\/$/, '');
432
+ const templateRelPath = relative(normalizedRoot, templateAbsPath);
433
+ const destRelPath = relative(normalizedRoot, destAbsPath);
434
+
435
+ if (existsSync(destAbsPath)) {
436
+ throw new Error(`Destination already exists: ${destAbsPath}`);
437
+ }
438
+
439
+ const templateContent = await readFile(templateAbsPath, 'utf8');
440
+ const templateBody = extractBody(templateContent);
441
+
442
+ // Build instance content: original template frontmatter + template-source + body
443
+ const templateMeta = extractMetadata(templateContent);
444
+ const instanceMeta = { ...(templateMeta || {}), 'template-source': templateRelPath };
445
+ const instanceContent = buildFrontmatter(instanceMeta) + templateBody;
446
+
447
+ await mkdir(dirname(destAbsPath), { recursive: true });
448
+ await writeFile(destAbsPath, instanceContent, 'utf8');
449
+
450
+ // Save the base snapshot so future reconciliation works
451
+ await saveBaseSnapshot(normalizedRoot, destRelPath, templateBody);
452
+
453
+ return { templateRelPath, destRelPath };
454
+ }