@kenjura/ursa 0.9.0 → 0.32.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 +163 -0
- package/README.md +182 -19
- package/bin/ursa.js +208 -0
- package/lib/index.js +7 -2
- package/meta/character-sheet-template.html +2 -0
- package/meta/default-template.html +29 -5
- package/meta/default.css +451 -115
- package/meta/menu.js +371 -0
- package/meta/search.js +208 -0
- package/meta/sectionify.js +36 -0
- package/meta/sticky.js +73 -0
- package/meta/toc-generator.js +124 -0
- package/meta/toc.js +93 -0
- package/package.json +25 -4
- package/src/helper/WikiImage.js +138 -0
- package/src/helper/automenu.js +211 -55
- package/src/helper/contentHash.js +71 -0
- package/src/helper/fileExists.js +13 -0
- package/src/helper/findStyleCss.js +26 -0
- package/src/helper/linkValidator.js +246 -0
- package/src/helper/metadataExtractor.js +19 -8
- package/src/helper/whitelistFilter.js +66 -0
- package/src/helper/wikitextHelper.js +6 -3
- package/src/index.js +4 -3
- package/src/jobs/generate.js +366 -117
- package/src/serve.js +138 -37
- package/.nvmrc +0 -1
- package/.vscode/launch.json +0 -20
- package/TODO.md +0 -16
- package/nodemon.json +0 -16
package/src/jobs/generate.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { recurse } from "../helper/recursive-readdir.js";
|
|
2
2
|
|
|
3
|
-
import { copyFile, mkdir, readdir, readFile } from "fs/promises";
|
|
3
|
+
import { copyFile, mkdir, readdir, readFile, stat } from "fs/promises";
|
|
4
4
|
import { getAutomenu } from "../helper/automenu.js";
|
|
5
5
|
import { filterAsync } from "../helper/filterAsync.js";
|
|
6
6
|
import { isDirectory } from "../helper/isDirectory.js";
|
|
@@ -8,45 +8,99 @@ import {
|
|
|
8
8
|
extractMetadata,
|
|
9
9
|
extractRawMetadata,
|
|
10
10
|
} from "../helper/metadataExtractor.js";
|
|
11
|
+
import {
|
|
12
|
+
hashContent,
|
|
13
|
+
loadHashCache,
|
|
14
|
+
saveHashCache,
|
|
15
|
+
needsRegeneration,
|
|
16
|
+
updateHash,
|
|
17
|
+
} from "../helper/contentHash.js";
|
|
18
|
+
import {
|
|
19
|
+
buildValidPaths,
|
|
20
|
+
markInactiveLinks,
|
|
21
|
+
} from "../helper/linkValidator.js";
|
|
22
|
+
|
|
23
|
+
// Helper function to build search index from processed files
|
|
24
|
+
function buildSearchIndex(jsonCache, source, output) {
|
|
25
|
+
const searchIndex = [];
|
|
26
|
+
|
|
27
|
+
for (const [filePath, jsonObject] of jsonCache.entries()) {
|
|
28
|
+
// Generate URL path relative to output
|
|
29
|
+
const relativePath = filePath.replace(source, '').replace(/\.(md|txt|yml)$/, '.html');
|
|
30
|
+
const url = relativePath.startsWith('/') ? relativePath : '/' + relativePath;
|
|
31
|
+
|
|
32
|
+
// Extract text content from body (strip HTML tags for search)
|
|
33
|
+
const textContent = jsonObject.bodyHtml.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
34
|
+
const excerpt = textContent.substring(0, 200); // First 200 chars for preview
|
|
35
|
+
|
|
36
|
+
searchIndex.push({
|
|
37
|
+
title: toTitleCase(jsonObject.name),
|
|
38
|
+
path: relativePath,
|
|
39
|
+
url: url,
|
|
40
|
+
content: excerpt
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return searchIndex;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Helper function to convert filename to title case
|
|
48
|
+
function toTitleCase(filename) {
|
|
49
|
+
return filename
|
|
50
|
+
.split(/[-_\s]+/) // Split on hyphens, underscores, and spaces
|
|
51
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
52
|
+
.join(' ');
|
|
53
|
+
}
|
|
11
54
|
import { renderFile } from "../helper/fileRenderer.js";
|
|
55
|
+
import { findStyleCss } from "../helper/findStyleCss.js";
|
|
12
56
|
import { copy as copyDir, emptyDir, outputFile } from "fs-extra";
|
|
13
57
|
import { basename, dirname, extname, join, parse, resolve } from "path";
|
|
14
58
|
import { URL } from "url";
|
|
15
59
|
import o2x from "object-to-xml";
|
|
60
|
+
import { existsSync } from "fs";
|
|
61
|
+
import { fileExists } from "../helper/fileExists.js";
|
|
62
|
+
|
|
63
|
+
import { createWhitelistFilter } from "../helper/whitelistFilter.js";
|
|
16
64
|
|
|
17
65
|
const DEFAULT_TEMPLATE_NAME =
|
|
18
66
|
process.env.DEFAULT_TEMPLATE_NAME ?? "default-template";
|
|
19
67
|
|
|
20
68
|
export async function generate({
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
69
|
+
_source = join(process.cwd(), "."),
|
|
70
|
+
_meta = join(process.cwd(), "meta"),
|
|
71
|
+
_output = join(process.cwd(), "build"),
|
|
72
|
+
_whitelist = null,
|
|
73
|
+
_incremental = false, // Legacy flag, now ignored (always incremental)
|
|
74
|
+
_clean = false, // When true, ignore cache and regenerate all files
|
|
24
75
|
} = {}) {
|
|
76
|
+
console.log({ _source, _meta, _output, _whitelist, _clean });
|
|
77
|
+
const source = resolve(_source) + "/";
|
|
78
|
+
const meta = resolve(_meta);
|
|
79
|
+
const output = resolve(_output) + "/";
|
|
25
80
|
console.log({ source, meta, output });
|
|
26
81
|
|
|
27
82
|
const allSourceFilenamesUnfiltered = await recurse(source, [() => false]);
|
|
83
|
+
|
|
84
|
+
// Apply include filter (existing functionality)
|
|
28
85
|
const includeFilter = process.env.INCLUDE_FILTER
|
|
29
86
|
? (fileName) => fileName.match(process.env.INCLUDE_FILTER)
|
|
30
87
|
: Boolean;
|
|
31
|
-
|
|
32
|
-
|
|
88
|
+
let allSourceFilenames = allSourceFilenamesUnfiltered.filter(includeFilter);
|
|
89
|
+
|
|
90
|
+
// Apply whitelist filter if specified
|
|
91
|
+
if (_whitelist) {
|
|
92
|
+
const whitelistFilter = await createWhitelistFilter(_whitelist, source);
|
|
93
|
+
allSourceFilenames = allSourceFilenames.filter(whitelistFilter);
|
|
94
|
+
console.log(`Whitelist applied: ${allSourceFilenames.length} files after filtering`);
|
|
95
|
+
}
|
|
96
|
+
// console.log(allSourceFilenames);
|
|
33
97
|
|
|
34
|
-
if (source.substr(-1) !== "/") source += "/"; // warning: might not work in windows
|
|
35
|
-
if (output.substr(-1) !== "/") output += "/";
|
|
98
|
+
// if (source.substr(-1) !== "/") source += "/"; // warning: might not work in windows
|
|
99
|
+
// if (output.substr(-1) !== "/") output += "/";
|
|
36
100
|
|
|
37
101
|
const templates = await getTemplates(meta); // todo: error if no default template
|
|
38
102
|
// console.log({ templates });
|
|
39
103
|
|
|
40
|
-
const menu = await getMenu(allSourceFilenames, source);
|
|
41
|
-
|
|
42
|
-
// clean build directory
|
|
43
|
-
await emptyDir(output);
|
|
44
|
-
|
|
45
|
-
// create public folder
|
|
46
|
-
const pub = join(output, "public");
|
|
47
|
-
await mkdir(pub);
|
|
48
|
-
await copyDir(meta, pub);
|
|
49
|
-
|
|
50
104
|
// read all articles, process them, copy them to build
|
|
51
105
|
const articleExtensions = /\.(md|txt|yml)/;
|
|
52
106
|
const allSourceFilenamesThatAreArticles = allSourceFilenames.filter(
|
|
@@ -57,116 +111,253 @@ export async function generate({
|
|
|
57
111
|
(filename) => isDirectory(filename)
|
|
58
112
|
);
|
|
59
113
|
|
|
60
|
-
//
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
114
|
+
// Build set of valid internal paths for link validation (must be before menu)
|
|
115
|
+
const validPaths = buildValidPaths(allSourceFilenamesThatAreArticles, source);
|
|
116
|
+
console.log(`Built ${validPaths.size} valid paths for link validation`);
|
|
117
|
+
|
|
118
|
+
const menu = await getMenu(allSourceFilenames, source, validPaths);
|
|
119
|
+
|
|
120
|
+
// Load content hash cache from .ursa folder in source directory
|
|
121
|
+
let hashCache = new Map();
|
|
122
|
+
if (!_clean) {
|
|
123
|
+
hashCache = await loadHashCache(source);
|
|
124
|
+
console.log(`Loaded ${hashCache.size} cached content hashes from .ursa folder`);
|
|
125
|
+
} else {
|
|
126
|
+
console.log(`Clean build: ignoring cached hashes`);
|
|
127
|
+
}
|
|
65
128
|
|
|
129
|
+
// create public folder
|
|
130
|
+
const pub = join(output, "public");
|
|
131
|
+
await mkdir(pub, { recursive: true });
|
|
132
|
+
await copyDir(meta, pub);
|
|
133
|
+
|
|
134
|
+
// Track errors for error report
|
|
135
|
+
const errors = [];
|
|
136
|
+
|
|
137
|
+
// First pass: collect search index data
|
|
138
|
+
const searchIndex = [];
|
|
139
|
+
const jsonCache = new Map();
|
|
140
|
+
|
|
141
|
+
// Collect basic data for search index
|
|
142
|
+
for (const file of allSourceFilenamesThatAreArticles) {
|
|
143
|
+
try {
|
|
66
144
|
const rawBody = await readFile(file, "utf8");
|
|
67
145
|
const type = parse(file).ext;
|
|
68
|
-
const meta = extractMetadata(rawBody);
|
|
69
|
-
const rawMeta = extractRawMetadata(rawBody);
|
|
70
|
-
const bodyLessMeta = rawBody.replace(rawMeta, "");
|
|
71
|
-
const transformedMetadata = await getTransformedMetadata(
|
|
72
|
-
dirname(file),
|
|
73
|
-
meta
|
|
74
|
-
);
|
|
75
146
|
const ext = extname(file);
|
|
76
147
|
const base = basename(file, ext);
|
|
77
148
|
const dir = addTrailingSlash(dirname(file)).replace(source, "");
|
|
149
|
+
|
|
150
|
+
// Generate title from filename (in title case)
|
|
151
|
+
const title = toTitleCase(base);
|
|
152
|
+
|
|
153
|
+
// Generate URL path relative to output
|
|
154
|
+
const relativePath = file.replace(source, '').replace(/\.(md|txt|yml)$/, '.html');
|
|
155
|
+
const url = relativePath.startsWith('/') ? relativePath : '/' + relativePath;
|
|
156
|
+
|
|
157
|
+
// Basic content processing for search (without full rendering)
|
|
78
158
|
const body = renderFile({
|
|
79
159
|
fileContents: rawBody,
|
|
80
160
|
type,
|
|
81
161
|
dirname: dir,
|
|
82
162
|
basename: base,
|
|
83
163
|
});
|
|
164
|
+
|
|
165
|
+
// Extract text content from body (strip HTML tags for search)
|
|
166
|
+
const textContent = body && body.replace && body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim() || 'body is undefined for some reason'
|
|
167
|
+
const excerpt = textContent.substring(0, 200); // First 200 chars for preview
|
|
168
|
+
|
|
169
|
+
searchIndex.push({
|
|
170
|
+
title: title,
|
|
171
|
+
path: relativePath,
|
|
172
|
+
url: url,
|
|
173
|
+
content: excerpt
|
|
174
|
+
});
|
|
175
|
+
} catch (e) {
|
|
176
|
+
console.error(`Error processing ${file} (first pass): ${e.message}`);
|
|
177
|
+
errors.push({ file, phase: 'search-index', error: e });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
console.log(`Built search index with ${searchIndex.length} entries`);
|
|
84
182
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
183
|
+
// Track files that were regenerated (for incremental mode stats)
|
|
184
|
+
let regeneratedCount = 0;
|
|
185
|
+
let skippedCount = 0;
|
|
186
|
+
|
|
187
|
+
// Second pass: process individual articles with search data available
|
|
188
|
+
await Promise.all(
|
|
189
|
+
allSourceFilenamesThatAreArticles.map(async (file) => {
|
|
190
|
+
try {
|
|
191
|
+
const rawBody = await readFile(file, "utf8");
|
|
192
|
+
|
|
193
|
+
// Skip files that haven't changed (unless --clean flag is set)
|
|
194
|
+
if (!_clean && !needsRegeneration(file, rawBody, hashCache)) {
|
|
195
|
+
skippedCount++;
|
|
196
|
+
return; // Skip this file
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
console.log(`processing article ${file}`);
|
|
200
|
+
regeneratedCount++;
|
|
201
|
+
|
|
202
|
+
const type = parse(file).ext;
|
|
203
|
+
const meta = extractMetadata(rawBody);
|
|
204
|
+
const rawMeta = extractRawMetadata(rawBody);
|
|
205
|
+
const bodyLessMeta = rawMeta ? rawBody.replace(rawMeta, "") : rawBody;
|
|
206
|
+
const transformedMetadata = await getTransformedMetadata(
|
|
207
|
+
dirname(file),
|
|
208
|
+
meta
|
|
209
|
+
);
|
|
210
|
+
const ext = extname(file);
|
|
211
|
+
const base = basename(file, ext);
|
|
212
|
+
const dir = addTrailingSlash(dirname(file)).replace(source, "");
|
|
213
|
+
|
|
214
|
+
// Calculate the document's URL path (e.g., "/character/index.html")
|
|
215
|
+
const docUrlPath = '/' + dir + base + '.html';
|
|
216
|
+
|
|
217
|
+
// Generate title from filename (in title case)
|
|
218
|
+
const title = toTitleCase(base);
|
|
219
|
+
|
|
220
|
+
const body = renderFile({
|
|
221
|
+
fileContents: rawBody,
|
|
222
|
+
type,
|
|
223
|
+
dirname: dir,
|
|
224
|
+
basename: base,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Find nearest style.css or _style.css up the tree
|
|
228
|
+
let embeddedStyle = "";
|
|
229
|
+
try {
|
|
230
|
+
const css = await findStyleCss(resolve(_source, dir));
|
|
231
|
+
if (css) {
|
|
232
|
+
embeddedStyle = css;
|
|
233
|
+
}
|
|
234
|
+
} catch (e) {
|
|
235
|
+
// ignore
|
|
236
|
+
console.error(e);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const requestedTemplateName = meta && meta.template;
|
|
240
|
+
const template =
|
|
241
|
+
templates[requestedTemplateName] || templates[DEFAULT_TEMPLATE_NAME];
|
|
242
|
+
|
|
243
|
+
if (!template) {
|
|
244
|
+
throw new Error(`Template not found. Requested: "${requestedTemplateName || DEFAULT_TEMPLATE_NAME}". Available templates: ${Object.keys(templates).join(', ') || 'none'}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Insert embeddedStyle just before </head> if present, else at top
|
|
248
|
+
let finalHtml = template
|
|
249
|
+
.replace("${title}", title)
|
|
250
|
+
.replace("${menu}", menu)
|
|
251
|
+
.replace("${meta}", JSON.stringify(meta))
|
|
252
|
+
.replace("${transformedMetadata}", transformedMetadata)
|
|
253
|
+
.replace("${body}", body)
|
|
254
|
+
.replace("${embeddedStyle}", embeddedStyle)
|
|
255
|
+
.replace("${searchIndex}", JSON.stringify(searchIndex));
|
|
256
|
+
|
|
257
|
+
// Resolve links and mark broken internal links as inactive (debug mode on)
|
|
258
|
+
// Pass docUrlPath so relative links can be resolved correctly
|
|
259
|
+
finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
|
|
260
|
+
|
|
261
|
+
const outputFilename = file
|
|
262
|
+
.replace(source, output)
|
|
263
|
+
.replace(parse(file).ext, ".html");
|
|
264
|
+
|
|
265
|
+
console.log(`writing article to ${outputFilename}`);
|
|
266
|
+
|
|
267
|
+
await outputFile(outputFilename, finalHtml);
|
|
268
|
+
|
|
269
|
+
// json
|
|
270
|
+
|
|
271
|
+
const jsonOutputFilename = outputFilename.replace(".html", ".json");
|
|
272
|
+
const url = '/' + outputFilename.replace(output, '');
|
|
273
|
+
const jsonObject = {
|
|
274
|
+
name: base,
|
|
275
|
+
url,
|
|
276
|
+
contents: rawBody,
|
|
277
|
+
// bodyLessMeta: bodyLessMeta,
|
|
278
|
+
bodyHtml: body,
|
|
279
|
+
metadata: meta,
|
|
280
|
+
transformedMetadata,
|
|
281
|
+
// html: finalHtml,
|
|
282
|
+
};
|
|
283
|
+
jsonCache.set(file, jsonObject);
|
|
284
|
+
const json = JSON.stringify(jsonObject);
|
|
285
|
+
console.log(`writing article to ${jsonOutputFilename}`);
|
|
286
|
+
await outputFile(jsonOutputFilename, json);
|
|
287
|
+
|
|
288
|
+
// xml
|
|
289
|
+
|
|
290
|
+
const xmlOutputFilename = outputFilename.replace(".html", ".xml");
|
|
291
|
+
const xml = `<article>${o2x(jsonObject)}</article>`;
|
|
292
|
+
await outputFile(xmlOutputFilename, xml);
|
|
293
|
+
|
|
294
|
+
// Update the content hash for this file
|
|
295
|
+
updateHash(file, rawBody, hashCache);
|
|
296
|
+
} catch (e) {
|
|
297
|
+
console.error(`Error processing ${file} (second pass): ${e.message}`);
|
|
298
|
+
errors.push({ file, phase: 'article-generation', error: e });
|
|
299
|
+
}
|
|
126
300
|
})
|
|
127
301
|
);
|
|
128
302
|
|
|
303
|
+
// Log build stats
|
|
304
|
+
console.log(`Build: ${regeneratedCount} regenerated, ${skippedCount} unchanged`);
|
|
305
|
+
|
|
129
306
|
console.log(jsonCache.keys());
|
|
307
|
+
|
|
130
308
|
// process directory indices
|
|
131
309
|
await Promise.all(
|
|
132
310
|
allSourceFilenamesThatAreDirectories.map(async (dir) => {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
311
|
+
try {
|
|
312
|
+
console.log(`processing directory ${dir}`);
|
|
313
|
+
|
|
314
|
+
const pathsInThisDirectory = allSourceFilenames.filter((filename) =>
|
|
315
|
+
filename.match(new RegExp(`${dir}.+`))
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const jsonObjects = pathsInThisDirectory
|
|
319
|
+
.map((path) => {
|
|
320
|
+
const object = jsonCache.get(path);
|
|
321
|
+
return typeof object === "object" ? object : null;
|
|
322
|
+
})
|
|
323
|
+
.filter((a) => a);
|
|
324
|
+
|
|
325
|
+
const json = JSON.stringify(jsonObjects);
|
|
326
|
+
|
|
327
|
+
const outputFilename = dir.replace(source, output) + ".json";
|
|
328
|
+
|
|
329
|
+
console.log(`writing directory index to ${outputFilename}`);
|
|
330
|
+
await outputFile(outputFilename, json);
|
|
331
|
+
|
|
332
|
+
// html
|
|
333
|
+
const htmlOutputFilename = dir.replace(source, output) + ".html";
|
|
334
|
+
const indexAlreadyExists = fileExists(htmlOutputFilename);
|
|
335
|
+
if (!indexAlreadyExists) {
|
|
336
|
+
const template = templates["default-template"]; // TODO: figure out a way to specify template for a directory index
|
|
337
|
+
const indexHtml = `<ul>${pathsInThisDirectory
|
|
338
|
+
.map((path) => {
|
|
339
|
+
const partialPath = path
|
|
340
|
+
.replace(source, "")
|
|
341
|
+
.replace(parse(path).ext, ".html");
|
|
342
|
+
const name = basename(path, parse(path).ext);
|
|
343
|
+
return `<li><a href="${partialPath}">${name}</a></li>`;
|
|
344
|
+
})
|
|
345
|
+
.join("")}</ul>`;
|
|
346
|
+
const finalHtml = template
|
|
347
|
+
.replace("${menu}", menu)
|
|
348
|
+
.replace("${body}", indexHtml)
|
|
349
|
+
.replace("${searchIndex}", JSON.stringify(searchIndex))
|
|
350
|
+
.replace("${title}", "Index")
|
|
351
|
+
.replace("${meta}", "{}")
|
|
352
|
+
.replace("${transformedMetadata}", "")
|
|
353
|
+
.replace("${embeddedStyle}", "");
|
|
354
|
+
console.log(`writing directory index to ${htmlOutputFilename}`);
|
|
355
|
+
await outputFile(htmlOutputFilename, finalHtml);
|
|
356
|
+
}
|
|
357
|
+
} catch (e) {
|
|
358
|
+
console.error(`Error processing directory ${dir}: ${e.message}`);
|
|
359
|
+
errors.push({ file: dir, phase: 'directory-index', error: e });
|
|
360
|
+
}
|
|
170
361
|
})
|
|
171
362
|
);
|
|
172
363
|
|
|
@@ -177,15 +368,73 @@ export async function generate({
|
|
|
177
368
|
);
|
|
178
369
|
await Promise.all(
|
|
179
370
|
allSourceFilenamesThatAreImages.map(async (file) => {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
371
|
+
try {
|
|
372
|
+
// For incremental mode, check if file has changed using file stat as a quick check
|
|
373
|
+
if (_incremental) {
|
|
374
|
+
const fileStat = await stat(file);
|
|
375
|
+
const statKey = `${file}:stat`;
|
|
376
|
+
const newStatHash = `${fileStat.size}:${fileStat.mtimeMs}`;
|
|
377
|
+
if (hashCache.get(statKey) === newStatHash) {
|
|
378
|
+
return; // Skip unchanged static file
|
|
379
|
+
}
|
|
380
|
+
hashCache.set(statKey, newStatHash);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
console.log(`processing static file ${file}`);
|
|
384
|
+
|
|
385
|
+
const outputFilename = file.replace(source, output);
|
|
386
|
+
|
|
387
|
+
console.log(`writing static file to ${outputFilename}`);
|
|
388
|
+
|
|
389
|
+
await mkdir(dirname(outputFilename), { recursive: true });
|
|
390
|
+
return await copyFile(file, outputFilename);
|
|
391
|
+
} catch (e) {
|
|
392
|
+
console.error(`Error processing static file ${file}: ${e.message}`);
|
|
393
|
+
errors.push({ file, phase: 'static-file', error: e });
|
|
394
|
+
}
|
|
187
395
|
})
|
|
188
396
|
);
|
|
397
|
+
|
|
398
|
+
// Save the hash cache to .ursa folder in source directory
|
|
399
|
+
if (hashCache.size > 0) {
|
|
400
|
+
await saveHashCache(source, hashCache);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Write error report if there were any errors
|
|
404
|
+
if (errors.length > 0) {
|
|
405
|
+
const errorReportPath = join(output, '_errors.log');
|
|
406
|
+
const failedFiles = errors.map(e => e.file);
|
|
407
|
+
|
|
408
|
+
let report = `URSA GENERATION ERROR REPORT\n`;
|
|
409
|
+
report += `Generated: ${new Date().toISOString()}\n`;
|
|
410
|
+
report += `Total errors: ${errors.length}\n\n`;
|
|
411
|
+
report += `${'='.repeat(60)}\n`;
|
|
412
|
+
report += `FAILED FILES:\n`;
|
|
413
|
+
report += `${'='.repeat(60)}\n\n`;
|
|
414
|
+
failedFiles.forEach(f => {
|
|
415
|
+
report += ` - ${f}\n`;
|
|
416
|
+
});
|
|
417
|
+
report += `\n${'='.repeat(60)}\n`;
|
|
418
|
+
report += `ERROR DETAILS:\n`;
|
|
419
|
+
report += `${'='.repeat(60)}\n\n`;
|
|
420
|
+
|
|
421
|
+
errors.forEach(({ file, phase, error }) => {
|
|
422
|
+
report += `${'─'.repeat(60)}\n`;
|
|
423
|
+
report += `File: ${file}\n`;
|
|
424
|
+
report += `Phase: ${phase}\n`;
|
|
425
|
+
report += `Error: ${error.message}\n`;
|
|
426
|
+
if (error.stack) {
|
|
427
|
+
report += `Stack:\n${error.stack}\n`;
|
|
428
|
+
}
|
|
429
|
+
report += `\n`;
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
await outputFile(errorReportPath, report);
|
|
433
|
+
console.log(`\n⚠️ ${errors.length} error(s) occurred during generation.`);
|
|
434
|
+
console.log(` Error report written to: ${errorReportPath}\n`);
|
|
435
|
+
} else {
|
|
436
|
+
console.log(`\n✅ Generation complete with no errors.\n`);
|
|
437
|
+
}
|
|
189
438
|
}
|
|
190
439
|
|
|
191
440
|
/**
|
|
@@ -213,10 +462,10 @@ async function getTemplates(meta) {
|
|
|
213
462
|
return templates;
|
|
214
463
|
}
|
|
215
464
|
|
|
216
|
-
async function getMenu(allSourceFilenames, source) {
|
|
465
|
+
async function getMenu(allSourceFilenames, source, validPaths) {
|
|
217
466
|
// todo: handle various incarnations of menu filename
|
|
218
467
|
|
|
219
|
-
const rawMenu = await getAutomenu(source);
|
|
468
|
+
const rawMenu = await getAutomenu(source, validPaths);
|
|
220
469
|
const menuBody = renderFile({ fileContents: rawMenu, type: ".md" });
|
|
221
470
|
return menuBody;
|
|
222
471
|
|