@kenjura/ursa 0.84.0 → 0.86.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 CHANGED
@@ -1,3 +1,14 @@
1
+ # 0.86.0
2
+ 2026-05-18
3
+
4
+ - small CSS fix
5
+
6
+ # 0.85.0
7
+ 2026-05-15
8
+
9
+ - deflists in MDX
10
+
11
+
1
12
  # 0.84.0
2
13
  2026-05-08
3
14
 
package/bin/ursa.js CHANGED
@@ -6,6 +6,7 @@ import { generate } from '../src/jobs/generate.js';
6
6
  import { resolve, dirname, join } from 'path';
7
7
  import { fileURLToPath } from 'url';
8
8
  import { stagePromotedChangelog, registerCleanupOnExit } from '../src/helper/promoteChangelog.js';
9
+ import { instantiateTemplate } from '../src/helper/documentTemplates.js';
9
10
 
10
11
  // Get the directory where ursa is installed
11
12
  const __filename = fileURLToPath(import.meta.url);
@@ -14,7 +15,7 @@ const PACKAGE_META = join(__dirname, '..', 'meta');
14
15
 
15
16
  yargs(hideBin(process.argv))
16
17
  .command(
17
- 'generate <source>',
18
+ ['generate <source>', '$0 <source>'],
18
19
  'Generate a static site from source files',
19
20
  (yargs) => {
20
21
  return yargs
@@ -235,54 +236,40 @@ yargs(hideBin(process.argv))
235
236
  }
236
237
  )
