@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 +11 -0
- package/bin/ursa.js +23 -36
- package/meta/templates/default-template/default.css +20 -0
- package/package.json +2 -1
- package/src/dev.js +6 -4
- package/src/helper/__test__/documentTemplates.test.js +354 -0
- package/src/helper/automenu.js +1 -1
- package/src/helper/documentTemplates.js +454 -0
- package/src/helper/mdxRenderer.js +20 -0
- package/src/jobs/generate.js +36 -1
- package/src/serve.js +25 -1
package/CHANGELOG.md
CHANGED
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
|
-
'
|
|
239
|
-
'
|
|
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
|
|
244
|
+
describe: 'Source directory (docroot) of the Ursa site',
|
|
244
245
|
type: 'string',
|
|
245
246
|
demandOption: true
|
|
246
247
|
})
|
|
247
|
-
.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
.
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
268
|
-
const
|
|
269
|
-
|
|
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
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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(
|
|
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.
|
|
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
|
|
573
|
-
|
|
574
|
-
|
|
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
|
+
});
|
package/src/helper/automenu.js
CHANGED
|
@@ -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
|
|
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
|
package/src/jobs/generate.js
CHANGED
|
@@ -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[\/\\]
|
|
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
|
-
|
|
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
|
}
|