@kenjura/ursa 0.47.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,22 @@
1
+ # 0.49.0
2
+ 2025-12-20
3
+
4
+ - Fixed more instances of false inactive links, this time in wikitext files (.txt)
5
+
6
+
7
+ # 0.48.0
8
+ 2025-12-20
9
+
10
+ - **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.
11
+ - **Fast CSS Updates**: CSS file changes in watch mode now just copy the file to output (~1ms) instead of triggering a full rebuild
12
+ - **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
13
+ - **Clickable Folder Links**: All folders in the navigation menu are now clickable links (auto-index ensures every folder has an index.html)
14
+ - **Menu Collapse Fix**: Fixed issue where clicking the caret on a folder containing the current page wouldn't collapse it
15
+ - **URL Encoding Fix**: Fixed menu not highlighting current page when URLs contain spaces or special characters
16
+ - **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)
17
+ - **WikiText Link Fix**: Fixed wikitext links (in .txt files) being incorrectly marked as inactive. Link validation is now handled centrally by the link validator after HTML generation.
18
+ - **Folder/Index Link Fix**: Links to folders containing a `(foldername).md` file (instead of `index.md`) are now correctly recognized as valid
19
+
1
20
  # 0.47.0
2
21
  2025-12-20
3
22
 
@@ -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
- <style>
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
- if (expandedLevel1.has(path)) {
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
@@ -2,7 +2,7 @@
2
2
  "name": "@kenjura/ursa",
3
3
  "author": "Andrew London <andrew@kenjura.com>",
4
4
  "type": "module",
5
- "version": "0.47.0",
5
+ "version": "0.49.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
@@ -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
- if (hasIndexFile(item.path)) {
166
- // Construct proper path - relativePath already starts with /
167
- const cleanPath = relativePath.startsWith('/') ? relativePath : '/' + relativePath;
168
- rawHref = `${cleanPath}/index.html`.replace(/\/\//g, '/');
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 contents of the first found file, or null if not found.
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 contents or null
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 (await import('fs/promises')).readFile(candidate, "utf8");
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
- import { extname, dirname, join, normalize, posix } from "path";
1
+ import { extname, dirname, join, normalize, posix, basename } 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());
@@ -30,6 +38,46 @@ export function buildValidPaths(sourceFiles, source) {
30
38
  validPaths.add((dirPath + "/").toLowerCase());
31
39
  validPaths.add((dirPath + "/index.html").toLowerCase());
32
40
  }
41
+
42
+ // Handle (foldername).md files - they get promoted to index.html by auto-index
43
+ // e.g., /foo/bar/bar.md becomes /foo/bar/index.html (bar.html promoted to index.html)
44
+ const fileName = basename(relativePath); // e.g., "bar" from "/foo/bar/bar"
45
+ const parentDir = dirname(relativePath); // e.g., "/foo/bar" from "/foo/bar/bar"
46
+ const parentDirName = basename(parentDir); // e.g., "bar" from "/foo/bar"
47
+
48
+ if (fileName === parentDirName) {
49
+ // This file has same name as its parent folder - it will be promoted to index.html
50
+ validPaths.add(parentDir.toLowerCase());
51
+ validPaths.add((parentDir + "/").toLowerCase());
52
+ validPaths.add((parentDir + "/index.html").toLowerCase());
53
+ }
54
+ }
55
+
56
+ // Add all directories as valid paths (they get auto-generated index.html)
57
+ for (const dir of directories) {
58
+ let relativePath = dir.replace(source, "");
59
+
60
+ // Normalize: ensure leading slash
61
+ if (!relativePath.startsWith("/")) {
62
+ relativePath = "/" + relativePath;
63
+ }
64
+
65
+ // Remove trailing slash for consistency
66
+ if (relativePath.endsWith("/")) {
67
+ relativePath = relativePath.slice(0, -1);
68
+ }
69
+
70
+ // Decode URI components
71
+ try {
72
+ relativePath = decodeURIComponent(relativePath);
73
+ } catch (e) {
74
+ // Ignore decode errors
75
+ }
76
+
77
+ // Add directory paths - all folders now have index.html (auto-generated if needed)
78
+ validPaths.add(relativePath.toLowerCase());
79
+ validPaths.add((relativePath + "/").toLowerCase());
80
+ validPaths.add((relativePath + "/index.html").toLowerCase());
33
81
  }
34
82
 
35
83
  // Add root
@@ -10,8 +10,6 @@ export function wikiToHtml({ wikitext, articleName, args } = {}) {
10
10
  const linkbase = ("/" + db + "/").replace(/\/\//g, "/");
11
11
  const imageroot = ("/" + db + "/img/").replace(/\/\//g, "/");
12
12
 
13
- const allArticles = args.allArticles || [];
14
-
15
13
  // console.log('wikitext=',wikitext);
16
14
  var html = String(wikitext);
17
15
  // instance.article = article;
@@ -226,31 +224,19 @@ export function wikiToHtml({ wikitext, articleName, args } = {}) {
226
224
  if (!anchor) anchor = "";
227
225
  else anchor = "#" + anchor;
228
226
 
229
- var active = true;
230
-
231
- active = !!allArticles.find((article) => article.match(articleName));
227
+ // Note: Link validation (active/inactive status) is now handled by linkValidator.js
228
+ // after HTML generation, so we don't set active/inactive class here.
232
229
 
233
230
  if (articleName.indexOf("/") >= 0) {
234
231
  // assume the link is fully formed
235
- return `<a class="wikiLink${
236
- active ? " active" : ""
237
- } data-articleName="${articleName}" href="${articleName}">${
232
+ return `<a class="wikiLink" data-articleName="${articleName}" href="${articleName}">${
238
233
  displayName || articleName
239
234
  }</a>`;
240
235
  } else {
241
236
  var link = linkbase + articleName + anchor;
242
237
 
243
- // not sure what this did, but I need a new handler for this case
244
- // if (articleName.indexOf('/')>-1) {
245
- // link = '/'+articleName+anchor;
246
- // displayName = articleName.substr(articleName.indexOf('/')+1);
247
- // console.log('link=',link);
248
- // }
249
-
250
238
  return (
251
- '<a class="wikiLink ' +
252
- (active ? "active" : "inactive") +
253
- '" data-articleName="' +
239
+ '<a class="wikiLink" data-articleName="' +
254
240
  articleName +
255
241
  '" href="' +
256
242
  link +
@@ -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
- const validPaths = buildValidPaths(allSourceFilenamesThatAreArticles, source);
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 embeddedStyle = "";
417
+ // Find nearest style.css or _style.css up the tree and copy to output
418
+ let styleLink = "";
385
419
  try {
386
- const css = await findStyleCss(resolve(_source, dir));
387
- if (css) {
388
- embeddedStyle = css;
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
- "${embeddedStyle}": embeddedStyle,
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
- "${embeddedStyle}": "",
569
+ "${styleLink}": "",
524
570
  "${footer}": footer
525
571
  };
526
572
  for (const [key, value] of Object.entries(replacements)) {
@@ -585,6 +631,19 @@ export async function generate({
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');
@@ -743,7 +802,7 @@ async function generateAutoIndices(output, directories, source, templates, menu,
743
802
  "${title}": folderDisplayName,
744
803
  "${meta}": "{}",
745
804
  "${transformedMetadata}": "",
746
- "${embeddedStyle}": "",
805
+ "${styleLink}": "",
747
806
  "${footer}": footer
748
807
  };
749
808
  for (const [key, value] of Object.entries(replacements)) {
@@ -766,6 +825,157 @@ async function generateAutoIndices(output, directories, source, templates, menu,
766
825
  }
767
826
  }
768
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
+
769
979
  /**
770
980
  * gets { [templateName:String]:[templateBody:String] }
771
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
- const { readdir, mkdir } = promises;
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, "(incremental)");
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 use incremental mode (only regenerate changed files)
58
- // Exception: CSS changes require full rebuild since they're embedded in all pages
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
- console.log(`Source files changed! Event: ${evt}, File: ${name}`);
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 affect all pages (embedded styles), so trigger full rebuild
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("CSS change detected - full rebuild required...");
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
- } else {
81
- console.log("Incremental rebuild...");
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
  }