@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.
- package/CHANGELOG.md +34 -0
- package/bin/ursa.js +23 -36
- package/meta/templates/default-template/default.css +5 -0
- package/package.json +2 -1
- package/src/dev.js +16 -1
- package/src/helper/__test__/dependencyTracker.test.js +157 -0
- package/src/helper/__test__/documentTemplates.test.js +354 -0
- package/src/helper/automenu.js +1 -1
- package/src/helper/build/__test__/autoIndex.test.js +95 -0
- package/src/helper/build/__test__/graph.test.js +529 -0
- package/src/helper/build/autoIndex.js +7 -1
- package/src/helper/build/graph.js +542 -0
- package/src/helper/build/index.js +1 -0
- package/src/helper/build/ursaMetadata.js +62 -0
- package/src/helper/dependencyTracker.js +116 -1
- package/src/helper/documentTemplates.js +454 -0
- package/src/jobs/generate.js +83 -6
- package/src/serve.js +66 -10
|
@@ -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
|
-
|
|
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
|
+
}
|