@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.
@@ -0,0 +1,354 @@
1
+ import { join } from 'path';
2
+ import { mkdtemp, writeFile, readFile, mkdir, rm } from 'fs/promises';
3
+ import { tmpdir } from 'os';
4
+ import {
5
+ isInsideTemplatesFolder,
6
+ extractFrontmatterBlock,
7
+ extractBody,
8
+ buildFrontmatter,
9
+ ensureTemplateSourceFrontmatter,
10
+ findAllDocumentTemplates,
11
+ threeWayMerge,
12
+ instantiateTemplate,
13
+ reconcileDocument,
14
+ reconcileAll,
15
+ loadBaseSnapshot,
16
+ } from '../documentTemplates.js';
17
+
18
+ // Helper: create a temp directory for each test
19
+ let tempDir;
20
+ beforeEach(async () => {
21
+ tempDir = await mkdtemp(join(tmpdir(), 'ursa-doctemplate-'));
22
+ });
23
+ afterEach(async () => {
24
+ await rm(tempDir, { recursive: true, force: true });
25
+ });
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // isInsideTemplatesFolder
29
+ // ---------------------------------------------------------------------------
30
+ describe('isInsideTemplatesFolder', () => {
31
+ it('detects _templates as a path segment', () => {
32
+ expect(isInsideTemplatesFolder('/site/docs/_templates/city.md')).toBe(true);
33
+ expect(isInsideTemplatesFolder('_templates/foo.md')).toBe(true);
34
+ expect(isInsideTemplatesFolder('/a/b/_templates/c/d.md')).toBe(true);
35
+ });
36
+
37
+ it('returns false for normal paths', () => {
38
+ expect(isInsideTemplatesFolder('/site/docs/city.md')).toBe(false);
39
+ expect(isInsideTemplatesFolder('/site/my_templates/city.md')).toBe(false);
40
+ });
41
+ });
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Frontmatter helpers
45
+ // ---------------------------------------------------------------------------
46
+ describe('extractFrontmatterBlock / extractBody', () => {
47
+ it('extracts frontmatter from a standard document', () => {
48
+ const content = '---\ntitle: Hello\n---\nBody here';
49
+ expect(extractFrontmatterBlock(content)).toBe('---\ntitle: Hello\n---\n');
50
+ expect(extractBody(content)).toBe('Body here');
51
+ });
52
+
53
+ it('returns empty string when no frontmatter', () => {
54
+ const content = 'Just body content';
55
+ expect(extractFrontmatterBlock(content)).toBe('');
56
+ expect(extractBody(content)).toBe('Just body content');
57
+ });
58
+ });
59
+
60
+ describe('buildFrontmatter', () => {
61
+ it('builds YAML frontmatter from an object', () => {
62
+ const result = buildFrontmatter({ title: 'My City', type: 'city' });
63
+ expect(result).toBe('---\ntitle: My City\ntype: city\n---\n');
64
+ });
65
+
66
+ it('returns empty string for null/empty', () => {
67
+ expect(buildFrontmatter(null)).toBe('');
68
+ expect(buildFrontmatter({})).toBe('');
69
+ });
70
+ });
71
+
72
+ describe('ensureTemplateSourceFrontmatter', () => {
73
+ it('adds frontmatter when none exists', () => {
74
+ const result = ensureTemplateSourceFrontmatter('Body text', '_templates/city.md');
75
+ expect(result).toContain('template-source: _templates/city.md');
76
+ expect(result).toContain('Body text');
77
+ });
78
+
79
+ it('inserts into existing frontmatter', () => {
80
+ const content = '---\ntitle: Springfield\n---\nBody';
81
+ const result = ensureTemplateSourceFrontmatter(content, '_templates/city.md');
82
+ expect(result).toContain('template-source: _templates/city.md');
83
+ expect(result).toContain('title: Springfield');
84
+ expect(result).toContain('Body');
85
+ });
86
+
87
+ it('updates existing template-source value', () => {
88
+ const content = '---\ntemplate-source: old/path.md\ntitle: X\n---\nBody';
89
+ const result = ensureTemplateSourceFrontmatter(content, '_templates/new.md');
90
+ expect(result).toContain('template-source: _templates/new.md');
91
+ expect(result).not.toContain('old/path.md');
92
+ });
93
+ });
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Discovery
97
+ // ---------------------------------------------------------------------------
98
+ describe('findAllDocumentTemplates', () => {
99
+ it('finds .md files inside _templates folders', () => {
100
+ const files = [
101
+ '/src/docs/_templates/city.md',
102
+ '/src/docs/_templates/character.md',
103
+ '/src/docs/places/springfield.md',
104
+ '/src/docs/deep/path/_templates/quest.mdx',
105
+ ];
106
+ const result = findAllDocumentTemplates(files, '/src/docs/');
107
+ expect(result.size).toBe(3);
108
+ expect(result.has('_templates/city.md')).toBe(true);
109
+ expect(result.has('_templates/character.md')).toBe(true);
110
+ expect(result.has('deep/path/_templates/quest.mdx')).toBe(true);
111
+ });
112
+
113
+ it('ignores non-markdown files in _templates', () => {
114
+ const files = ['/src/_templates/style.css', '/src/_templates/image.png'];
115
+ const result = findAllDocumentTemplates(files, '/src/');
116
+ expect(result.size).toBe(0);
117
+ });
118
+ });
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // 3-way merge
122
+ // ---------------------------------------------------------------------------
123
+ describe('threeWayMerge', () => {
124
+ it('cleanly merges non-conflicting changes', () => {
125
+ const base = 'line 1\nline 2\nline 3';
126
+ const ours = 'line 1\nline 2 modified\nline 3'; // user edited line 2
127
+ const theirs = 'line 1\nline 2\nline 3\nline 4 new'; // template added line 4
128
+
129
+ const { merged, conflict } = threeWayMerge(base, ours, theirs);
130
+ expect(conflict).toBe(false);
131
+ expect(merged).toContain('line 2 modified');
132
+ expect(merged).toContain('line 4 new');
133
+ });
134
+
135
+ it('returns conflict markers when both sides edit the same line', () => {
136
+ const base = 'line 1\nline 2\nline 3';
137
+ const ours = 'line 1\nours version\nline 3';
138
+ const theirs = 'line 1\ntheirs version\nline 3';
139
+
140
+ const { merged, conflict } = threeWayMerge(base, ours, theirs);
141
+ expect(conflict).toBe(true);
142
+ expect(merged).toContain('<<<<<<<');
143
+ expect(merged).toContain('>>>>>>>');
144
+ });
145
+
146
+ it('handles identical changes on both sides (no conflict)', () => {
147
+ const base = 'line 1\nline 2\nline 3';
148
+ const ours = 'line 1\nline 2 same change\nline 3';
149
+ const theirs = 'line 1\nline 2 same change\nline 3';
150
+
151
+ const { merged, conflict } = threeWayMerge(base, ours, theirs);
152
+ expect(conflict).toBe(false);
153
+ expect(merged).toBe('line 1\nline 2 same change\nline 3');
154
+ });
155
+ });
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // instantiateTemplate (filesystem)
159
+ // ---------------------------------------------------------------------------
160
+ describe('instantiateTemplate', () => {
161
+ it('creates a document from a template with template-source frontmatter', async () => {
162
+ // Setup: create a template file
163
+ const tplDir = join(tempDir, '_templates');
164
+ await mkdir(tplDir, { recursive: true });
165
+ const tplPath = join(tplDir, 'city.md');
166
+ await writeFile(tplPath, '---\ntype: city\n---\n# {City Name}\n\n## Geography\n');
167
+
168
+ const destPath = join(tempDir, 'places', 'springfield.md');
169
+ const result = await instantiateTemplate(tplPath, destPath, tempDir);
170
+
171
+ expect(result.templateRelPath).toBe('_templates/city.md');
172
+ expect(result.destRelPath).toBe(join('places', 'springfield.md'));
173
+
174
+ // Check the created file
175
+ const content = await readFile(destPath, 'utf8');
176
+ expect(content).toContain('template-source: _templates/city.md');
177
+ expect(content).toContain('type: city');
178
+ expect(content).toContain('# {City Name}');
179
+ expect(content).toContain('## Geography');
180
+ });
181
+
182
+ it('creates frontmatter even if the template has none', async () => {
183
+ const tplDir = join(tempDir, '_templates');
184
+ await mkdir(tplDir, { recursive: true });
185
+ const tplPath = join(tplDir, 'bare.md');
186
+ await writeFile(tplPath, '# Simple Template\n\nContent here.\n');
187
+
188
+ const destPath = join(tempDir, 'pages', 'new.md');
189
+ await instantiateTemplate(tplPath, destPath, tempDir);
190
+
191
+ const content = await readFile(destPath, 'utf8');
192
+ expect(content).toContain('---');
193
+ expect(content).toContain('template-source: _templates/bare.md');
194
+ expect(content).toContain('# Simple Template');
195
+ });
196
+
197
+ it('throws if destination already exists', async () => {
198
+ const tplDir = join(tempDir, '_templates');
199
+ await mkdir(tplDir, { recursive: true });
200
+ const tplPath = join(tplDir, 'city.md');
201
+ await writeFile(tplPath, '# Template');
202
+
203
+ const destPath = join(tempDir, 'existing.md');
204
+ await writeFile(destPath, 'already here');
205
+
206
+ await expect(instantiateTemplate(tplPath, destPath, tempDir)).rejects.toThrow(
207
+ /already exists/
208
+ );
209
+ });
210
+
211
+ it('saves a base snapshot for future reconciliation', async () => {
212
+ const tplDir = join(tempDir, '_templates');
213
+ await mkdir(tplDir, { recursive: true });
214
+ const tplPath = join(tplDir, 'city.md');
215
+ await writeFile(tplPath, '---\ntype: city\n---\n# {City Name}\n');
216
+
217
+ const destPath = join(tempDir, 'places', 'springfield.md');
218
+ const result = await instantiateTemplate(tplPath, destPath, tempDir);
219
+
220
+ const base = await loadBaseSnapshot(tempDir, result.destRelPath);
221
+ expect(base).toBe('# {City Name}\n');
222
+ });
223
+ });
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // reconcileDocument (filesystem)
227
+ // ---------------------------------------------------------------------------
228
+ describe('reconcileDocument', () => {
229
+ it('initializes a base snapshot on first encounter', async () => {
230
+ // Setup: template + document that references it, but no base snapshot yet
231
+ const tplDir = join(tempDir, '_templates');
232
+ await mkdir(tplDir, { recursive: true });
233
+ const tplPath = join(tplDir, 'city.md');
234
+ await writeFile(tplPath, '# {City Name}\n\n## Geography\n');
235
+
236
+ const docPath = join(tempDir, 'springfield.md');
237
+ await writeFile(docPath, '---\ntemplate-source: _templates/city.md\n---\n# Springfield\n\n## Geography\nFlat plains.\n');
238
+
239
+ const result = await reconcileDocument(docPath, tplPath, tempDir);
240
+ expect(result.action).toBe('initialized');
241
+ });
242
+
243
+ it('returns "none" when template has not changed', async () => {
244
+ const tplDir = join(tempDir, '_templates');
245
+ await mkdir(tplDir, { recursive: true });
246
+ const tplPath = join(tplDir, 'city.md');
247
+ const tplBody = '# {City Name}\n\n## Geography\n';
248
+ await writeFile(tplPath, tplBody);
249
+
250
+ const destPath = join(tempDir, 'springfield.md');
251
+ // Instantiate first so base is saved
252
+ await instantiateTemplate(tplPath, destPath, tempDir);
253
+
254
+ // Now reconcile without changing the template
255
+ const result = await reconcileDocument(destPath, tplPath, tempDir);
256
+ expect(result.action).toBe('none');
257
+ });
258
+
259
+ it('auto-merges when template and document change different parts', async () => {
260
+ const tplDir = join(tempDir, '_templates');
261
+ await mkdir(tplDir, { recursive: true });
262
+ const tplPath = join(tplDir, 'city.md');
263
+ await writeFile(tplPath, '# {City Name}\n\n## Geography\n\n## History\n');
264
+
265
+ const destPath = join(tempDir, 'springfield.md');
266
+ await instantiateTemplate(tplPath, destPath, tempDir);
267
+
268
+ // User modifies the document (fills in Geography)
269
+ const docContent = await readFile(destPath, 'utf8');
270
+ const edited = docContent.replace('## Geography', '## Geography\nFlat plains and rivers.');
271
+ await writeFile(destPath, edited);
272
+
273
+ // Template adds a new section at the end
274
+ await writeFile(tplPath, '# {City Name}\n\n## Geography\n\n## History\n\n## Notable People\n');
275
+
276
+ const result = await reconcileDocument(destPath, tplPath, tempDir);
277
+ expect(result.action).toBe('updated');
278
+ expect(result.conflict).toBe(false);
279
+
280
+ // Verify the merged document has both changes
281
+ const merged = await readFile(destPath, 'utf8');
282
+ expect(merged).toContain('Flat plains and rivers.');
283
+ expect(merged).toContain('## Notable People');
284
+ });
285
+
286
+ it('writes conflict markers when both sides edit the same section', async () => {
287
+ const tplDir = join(tempDir, '_templates');
288
+ await mkdir(tplDir, { recursive: true });
289
+ const tplPath = join(tplDir, 'city.md');
290
+ await writeFile(tplPath, '# {City Name}\n\n## Geography\nDescribe here.\n');
291
+
292
+ const destPath = join(tempDir, 'springfield.md');
293
+ await instantiateTemplate(tplPath, destPath, tempDir);
294
+
295
+ // User changes "Describe here." to their own text
296
+ const docContent = await readFile(destPath, 'utf8');
297
+ await writeFile(destPath, docContent.replace('Describe here.', 'User wrote this.'));
298
+
299
+ // Template also changes "Describe here." to something else
300
+ await writeFile(tplPath, '# {City Name}\n\n## Geography\nTemplate says this instead.\n');
301
+
302
+ const result = await reconcileDocument(destPath, tplPath, tempDir);
303
+ expect(result.action).toBe('conflict');
304
+ expect(result.conflict).toBe(true);
305
+
306
+ const merged = await readFile(destPath, 'utf8');
307
+ expect(merged).toContain('<<<<<<<');
308
+ expect(merged).toContain('>>>>>>>');
309
+ });
310
+ });
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // reconcileAll (filesystem)
314
+ // ---------------------------------------------------------------------------
315
+ describe('reconcileAll', () => {
316
+ it('reconciles multiple documents at once', async () => {
317
+ const tplDir = join(tempDir, '_templates');
318
+ await mkdir(tplDir, { recursive: true });
319
+ const tplPath = join(tplDir, 'city.md');
320
+ await writeFile(tplPath, '# {City Name}\n\n## Geography\n');
321
+
322
+ // Create two instances
323
+ const doc1 = join(tempDir, 'springfield.md');
324
+ const doc2 = join(tempDir, 'shelbyville.md');
325
+ await instantiateTemplate(tplPath, doc1, tempDir);
326
+ await instantiateTemplate(tplPath, doc2, tempDir);
327
+
328
+ // Change template
329
+ await writeFile(tplPath, '# {City Name}\n\n## Geography\n\n## History\n');
330
+
331
+ const allFiles = [tplPath, doc1, doc2];
332
+ const articlePaths = [doc1, doc2];
333
+
334
+ const summary = await reconcileAll(articlePaths, allFiles, tempDir);
335
+ expect(summary.updated).toBe(2);
336
+ expect(summary.conflicts).toBe(0);
337
+ expect(summary.affectedPaths.size).toBe(2);
338
+
339
+ // Verify both docs now have the new section
340
+ const content1 = await readFile(doc1, 'utf8');
341
+ const content2 = await readFile(doc2, 'utf8');
342
+ expect(content1).toContain('## History');
343
+ expect(content2).toContain('## History');
344
+ });
345
+
346
+ it('returns zeroes when there are no templated documents', async () => {
347
+ const doc = join(tempDir, 'plain.md');
348
+ await writeFile(doc, '# Just a regular doc');
349
+
350
+ const summary = await reconcileAll([doc], [doc], tempDir);
351
+ expect(summary.updated).toBe(0);
352
+ expect(summary.initialized).toBe(0);
353
+ });
354
+ });
@@ -449,7 +449,7 @@ function collapseSingleDocFolders(items) {
449
449
 
450
450
  export async function getAutomenu(source, validPaths) {
451
451
  const tree = dirTree(source, {
452
- exclude: /[\/\\]\.|node_modules/, // Exclude hidden folders (starting with .) and node_modules
452
+ exclude: /[\/\\]\.|node_modules|_templates/, // Exclude hidden folders (starting with .), node_modules, and _templates
453
453
  });
454
454
 
455
455
  // Build menu data WITHOUT debug fields for smaller JSON
@@ -0,0 +1,95 @@
1
+ import { join } from "path";
2
+ import { mkdtemp, mkdir, writeFile, rm, readFile } from "fs/promises";
3
+ import { existsSync } from "fs";
4
+ import { tmpdir } from "os";
5
+ import { generateAutoIndices } from "../autoIndex.js";
6
+
7
+ let tempDir;
8
+ let source;
9
+ let output;
10
+ beforeEach(async () => {
11
+ tempDir = await mkdtemp(join(tmpdir(), "ursa-autoindex-"));
12
+ source = join(tempDir, "source");
13
+ output = join(tempDir, "output");
14
+ await mkdir(source, { recursive: true });
15
+ await mkdir(output, { recursive: true });
16
+ });
17
+ afterEach(async () => {
18
+ await rm(tempDir, { recursive: true, force: true });
19
+ });
20
+
21
+ const TEMPLATE =
22
+ "<html><head>${styleLink}</head><body>${menu}${body}${footer}${customScript}</body></html>";
23
+
24
+ function makeProgress() {
25
+ const logs = [];
26
+ return {
27
+ logs,
28
+ log: (msg) => logs.push(msg),
29
+ status: () => {},
30
+ done: () => {},
31
+ };
32
+ }
33
+
34
+ function runAutoIndices(directories, generatedArticles, progress) {
35
+ return generateAutoIndices(
36
+ output,
37
+ directories,
38
+ source,
39
+ { "default-template": TEMPLATE },
40
+ "",
41
+ "",
42
+ generatedArticles,
43
+ new Set(),
44
+ new Set(),
45
+ "20260101000000",
46
+ progress,
47
+ null
48
+ );
49
+ }
50
+
51
+ describe("generateAutoIndices with empty source folders", () => {
52
+ it("skips output directories that were never created instead of logging an error", async () => {
53
+ // Source has an empty folder (guides) and a folder with a document (docs).
54
+ // Only docs produced output files, so output/guides does not exist.
55
+ await mkdir(join(source, "guides"));
56
+ await mkdir(join(source, "docs"));
57
+ await writeFile(join(source, "docs", "hello.md"), "# Hello\n\nWorld\n");
58
+ await mkdir(join(output, "docs"));
59
+ await writeFile(join(output, "docs", "hello.html"), "<html><body>Hello</body></html>");
60
+
61
+ const progress = makeProgress();
62
+ await runAutoIndices(
63
+ [join(source, "guides"), join(source, "docs")],
64
+ [join(source, "docs", "hello.md")],
65
+ progress
66
+ );
67
+
68
+ const errors = progress.logs.filter((m) => /Error generating auto-index/i.test(m));
69
+ expect(errors).toEqual([]);
70
+ // The missing output directory is skipped, not created
71
+ expect(existsSync(join(output, "guides"))).toBe(false);
72
+ expect(existsSync(join(output, "guides", "index.html"))).toBe(false);
73
+ });
74
+
75
+ it("still generates auto-indices for folders that produced output", async () => {
76
+ await mkdir(join(source, "guides"));
77
+ await mkdir(join(source, "docs"));
78
+ await writeFile(join(source, "docs", "hello.md"), "# Hello\n\nWorld\n");
79
+ await mkdir(join(output, "docs"));
80
+ await writeFile(join(output, "docs", "hello.html"), "<html><body>Hello</body></html>");
81
+
82
+ const progress = makeProgress();
83
+ await runAutoIndices(
84
+ [join(source, "guides"), join(source, "docs")],
85
+ [join(source, "docs", "hello.md")],
86
+ progress
87
+ );
88
+
89
+ // Root and docs both exist in output, so both get an index.html
90
+ const docsIndex = await readFile(join(output, "docs", "index.html"), "utf8");
91
+ expect(docsIndex).toContain('<a href="hello.html">');
92
+ const rootIndex = await readFile(join(output, "index.html"), "utf8");
93
+ expect(rootIndex).toContain('<a href="docs/index.html">');
94
+ });
95
+ });