@kenjura/ursa 0.46.0 → 0.48.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 +18 -0
- package/meta/default-template.html +1 -3
- package/meta/menu.js +17 -6
- package/package.json +1 -1
- package/src/helper/automenu.js +15 -8
- package/src/helper/findStyleCss.js +3 -3
- package/src/helper/linkValidator.js +37 -2
- package/src/jobs/generate.js +238 -12
- package/src/serve.js +107 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
# 0.48.0
|
|
4
|
+
2025-12-20
|
|
5
|
+
|
|
6
|
+
- **External CSS**: CSS files are now externally linked via `<link>` tags instead of being embedded in each HTML page. This significantly reduces HTML file sizes and improves browser caching.
|
|
7
|
+
- **Fast CSS Updates**: CSS file changes in watch mode now just copy the file to output (~1ms) instead of triggering a full rebuild
|
|
8
|
+
- **Fast Single-File Regeneration**: Article changes in watch mode use a new fast-path that regenerates only the changed file (~50-100ms) instead of scanning all source files
|
|
9
|
+
- **Clickable Folder Links**: All folders in the navigation menu are now clickable links (auto-index ensures every folder has an index.html)
|
|
10
|
+
- **Menu Collapse Fix**: Fixed issue where clicking the caret on a folder containing the current page wouldn't collapse it
|
|
11
|
+
- **URL Encoding Fix**: Fixed menu not highlighting current page when URLs contain spaces or special characters
|
|
12
|
+
- **Link Validation Fix**: Links to folders are no longer incorrectly marked as inactive (folders now included in valid paths since auto-index generates index.html for all)
|
|
13
|
+
|
|
14
|
+
# 0.47.0
|
|
15
|
+
2025-12-20
|
|
16
|
+
|
|
17
|
+
- Improved handling of trailing slashes in URLs to ensure consistency across all links and resources
|
|
18
|
+
|
|
1
19
|
# 0.46.0
|
|
2
20
|
2025-12-20
|
|
3
21
|
|
|
@@ -5,9 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>${title}</title>
|
|
7
7
|
<link rel="stylesheet" href="/public/default.css" />
|
|
8
|
-
|
|
9
|
-
${embeddedStyle}
|
|
10
|
-
</style>
|
|
8
|
+
${styleLink}
|
|
11
9
|
<script>
|
|
12
10
|
// Search index loaded asynchronously from separate file to reduce page size
|
|
13
11
|
window.SEARCH_INDEX = ${searchIndex};
|
package/meta/menu.js
CHANGED
|
@@ -28,6 +28,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
28
28
|
// State
|
|
29
29
|
let currentPath = []; // Array of path segments representing current directory
|
|
30
30
|
let expandedLevel1 = new Set(); // Track which level-1 items are expanded
|
|
31
|
+
let collapsedLevel1 = new Set(); // Track which level-1 items are explicitly collapsed (overrides auto-expand for current page)
|
|
31
32
|
|
|
32
33
|
// DOM elements
|
|
33
34
|
const breadcrumb = navMain.querySelector('.menu-breadcrumb');
|
|
@@ -124,8 +125,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
124
125
|
const hasChildrenClass = item.hasChildren ? ' has-children' : '';
|
|
125
126
|
|
|
126
127
|
// Level-1 items with children get a caret, not triple-dot
|
|
127
|
-
// Expanded if: manually expanded, or current page is in this tree
|
|
128
|
-
const isExpanded = expandedLevel1.has(item.path) || isCurrentPageInTree(item);
|
|
128
|
+
// Expanded if: manually expanded, or (current page is in this tree AND not explicitly collapsed)
|
|
129
|
+
const isExpanded = expandedLevel1.has(item.path) || (isCurrentPageInTree(item) && !collapsedLevel1.has(item.path));
|
|
129
130
|
const expandedClass = isExpanded ? ' expanded' : '';
|
|
130
131
|
const caretIndicator = item.hasChildren
|
|
131
132
|
? `<span class="menu-caret">${isExpanded ? '▼' : '▶'}</span>`
|
|
@@ -204,9 +205,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
204
205
|
function isCurrentPage(item) {
|
|
205
206
|
if (!item.href) return false;
|
|
206
207
|
const currentHref = window.location.pathname;
|
|
207
|
-
// Normalize paths for comparison
|
|
208
|
-
const normalizedItemHref = item.href.replace(/\/index\.html$/, '').replace(/\.html$/, '');
|
|
209
|
-
const normalizedCurrentHref = currentHref.replace(/\/index\.html$/, '').replace(/\.html$/, '');
|
|
208
|
+
// Normalize paths for comparison - decode URI components to handle spaces and special chars
|
|
209
|
+
const normalizedItemHref = decodeURIComponent(item.href).replace(/\/index\.html$/, '').replace(/\.html$/, '');
|
|
210
|
+
const normalizedCurrentHref = decodeURIComponent(currentHref).replace(/\/index\.html$/, '').replace(/\.html$/, '');
|
|
210
211
|
return normalizedItemHref === normalizedCurrentHref;
|
|
211
212
|
}
|
|
212
213
|
|
|
@@ -249,10 +250,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
249
250
|
e.stopPropagation();
|
|
250
251
|
const path = li.dataset.path;
|
|
251
252
|
const hasLink = !!link;
|
|
253
|
+
const containsCurrentPage = li.dataset.path === currentPageParentPath;
|
|
252
254
|
|
|
253
|
-
|
|
255
|
+
// Check current expanded state (same logic as renderMenu)
|
|
256
|
+
const isCurrentlyExpanded = expandedLevel1.has(path) || (containsCurrentPage && !collapsedLevel1.has(path));
|
|
257
|
+
|
|
258
|
+
if (isCurrentlyExpanded) {
|
|
254
259
|
// Collapsing this item
|
|
255
260
|
expandedLevel1.delete(path);
|
|
261
|
+
// If it contains current page, mark as explicitly collapsed
|
|
262
|
+
if (containsCurrentPage) {
|
|
263
|
+
collapsedLevel1.add(path);
|
|
264
|
+
}
|
|
256
265
|
} else {
|
|
257
266
|
// Expanding this item
|
|
258
267
|
// If this item has no link (non-navigable folder), collapse others
|
|
@@ -260,6 +269,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
260
269
|
expandedLevel1.clear();
|
|
261
270
|
}
|
|
262
271
|
expandedLevel1.add(path);
|
|
272
|
+
// Remove from explicit collapsed set
|
|
273
|
+
collapsedLevel1.delete(path);
|
|
263
274
|
}
|
|
264
275
|
renderMenu();
|
|
265
276
|
};
|
package/package.json
CHANGED
package/src/helper/automenu.js
CHANGED
|
@@ -161,20 +161,27 @@ function buildMenuData(tree, source, validPaths, parentPath = '') {
|
|
|
161
161
|
const label = folderConfig?.label || toDisplayName(baseName);
|
|
162
162
|
|
|
163
163
|
let rawHref = null;
|
|
164
|
+
let href = null;
|
|
165
|
+
let inactive = false;
|
|
166
|
+
let debug = '';
|
|
167
|
+
|
|
164
168
|
if (hasChildren) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
169
|
+
// All folders now have index pages (either existing or auto-generated)
|
|
170
|
+
const cleanPath = relativePath.startsWith('/') ? relativePath : '/' + relativePath;
|
|
171
|
+
rawHref = `${cleanPath}/index.html`.replace(/\/\//g, '/');
|
|
172
|
+
href = rawHref;
|
|
173
|
+
inactive = false; // Always active - auto-index ensures all folders have index.html
|
|
174
|
+
debug = 'folder (auto-index enabled)';
|
|
170
175
|
} else {
|
|
171
176
|
const cleanPath = relativePath.startsWith('/') ? relativePath : '/' + relativePath;
|
|
172
177
|
rawHref = cleanPath.replace(ext, '');
|
|
178
|
+
// Resolve the href and check if target exists
|
|
179
|
+
const resolved = resolveHref(rawHref, validPaths);
|
|
180
|
+
href = resolved.href;
|
|
181
|
+
inactive = resolved.inactive;
|
|
182
|
+
debug = resolved.debug;
|
|
173
183
|
}
|
|
174
184
|
|
|
175
|
-
// Resolve the href and check if target exists
|
|
176
|
-
const { href, inactive, debug } = resolveHref(rawHref, validPaths);
|
|
177
|
-
|
|
178
185
|
// Determine icon - custom from config, or custom icon file, or default
|
|
179
186
|
let icon = getIcon(item, source);
|
|
180
187
|
if (folderConfig?.icon) {
|
|
@@ -3,11 +3,11 @@ import { existsSync } from "fs";
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Recursively search for style.css or _style.css up the directory tree.
|
|
6
|
-
* Returns the
|
|
6
|
+
* Returns the path to the first found file, or null if not found.
|
|
7
7
|
* @param {string} startDir - Directory to start searching from
|
|
8
8
|
* @param {string[]} [names=["style.css", "_style.css"]] - Filenames to look for
|
|
9
9
|
* @param {string} [baseDir] - Stop searching when this directory is reached
|
|
10
|
-
* @returns {Promise<string|null>} CSS
|
|
10
|
+
* @returns {Promise<string|null>} CSS file path or null
|
|
11
11
|
*/
|
|
12
12
|
export async function findStyleCss(startDir, names = ["style-ursa.css", "style.css", "_style.css"], baseDir = null) {
|
|
13
13
|
let dir = resolve(startDir);
|
|
@@ -16,7 +16,7 @@ export async function findStyleCss(startDir, names = ["style-ursa.css", "style.c
|
|
|
16
16
|
for (const name of names) {
|
|
17
17
|
const candidate = join(dir, name);
|
|
18
18
|
if (existsSync(candidate)) {
|
|
19
|
-
return
|
|
19
|
+
return candidate; // Return path instead of contents
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
if (dir === baseDir || dir === dirname(dir)) break;
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { extname, dirname, join, normalize, posix } from "path";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Build a set of valid internal paths from the list of source files
|
|
4
|
+
* Build a set of valid internal paths from the list of source files and directories
|
|
5
5
|
* @param {string[]} sourceFiles - Array of source file paths
|
|
6
6
|
* @param {string} source - Source directory path
|
|
7
|
+
* @param {string[]} [directories] - Optional array of directory paths (for auto-index support)
|
|
7
8
|
* @returns {Set<string>} Set of valid internal paths (without extension, lowercased)
|
|
8
9
|
*/
|
|
9
|
-
export function buildValidPaths(sourceFiles, source) {
|
|
10
|
+
export function buildValidPaths(sourceFiles, source, directories = []) {
|
|
10
11
|
const validPaths = new Set();
|
|
11
12
|
|
|
12
13
|
for (const file of sourceFiles) {
|
|
@@ -19,6 +20,13 @@ export function buildValidPaths(sourceFiles, source) {
|
|
|
19
20
|
relativePath = "/" + relativePath;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
// Decode URI components for paths with special characters (spaces, etc.)
|
|
24
|
+
try {
|
|
25
|
+
relativePath = decodeURIComponent(relativePath);
|
|
26
|
+
} catch (e) {
|
|
27
|
+
// Ignore decode errors
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
// Add both with and without trailing slash for directories
|
|
23
31
|
validPaths.add(relativePath.toLowerCase());
|
|
24
32
|
validPaths.add((relativePath + ".html").toLowerCase());
|
|
@@ -32,6 +40,33 @@ export function buildValidPaths(sourceFiles, source) {
|
|
|
32
40
|
}
|
|
33
41
|
}
|
|
34
42
|
|
|
43
|
+
// Add all directories as valid paths (they get auto-generated index.html)
|
|
44
|
+
for (const dir of directories) {
|
|
45
|
+
let relativePath = dir.replace(source, "");
|
|
46
|
+
|
|
47
|
+
// Normalize: ensure leading slash
|
|
48
|
+
if (!relativePath.startsWith("/")) {
|
|
49
|
+
relativePath = "/" + relativePath;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Remove trailing slash for consistency
|
|
53
|
+
if (relativePath.endsWith("/")) {
|
|
54
|
+
relativePath = relativePath.slice(0, -1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Decode URI components
|
|
58
|
+
try {
|
|
59
|
+
relativePath = decodeURIComponent(relativePath);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
// Ignore decode errors
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Add directory paths - all folders now have index.html (auto-generated if needed)
|
|
65
|
+
validPaths.add(relativePath.toLowerCase());
|
|
66
|
+
validPaths.add((relativePath + "/").toLowerCase());
|
|
67
|
+
validPaths.add((relativePath + "/index.html").toLowerCase());
|
|
68
|
+
}
|
|
69
|
+
|
|
35
70
|
// Add root
|
|
36
71
|
validPaths.add("/");
|
|
37
72
|
validPaths.add("/index.html");
|
package/src/jobs/generate.js
CHANGED
|
@@ -5,6 +5,36 @@ import { copyFile, mkdir, readdir, readFile, stat } from "fs/promises";
|
|
|
5
5
|
// Concurrency limiter for batch processing to avoid memory exhaustion
|
|
6
6
|
const BATCH_SIZE = parseInt(process.env.URSA_BATCH_SIZE || '50', 10);
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Cache for watch mode - stores expensive data that doesn't change often
|
|
10
|
+
* This allows single-file regeneration to skip re-building menu, templates, etc.
|
|
11
|
+
*/
|
|
12
|
+
const watchModeCache = {
|
|
13
|
+
templates: null,
|
|
14
|
+
menu: null,
|
|
15
|
+
footer: null,
|
|
16
|
+
validPaths: null,
|
|
17
|
+
source: null,
|
|
18
|
+
meta: null,
|
|
19
|
+
output: null,
|
|
20
|
+
hashCache: null,
|
|
21
|
+
lastFullBuild: 0,
|
|
22
|
+
isInitialized: false,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Clear the watch mode cache (call when templates/meta/config change)
|
|
27
|
+
*/
|
|
28
|
+
export function clearWatchCache() {
|
|
29
|
+
watchModeCache.templates = null;
|
|
30
|
+
watchModeCache.menu = null;
|
|
31
|
+
watchModeCache.footer = null;
|
|
32
|
+
watchModeCache.validPaths = null;
|
|
33
|
+
watchModeCache.hashCache = null;
|
|
34
|
+
watchModeCache.isInitialized = false;
|
|
35
|
+
console.log('Watch cache cleared');
|
|
36
|
+
}
|
|
37
|
+
|
|
8
38
|
/**
|
|
9
39
|
* Progress reporter that updates lines in place (like pnpm)
|
|
10
40
|
*/
|
|
@@ -270,7 +300,8 @@ export async function generate({
|
|
|
270
300
|
)).filter((filename) => !filename.match(hiddenOrSystemDirs) && !isFolderHidden(filename, source));
|
|
271
301
|
|
|
272
302
|
// Build set of valid internal paths for link validation (must be before menu)
|
|
273
|
-
|
|
303
|
+
// Pass directories to ensure folder links are valid (auto-index generates index.html for all folders)
|
|
304
|
+
const validPaths = buildValidPaths(allSourceFilenamesThatAreArticles, source, allSourceFilenamesThatAreDirectories);
|
|
274
305
|
progress.log(`Built ${validPaths.size} valid paths for link validation`);
|
|
275
306
|
|
|
276
307
|
const menu = await getMenu(allSourceFilenames, source, validPaths);
|
|
@@ -304,6 +335,9 @@ export async function generate({
|
|
|
304
335
|
// Directory index cache: only stores minimal data needed for directory indices
|
|
305
336
|
// Uses WeakRef-style approach - store only what's needed, clear as we go
|
|
306
337
|
const dirIndexCache = new Map();
|
|
338
|
+
|
|
339
|
+
// Track CSS files that have been copied to avoid duplicates
|
|
340
|
+
const copiedCssFiles = new Set();
|
|
307
341
|
|
|
308
342
|
// Track files that were regenerated (for incremental mode stats)
|
|
309
343
|
let regeneratedCount = 0;
|
|
@@ -380,12 +414,24 @@ export async function generate({
|
|
|
380
414
|
basename: base,
|
|
381
415
|
});
|
|
382
416
|
|
|
383
|
-
// Find nearest style.css or _style.css up the tree
|
|
384
|
-
let
|
|
417
|
+
// Find nearest style.css or _style.css up the tree and copy to output
|
|
418
|
+
let styleLink = "";
|
|
385
419
|
try {
|
|
386
|
-
const
|
|
387
|
-
if (
|
|
388
|
-
|
|
420
|
+
const cssPath = await findStyleCss(resolve(_source, dir));
|
|
421
|
+
if (cssPath) {
|
|
422
|
+
// Calculate output path for the CSS file (mirrors source structure)
|
|
423
|
+
const cssOutputPath = cssPath.replace(source, output);
|
|
424
|
+
const cssUrlPath = '/' + cssPath.replace(source, '');
|
|
425
|
+
|
|
426
|
+
// Copy CSS file if not already copied
|
|
427
|
+
if (!copiedCssFiles.has(cssPath)) {
|
|
428
|
+
const cssContent = await readFile(cssPath, 'utf8');
|
|
429
|
+
await outputFile(cssOutputPath, cssContent);
|
|
430
|
+
copiedCssFiles.add(cssPath);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Generate link tag
|
|
434
|
+
styleLink = `<link rel="stylesheet" href="${cssUrlPath}" />`;
|
|
389
435
|
}
|
|
390
436
|
} catch (e) {
|
|
391
437
|
// ignore
|
|
@@ -409,7 +455,7 @@ export async function generate({
|
|
|
409
455
|
"${meta}": JSON.stringify(fileMeta),
|
|
410
456
|
"${transformedMetadata}": transformedMetadata,
|
|
411
457
|
"${body}": body,
|
|
412
|
-
"${
|
|
458
|
+
"${styleLink}": styleLink,
|
|
413
459
|
"${searchIndex}": "[]", // Placeholder - search index written separately as JSON file
|
|
414
460
|
"${footer}": footer
|
|
415
461
|
};
|
|
@@ -520,7 +566,7 @@ export async function generate({
|
|
|
520
566
|
"${title}": "Index",
|
|
521
567
|
"${meta}": "{}",
|
|
522
568
|
"${transformedMetadata}": "",
|
|
523
|
-
"${
|
|
569
|
+
"${styleLink}": "",
|
|
524
570
|
"${footer}": footer
|
|
525
571
|
};
|
|
526
572
|
for (const [key, value] of Object.entries(replacements)) {
|
|
@@ -578,13 +624,26 @@ export async function generate({
|
|
|
578
624
|
|
|
579
625
|
// Automatic index generation for folders without index.html
|
|
580
626
|
progress.log(`Checking for missing index files...`);
|
|
581
|
-
await generateAutoIndices(output, allSourceFilenamesThatAreDirectories, source, templates, menu, footer);
|
|
627
|
+
await generateAutoIndices(output, allSourceFilenamesThatAreDirectories, source, templates, menu, footer, allSourceFilenamesThatAreArticles);
|
|
582
628
|
|
|
583
629
|
// Save the hash cache to .ursa folder in source directory
|
|
584
630
|
if (hashCache.size > 0) {
|
|
585
631
|
await saveHashCache(source, hashCache);
|
|
586
632
|
}
|
|
587
633
|
|
|
634
|
+
// Populate watch mode cache for fast single-file regeneration
|
|
635
|
+
watchModeCache.templates = templates;
|
|
636
|
+
watchModeCache.menu = menu;
|
|
637
|
+
watchModeCache.footer = footer;
|
|
638
|
+
watchModeCache.validPaths = validPaths;
|
|
639
|
+
watchModeCache.source = source;
|
|
640
|
+
watchModeCache.meta = meta;
|
|
641
|
+
watchModeCache.output = output;
|
|
642
|
+
watchModeCache.hashCache = hashCache;
|
|
643
|
+
watchModeCache.lastFullBuild = Date.now();
|
|
644
|
+
watchModeCache.isInitialized = true;
|
|
645
|
+
progress.log(`Watch cache initialized for fast single-file regeneration`);
|
|
646
|
+
|
|
588
647
|
// Write error report if there were any errors
|
|
589
648
|
if (errors.length > 0) {
|
|
590
649
|
const errorReportPath = join(output, '_errors.log');
|
|
@@ -631,7 +690,7 @@ export async function generate({
|
|
|
631
690
|
* @param {string} menu - Rendered menu HTML
|
|
632
691
|
* @param {string} footer - Footer HTML
|
|
633
692
|
*/
|
|
634
|
-
async function generateAutoIndices(output, directories, source, templates, menu, footer) {
|
|
693
|
+
async function generateAutoIndices(output, directories, source, templates, menu, footer, generatedArticles) {
|
|
635
694
|
// Alternate index file names to look for (in priority order)
|
|
636
695
|
const INDEX_ALTERNATES = ['_index.html', 'home.html', '_home.html'];
|
|
637
696
|
|
|
@@ -639,6 +698,17 @@ async function generateAutoIndices(output, directories, source, templates, menu,
|
|
|
639
698
|
const sourceNorm = source.replace(/\/+$/, '');
|
|
640
699
|
const outputNorm = output.replace(/\/+$/, '');
|
|
641
700
|
|
|
701
|
+
// Build set of directories that already have an index.html from a source index.md/txt/yml
|
|
702
|
+
const dirsWithSourceIndex = new Set();
|
|
703
|
+
for (const articlePath of generatedArticles) {
|
|
704
|
+
const base = basename(articlePath, extname(articlePath));
|
|
705
|
+
if (base === 'index') {
|
|
706
|
+
const dir = dirname(articlePath);
|
|
707
|
+
const outputDir = dir.replace(sourceNorm, outputNorm);
|
|
708
|
+
dirsWithSourceIndex.add(outputDir);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
642
712
|
// Get all output directories (including root)
|
|
643
713
|
const outputDirs = new Set([outputNorm]);
|
|
644
714
|
for (const dir of directories) {
|
|
@@ -653,7 +723,12 @@ async function generateAutoIndices(output, directories, source, templates, menu,
|
|
|
653
723
|
for (const dir of outputDirs) {
|
|
654
724
|
const indexPath = join(dir, 'index.html');
|
|
655
725
|
|
|
656
|
-
// Skip if index.
|
|
726
|
+
// Skip if this directory had a source index.md/txt/yml that was already processed
|
|
727
|
+
if (dirsWithSourceIndex.has(dir)) {
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Skip if index.html already exists (e.g., created by previous run)
|
|
657
732
|
if (existsSync(indexPath)) {
|
|
658
733
|
continue;
|
|
659
734
|
}
|
|
@@ -727,7 +802,7 @@ async function generateAutoIndices(output, directories, source, templates, menu,
|
|
|
727
802
|
"${title}": folderDisplayName,
|
|
728
803
|
"${meta}": "{}",
|
|
729
804
|
"${transformedMetadata}": "",
|
|
730
|
-
"${
|
|
805
|
+
"${styleLink}": "",
|
|
731
806
|
"${footer}": footer
|
|
732
807
|
};
|
|
733
808
|
for (const [key, value] of Object.entries(replacements)) {
|
|
@@ -750,6 +825,157 @@ async function generateAutoIndices(output, directories, source, templates, menu,
|
|
|
750
825
|
}
|
|
751
826
|
}
|
|
752
827
|
|
|
828
|
+
/**
|
|
829
|
+
* Regenerate a single file without scanning the entire source directory.
|
|
830
|
+
* This is much faster for watch mode - only regenerate what changed.
|
|
831
|
+
*
|
|
832
|
+
* @param {string} changedFile - Absolute path to the file that changed
|
|
833
|
+
* @param {Object} options - Same options as generate()
|
|
834
|
+
* @returns {Promise<{success: boolean, message: string}>}
|
|
835
|
+
*/
|
|
836
|
+
export async function regenerateSingleFile(changedFile, {
|
|
837
|
+
_source,
|
|
838
|
+
_meta,
|
|
839
|
+
_output,
|
|
840
|
+
} = {}) {
|
|
841
|
+
const startTime = Date.now();
|
|
842
|
+
const source = resolve(_source) + "/";
|
|
843
|
+
const meta = resolve(_meta);
|
|
844
|
+
const output = resolve(_output) + "/";
|
|
845
|
+
|
|
846
|
+
// Check if this is an article file we can regenerate
|
|
847
|
+
const articleExtensions = /\.(md|txt|yml)$/;
|
|
848
|
+
if (!changedFile.match(articleExtensions)) {
|
|
849
|
+
return { success: false, message: `Not an article file: ${changedFile}` };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Check if cache is initialized
|
|
853
|
+
if (!watchModeCache.isInitialized) {
|
|
854
|
+
return { success: false, message: 'Cache not initialized - need full build first' };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Verify paths match cached paths
|
|
858
|
+
if (watchModeCache.source !== source || watchModeCache.output !== output) {
|
|
859
|
+
return { success: false, message: 'Paths changed - need full rebuild' };
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
try {
|
|
863
|
+
const { templates, menu, footer, validPaths, hashCache } = watchModeCache;
|
|
864
|
+
|
|
865
|
+
const rawBody = await readFile(changedFile, "utf8");
|
|
866
|
+
const type = parse(changedFile).ext;
|
|
867
|
+
const ext = extname(changedFile);
|
|
868
|
+
const base = basename(changedFile, ext);
|
|
869
|
+
const dir = addTrailingSlash(dirname(changedFile)).replace(source, "");
|
|
870
|
+
|
|
871
|
+
// Calculate output paths
|
|
872
|
+
const outputFilename = changedFile
|
|
873
|
+
.replace(source, output)
|
|
874
|
+
.replace(parse(changedFile).ext, ".html");
|
|
875
|
+
const url = '/' + outputFilename.replace(output, '');
|
|
876
|
+
|
|
877
|
+
// Title from filename
|
|
878
|
+
const title = toTitleCase(base);
|
|
879
|
+
|
|
880
|
+
// Extract metadata
|
|
881
|
+
const fileMeta = extractMetadata(rawBody);
|
|
882
|
+
const transformedMetadata = await getTransformedMetadata(
|
|
883
|
+
dirname(changedFile),
|
|
884
|
+
fileMeta
|
|
885
|
+
);
|
|
886
|
+
|
|
887
|
+
// Calculate the document's URL path
|
|
888
|
+
const docUrlPath = '/' + dir + base + '.html';
|
|
889
|
+
|
|
890
|
+
// Render body
|
|
891
|
+
const body = renderFile({
|
|
892
|
+
fileContents: rawBody,
|
|
893
|
+
type,
|
|
894
|
+
dirname: dir,
|
|
895
|
+
basename: base,
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
// Find CSS and copy to output
|
|
899
|
+
let styleLink = "";
|
|
900
|
+
try {
|
|
901
|
+
const cssPath = await findStyleCss(resolve(_source, dir));
|
|
902
|
+
if (cssPath) {
|
|
903
|
+
// Calculate output path for the CSS file
|
|
904
|
+
const cssOutputPath = cssPath.replace(source, output);
|
|
905
|
+
const cssUrlPath = '/' + cssPath.replace(source, '');
|
|
906
|
+
|
|
907
|
+
// Copy CSS file (always copy in single-file mode to ensure it's up to date)
|
|
908
|
+
const cssContent = await readFile(cssPath, 'utf8');
|
|
909
|
+
await outputFile(cssOutputPath, cssContent);
|
|
910
|
+
|
|
911
|
+
// Generate link tag
|
|
912
|
+
styleLink = `<link rel="stylesheet" href="${cssUrlPath}" />`;
|
|
913
|
+
}
|
|
914
|
+
} catch (e) {
|
|
915
|
+
// ignore
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Get template
|
|
919
|
+
const requestedTemplateName = fileMeta && fileMeta.template;
|
|
920
|
+
const template =
|
|
921
|
+
templates[requestedTemplateName] || templates[DEFAULT_TEMPLATE_NAME];
|
|
922
|
+
|
|
923
|
+
if (!template) {
|
|
924
|
+
return { success: false, message: `Template not found: ${requestedTemplateName || DEFAULT_TEMPLATE_NAME}` };
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Build final HTML
|
|
928
|
+
let finalHtml = template;
|
|
929
|
+
const replacements = {
|
|
930
|
+
"${title}": title,
|
|
931
|
+
"${menu}": menu,
|
|
932
|
+
"${meta}": JSON.stringify(fileMeta),
|
|
933
|
+
"${transformedMetadata}": transformedMetadata,
|
|
934
|
+
"${body}": body,
|
|
935
|
+
"${styleLink}": styleLink,
|
|
936
|
+
"${searchIndex}": "[]",
|
|
937
|
+
"${footer}": footer
|
|
938
|
+
};
|
|
939
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
940
|
+
finalHtml = finalHtml.replace(key, value);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Mark broken links
|
|
944
|
+
finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
|
|
945
|
+
|
|
946
|
+
await outputFile(outputFilename, finalHtml);
|
|
947
|
+
|
|
948
|
+
// JSON output
|
|
949
|
+
const jsonOutputFilename = outputFilename.replace(".html", ".json");
|
|
950
|
+
const sections = type === '.md' ? extractSections(rawBody) : [];
|
|
951
|
+
const jsonObject = {
|
|
952
|
+
name: base,
|
|
953
|
+
url,
|
|
954
|
+
contents: rawBody,
|
|
955
|
+
bodyHtml: body,
|
|
956
|
+
metadata: fileMeta,
|
|
957
|
+
sections,
|
|
958
|
+
transformedMetadata,
|
|
959
|
+
};
|
|
960
|
+
const json = JSON.stringify(jsonObject);
|
|
961
|
+
await outputFile(jsonOutputFilename, json);
|
|
962
|
+
|
|
963
|
+
// XML output
|
|
964
|
+
const xmlOutputFilename = outputFilename.replace(".html", ".xml");
|
|
965
|
+
const xml = `<article>${o2x(jsonObject)}</article>`;
|
|
966
|
+
await outputFile(xmlOutputFilename, xml);
|
|
967
|
+
|
|
968
|
+
// Update hash cache
|
|
969
|
+
updateHash(changedFile, rawBody, hashCache);
|
|
970
|
+
|
|
971
|
+
const elapsed = Date.now() - startTime;
|
|
972
|
+
const shortFile = changedFile.replace(source, '');
|
|
973
|
+
return { success: true, message: `Regenerated ${shortFile} in ${elapsed}ms` };
|
|
974
|
+
} catch (e) {
|
|
975
|
+
return { success: false, message: `Error: ${e.message}` };
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
753
979
|
/**
|
|
754
980
|
* gets { [templateName:String]:[templateBody:String] }
|
|
755
981
|
* meta: full path to meta files (default-template.html, etc)
|
package/src/serve.js
CHANGED
|
@@ -1,10 +1,37 @@
|
|
|
1
1
|
import express from "express";
|
|
2
2
|
import watch from "node-watch";
|
|
3
|
-
import { generate } from "./jobs/generate.js";
|
|
3
|
+
import { generate, regenerateSingleFile, clearWatchCache } from "./jobs/generate.js";
|
|
4
4
|
import { join, resolve } from "path";
|
|
5
5
|
import fs from "fs";
|
|
6
6
|
import { promises } from "fs";
|
|
7
|
-
|
|
7
|
+
import { outputFile } from "fs-extra";
|
|
8
|
+
const { readdir, mkdir, readFile } = promises;
|
|
9
|
+
|
|
10
|
+
// Debounce timer and lock for preventing concurrent regenerations
|
|
11
|
+
let debounceTimer = null;
|
|
12
|
+
let isRegenerating = false;
|
|
13
|
+
const DEBOUNCE_MS = 100; // Wait 100ms after last change before regenerating
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Copy a single CSS file to the output directory
|
|
17
|
+
* @param {string} cssPath - Absolute path to the CSS file
|
|
18
|
+
* @param {string} sourceDir - Source directory root
|
|
19
|
+
* @param {string} outputDir - Output directory root
|
|
20
|
+
*/
|
|
21
|
+
async function copyCssFile(cssPath, sourceDir, outputDir) {
|
|
22
|
+
const startTime = Date.now();
|
|
23
|
+
const relativePath = cssPath.replace(sourceDir, '');
|
|
24
|
+
const outputPath = join(outputDir, relativePath);
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const content = await readFile(cssPath, 'utf8');
|
|
28
|
+
await outputFile(outputPath, content);
|
|
29
|
+
const elapsed = Date.now() - startTime;
|
|
30
|
+
return { success: true, message: `Copied ${relativePath} in ${elapsed}ms` };
|
|
31
|
+
} catch (e) {
|
|
32
|
+
return { success: false, message: `Error copying CSS: ${e.message}` };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
8
35
|
|
|
9
36
|
/**
|
|
10
37
|
* Configurable serve function for CLI and library use
|
|
@@ -32,13 +59,14 @@ export async function serve({
|
|
|
32
59
|
console.log("⏳ Generating site in background...\n");
|
|
33
60
|
|
|
34
61
|
// Initial generation (use _clean flag only for initial generation)
|
|
62
|
+
// This also initializes the watch cache for fast single-file updates
|
|
35
63
|
generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude, _clean })
|
|
36
|
-
.then(() => console.log("\n✅ Initial generation complete.\n"))
|
|
64
|
+
.then(() => console.log("\n✅ Initial generation complete. Fast single-file regeneration enabled.\n"))
|
|
37
65
|
.catch((error) => console.error("Error during initial generation:", error.message));
|
|
38
66
|
|
|
39
67
|
// Watch for changes
|
|
40
68
|
console.log("👀 Watching for changes in:");
|
|
41
|
-
console.log(" Source:", sourceDir, "(
|
|
69
|
+
console.log(" Source:", sourceDir, "(fast single-file mode)");
|
|
42
70
|
console.log(" Meta:", metaDir, "(full rebuild)");
|
|
43
71
|
console.log("\nPress Ctrl+C to stop the server\n");
|
|
44
72
|
|
|
@@ -46,6 +74,7 @@ export async function serve({
|
|
|
46
74
|
watch(metaDir, { recursive: true, filter: /\.(js|json|css|html|md|txt|yml|yaml)$/ }, async (evt, name) => {
|
|
47
75
|
console.log(`Meta files changed! Event: ${evt}, File: ${name}`);
|
|
48
76
|
console.log("Full rebuild required (meta files affect all pages)...");
|
|
77
|
+
clearWatchCache(); // Clear cache since templates/CSS may have changed
|
|
49
78
|
try {
|
|
50
79
|
await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude, _clean: true });
|
|
51
80
|
console.log("Regeneration complete.");
|
|
@@ -54,8 +83,8 @@ export async function serve({
|
|
|
54
83
|
}
|
|
55
84
|
});
|
|
56
85
|
|
|
57
|
-
// Source changes
|
|
58
|
-
//
|
|
86
|
+
// Source changes: try fast single-file regeneration first
|
|
87
|
+
// Falls back to full rebuild for CSS, config, or if cache isn't ready
|
|
59
88
|
watch(sourceDir, {
|
|
60
89
|
recursive: true,
|
|
61
90
|
filter: (f, skip) => {
|
|
@@ -65,26 +94,93 @@ export async function serve({
|
|
|
65
94
|
return /\.(js|json|css|html|md|txt|yml|yaml)$/.test(f);
|
|
66
95
|
}
|
|
67
96
|
}, async (evt, name) => {
|
|
68
|
-
|
|
97
|
+
// Skip if we're already regenerating
|
|
98
|
+
if (isRegenerating) {
|
|
99
|
+
console.log(`⏳ Skipping ${name} (regeneration in progress)`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
69
102
|
|
|
70
|
-
// CSS files
|
|
103
|
+
// CSS files: just copy the file (no longer embedded in HTML)
|
|
71
104
|
const isCssChange = name && name.endsWith('.css');
|
|
105
|
+
// Menu/config changes need full rebuild
|
|
106
|
+
const isMenuChange = name && (name.includes('_menu') || name.includes('menu.'));
|
|
107
|
+
const isConfigChange = name && (name.includes('_config') || name.includes('.ursa'));
|
|
108
|
+
|
|
72
109
|
if (isCssChange) {
|
|
73
|
-
console.log(
|
|
110
|
+
console.log(`\n🎨 CSS change detected: ${name}`);
|
|
111
|
+
isRegenerating = true;
|
|
112
|
+
try {
|
|
113
|
+
const result = await copyCssFile(name, sourceDir + '/', outputDir + '/');
|
|
114
|
+
if (result.success) {
|
|
115
|
+
console.log(`✅ ${result.message}`);
|
|
116
|
+
} else {
|
|
117
|
+
console.log(`⚠️ ${result.message}`);
|
|
118
|
+
}
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.error("Error copying CSS:", error.message);
|
|
121
|
+
} finally {
|
|
122
|
+
isRegenerating = false;
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (isMenuChange || isConfigChange) {
|
|
128
|
+
console.log(`\n📦 ${isMenuChange ? 'Menu' : 'Config'} change detected: ${name}`);
|
|
129
|
+
console.log("Full rebuild required...");
|
|
130
|
+
clearWatchCache();
|
|
131
|
+
isRegenerating = true;
|
|
74
132
|
try {
|
|
75
133
|
await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude, _clean: true });
|
|
76
134
|
console.log("Regeneration complete.");
|
|
77
135
|
} catch (error) {
|
|
78
136
|
console.error("Error during regeneration:", error.message);
|
|
137
|
+
} finally {
|
|
138
|
+
isRegenerating = false;
|
|
79
139
|
}
|
|
80
|
-
|
|
81
|
-
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Try fast single-file regeneration for article files
|
|
144
|
+
const isArticle = name && /\.(md|txt|yml)$/.test(name);
|
|
145
|
+
if (isArticle) {
|
|
146
|
+
console.log(`\n⚡ Fast regeneration: ${name}`);
|
|
147
|
+
isRegenerating = true;
|
|
82
148
|
try {
|
|
149
|
+
const result = await regenerateSingleFile(name, {
|
|
150
|
+
_source: sourceDir,
|
|
151
|
+
_meta: metaDir,
|
|
152
|
+
_output: outputDir
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (result.success) {
|
|
156
|
+
console.log(`✅ ${result.message}`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Fall back to full rebuild if single-file failed
|
|
161
|
+
console.log(`⚠️ ${result.message}`);
|
|
162
|
+
console.log("Falling back to full rebuild...");
|
|
83
163
|
await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude });
|
|
84
164
|
console.log("Regeneration complete.");
|
|
85
165
|
} catch (error) {
|
|
86
166
|
console.error("Error during regeneration:", error.message);
|
|
167
|
+
} finally {
|
|
168
|
+
isRegenerating = false;
|
|
87
169
|
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Non-article files - incremental build
|
|
174
|
+
console.log(`\n📄 Non-article change: ${name}`);
|
|
175
|
+
console.log("Running incremental rebuild...");
|
|
176
|
+
isRegenerating = true;
|
|
177
|
+
try {
|
|
178
|
+
await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude });
|
|
179
|
+
console.log("Regeneration complete.");
|
|
180
|
+
} catch (error) {
|
|
181
|
+
console.error("Error during regeneration:", error.message);
|
|
182
|
+
} finally {
|
|
183
|
+
isRegenerating = false;
|
|
88
184
|
}
|
|
89
185
|
});
|
|
90
186
|
}
|