237
238
  .command(
238
- '$0 <source>',
239
- 'Generate a static site from source files (default command)',
239
+ 'template <source> <templatePath> <destination>',
240
+ 'Create a new document from a document template',
240
241
  (yargs) => {
241
242
  return yargs
242
243
  .positional('source', {
243
- describe: 'Source directory containing markdown/wikitext files',
244
+ describe: 'Source directory (docroot) of the Ursa site',
244
245
  type: 'string',
245
246
  demandOption: true
246
247
  })
247
- .option('meta', {
248
- alias: 'm',
249
- default: 'meta',
250
- describe: 'Meta directory containing templates and styles',
251
- type: 'string'
252
- })
253
- .option('output', {
254
- alias: 'o',
255
- default: 'output',
256
- describe: 'Output directory for generated site',
257
- type: 'string'
248
+ .positional('templatePath', {
249
+ describe: 'Path to the template file (relative to source, e.g. _templates/city.md)',
250
+ type: 'string',
251
+ demandOption: true
258
252
  })
259
- .option('whitelist', {
260
- alias: 'w',
261
- describe: 'Path to whitelist file containing patterns for files to include',
262
- type: 'string'
253
+ .positional('destination', {
254
+ describe: 'Path for the new document (relative to source, e.g. places/springfield.md)',
255
+ type: 'string',
256
+ demandOption: true
263
257
  });
264
258
  },
265
259
  async (argv) => {
266
260
  const source = resolve(argv.source);
267
- const meta = resolve(argv.meta);
268
- const output = resolve(argv.output);
269
- const whitelist = argv.whitelist ? resolve(argv.whitelist) : null;
270
-
271
- console.log(`Generating site from ${source} to ${output} using meta from ${meta}`);
272
- if (whitelist) {
273
- console.log(`Using whitelist: ${whitelist}`);
274
- }
275
-
261
+ const templateAbsPath = resolve(source, argv.templatePath);
262
+ const destAbsPath = resolve(source, argv.destination);
263
+
276
264
  try {
277
- await generate({
278
- _source: source,
279
- _meta: meta,
280
- _output: output,
281
- _whitelist: whitelist
282
- });
283
- console.log('Site generation completed successfully!');
265
+ const { templateRelPath, destRelPath } = await instantiateTemplate(
266
+ templateAbsPath,
267
+ destAbsPath,
268
+ source
269
+ );
270
+ console.log(`āœ… Created ${destRelPath} from template ${templateRelPath}`);
284
271
  } catch (error) {
285
- console.error('Error generating site:', error.message);
272
+ console.error(`Error creating template instance: ${error.message}`);
286
273
  process.exit(1);
287
274
  }
288
275
  }
@@ -50,6 +50,26 @@ h1 {
50
50
  text-overflow: ellipsis;
51
51
  }
52
52
 
53
+ h1,h2,h3 {
54
+ clear: both;
55
+ }
56
+
57
+ figure {
58
+ margin: 0.5rem 0;
59
+ padding: 0;
60
+ max-width: 300px;
61
+ float: left;
62
+ clear: both;
63
+ margin-right: 1.5rem;
64
+ margin-bottom: 0.5rem;
65
+
66
+ &:nth-of-type(even) {
67
+ float: right;
68
+ margin-left: 1.5rem;
69
+ margin-right: 0;
70
+ }
71
+ }
72
+
53
73
  nav#nav-global {
54
74
  background-color: var(--nav-top-bg);
55
75
  height: var(--global-nav-height);
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@kenjura/ursa",
3
3
  "author": "Andrew London <andrew@kenjura.com>",
4
4
  "type": "module",
5
- "version": "0.84.0",
5
+ "version": "0.86.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
@@ -35,6 +35,7 @@
35
35
  "markdown-it-front-matter": "^0.2.3",
36
36
  "markdown-it-sup": "^1.0.0",
37
37
  "mdx-bundler": "^10.1.1",
38
+ "node-diff3": "^3.2.0",
38
39
  "node-watch": "^0.7.3",
39
40
  "object-to-xml": "^2.0.0",
40
41
  "react": "^19.2.4",
package/src/dev.js CHANGED
@@ -569,10 +569,12 @@ async function wrapInTemplate(body, title, fileMeta, urlPath, sourcePath, hydrat
569
569
  // Resolve relative URLs
570
570
  finalHtml = resolveRelativeUrls(finalHtml, docUrlPath);
571
571
 
572
- // Mark inactive links (only if validPaths is ready)
573
- if (validPaths) {
574
- finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
575
- }
572
+ // Mark inactive links + rewrite .md/.mdx → .html. We always run this even
573
+ // before validPaths is ready, because the optimistic .md→.html conversion
574
+ // inside resolveHref does not require validPaths. Without this, links to
575
+ // markdown files served before background caches finish would not be
576
+ // rewritten and the browser would request the source .md file directly.
577
+ finalHtml = markInactiveLinks(finalHtml, validPaths || new Map(), docUrlPath, false);
576
578
 
577
579
  return finalHtml;
578
580
  }
@@ -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,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
+ }
@@ -12,6 +12,25 @@ import remarkSupersub from "remark-supersub";
12
12
  import remarkGfm from "remark-gfm";
13
13
  import { visit } from "unist-util-visit";
14
14
 
15
+ /**
16
+ * remark-definition-list only registers the `:` (charcode 58) marker, but
17
+ * PHP Markdown Extra (and markdown-it-deflist) also support `~` (charcode 126).
18
+ * This sibling plugin must run AFTER remarkDefinitionList; it locates the
19
+ * already-registered defList construct and re-registers it under `~` so that
20
+ * .mdx files have parity with .md files.
21
+ */
22
+ function remarkDefinitionListTildeMarker() {
23
+ const data = this.data();
24
+ const micromarkExtensions = data.micromarkExtensions ?? (data.micromarkExtensions = []);
25
+ for (const ext of micromarkExtensions) {
26
+ const construct = ext?.document?.[58];
27
+ if (construct) {
28
+ micromarkExtensions.push({ document: { 126: construct } });
29
+ return;
30
+ }
31
+ }
32
+ }
33
+
15
34
  /**
16
35
  * Custom remark plugin that converts container directives (:::name ... :::)
17
36
  * into <aside> HTML elements, matching the markdown-it-container behavior
@@ -122,6 +141,7 @@ export async function renderMDX({ source, filePath, sourceRoot, hydrate = false
122
141
  remarkDirective,
123
142
  remarkAsideContainers,
124
143
  remarkDefinitionList,
144
+ remarkDefinitionListTildeMarker,
125
145
  remarkSupersub,
126
146
  ];
127
147
  // remark-definition-list needs custom handlers for remark-rehype conversion
@@ -79,6 +79,7 @@ import {
79
79
  copyMetaAssets,
80
80
  } from "../helper/build/index.js";
81
81
  import { getProfiler } from "../helper/build/profiler.js";
82
+ import { reconcileAll, isInsideTemplatesFolder } from "../helper/documentTemplates.js";
82
83
 
83
84
  // Concurrency limiter for batch processing to avoid memory exhaustion
84
85
  const BATCH_SIZE = parseInt(process.env.URSA_BATCH_SIZE || '50', 10);
@@ -210,7 +211,7 @@ export async function generate({
210
211
 
211
212
  // read all articles, process them, copy them to build
212
213
  const articleExtensions = /\.(md|mdx|txt|yml)$/;
213
- const hiddenOrSystemDirs = /[\/\\]\.(?!\.)|[\/\\]node_modules[\/\\]/; // Matches hidden folders (starting with .) or node_modules
214
+ const hiddenOrSystemDirs = /[\/\\]\.(?!\.)|[\/\\]node_modules[\/\\]|[\/\\]_templates[\/\\]|[\/\\]_templates$/; // Matches hidden folders (starting with .), node_modules, or _templates
214
215
  const allSourceFilenamesThatAreArticles = allSourceFilenames.filter(
215
216
  (filename) => filename.match(articleExtensions) && !filename.match(hiddenOrSystemDirs) && !isInHiddenFolder(filename)
216
217
  );
@@ -230,6 +231,40 @@ export async function generate({
230
231
  progress.logTimed(`Classified: ${allSourceFilenamesThatAreArticles.length} articles, ${allSourceFilenamesThatAreDirectories.length} dirs, ${existingHtmlFiles.size} HTML [${progress.stopTimer('Filter')}]`);
231
232
  profiler.endPhase('Filter & classify');
232
233
 
234
+ // Phase: Document template reconciliation
235
+ // Must run BEFORE article processing so that any template-driven changes
236
+ // to source .md files are picked up during rendering.
237
+ profiler.startPhase('Template reconciliation');
238
+ progress.startTimer('Templates');
239
+ const templateReconciliation = await reconcileAll(
240
+ allSourceFilenamesThatAreArticles,
241
+ allSourceFilenamesUnfiltered, // templates live in _templates which is filtered out of articles
242
+ source
243
+ );
244
+ if (templateReconciliation.updated > 0 || templateReconciliation.conflicts > 0 || templateReconciliation.initialized > 0) {
245
+ progress.logTimed(
246
+ `šŸ“„ Document templates: ${templateReconciliation.initialized} initialized, ` +
247
+ `${templateReconciliation.updated} auto-merged, ` +
248
+ `${templateReconciliation.conflicts} conflicts, ` +
249
+ `${templateReconciliation.unchanged} unchanged, ` +
250
+ `${templateReconciliation.errors} errors`
251
+ );
252
+ if (templateReconciliation.conflicts > 0) {
253
+ console.warn(`\nāš ļø Template conflicts require manual resolution:`);
254
+ for (const msg of templateReconciliation.messages) {
255
+ if (msg.includes('Conflict')) console.warn(` ${msg}`);
256
+ }
257
+ console.warn('');
258
+ }
259
+ if (templateReconciliation.errors > 0) {
260
+ for (const msg of templateReconciliation.messages) {
261
+ if (msg.includes('Error') || msg.includes('not found')) console.warn(` āš ļø ${msg}`);
262
+ }
263
+ }
264
+ }
265
+ progress.logTimed(`Document templates processed [${progress.stopTimer('Templates')}]`);
266
+ profiler.endPhase('Template reconciliation');
267
+
233
268
  // Phase: Build navigation and metadata
234
269
  profiler.startPhase('Build navigation');
235
270
  progress.startTimer('Navigation');
package/src/serve.js CHANGED
@@ -11,6 +11,7 @@ import { watchModeCache } from "./helper/build/watchCache.js";
11
11
  import { dependencyTracker } from "./helper/dependencyTracker.js";
12
12
  import { bundleMetaTemplateAssets, clearMetaBundleCache } from "./helper/assetBundler.js";
13
13
  import { getTemplates, copyMetaAssets } from "./helper/build/templates.js";
14
+ import { isInsideTemplatesFolder, reconcileByTemplate } from "./helper/documentTemplates.js";
14
15
  import { WebSocketServer } from "ws";
15
16
  import { createServer } from "http";
16
17
  import { resolvePort } from "./helper/portUtils.js";
@@ -553,9 +554,32 @@ export async function serve({
553
554
  try { await promises.unlink(join(ursaDir, 'nav-cache.json')); } catch {}
554
555
  }
555
556
 
557
+ // --- 5.5) Handle document template changes ---
558
+ // If a _templates/*.md file changed, reconcile all documents using that template
559
+ // and add the affected instance documents to the regeneration set.
560
+ const templateChanges = articleChanges.filter(c => c.name && isInsideTemplatesFolder(c.name));
561
+ if (templateChanges.length > 0 && watchModeCache.isInitialized) {
562
+ const allArticles = watchModeCache.allArticlePaths || [];
563
+ for (const change of templateChanges) {
564
+ console.log(`šŸ“„ Document template changed: ${basename(change.name)}`);
565
+ const reconcileResult = await reconcileByTemplate(change.name, allArticles, sourceDir);
566
+ if (reconcileResult.updated > 0 || reconcileResult.conflicts > 0) {
567
+ console.log(` ${reconcileResult.updated} auto-merged, ${reconcileResult.conflicts} conflicts`);
568
+ reconcileResult.affectedPaths.forEach(p => affectedDocPaths.add(p));
569
+ }
570
+ if (reconcileResult.conflicts > 0) {
571
+ for (const msg of reconcileResult.messages) {
572
+ if (msg.includes('Conflict')) console.warn(` āš ļø ${msg}`);
573
+ }
574
+ }
575
+ }
576
+ }
577
+
556
578
  // --- 6) Handle article changes via fast single-file regen ---
557
579
  // Deduplicate articles (same file may appear multiple times in rapid saves)
558
- const uniqueArticles = [...new Set(articleChanges.map(c => c.name))];
580
+ // Exclude _templates files from direct article regeneration (they aren't rendered)
581
+ const uniqueArticles = [...new Set(articleChanges.map(c => c.name))]
582
+ .filter(name => !isInsideTemplatesFolder(name));
559
583
  for (const articlePath of uniqueArticles) {
560
584
  affectedDocPaths.add(articlePath);
561
585
  }