@kenjura/ursa 0.53.0 → 0.55.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,5 +1,17 @@
1
+ # 0.55.0
2
+ 2025-12-21
3
+
4
+ - custom menus (menu.md|txt) override the automenu when present
5
+
6
+ # 0.54.0
7
+ 2025-12-21
8
+
9
+ - added cache-busting timestamps to static files
10
+ - cleaned up generate.js by moving helper functions to separate files
11
+
12
+
1
13
  # 0.53.0
2
- 2025-01-01
14
+ 2025-12-21
3
15
 
4
16
  ### Menu Size Optimization
5
17
  - **External Menu JSON**: Menu data is now stored in `/public/menu-data.json` instead of being embedded in every HTML file. This dramatically reduces HTML file sizes for sites with large folder structures (e.g., from 2-3MB per file down to ~50KB).
package/meta/default.css CHANGED
@@ -74,16 +74,46 @@ nav#nav-global {
74
74
  opacity: 0.7;
75
75
  }
76
76
 
77
- input#global-search {
77
+ .search-wrapper {
78
+ position: relative;
78
79
  width: var(--article-width);
79
80
  max-width: calc(100% - 16px);
81
+ justify-self: center;
82
+ }
83
+
84
+ input#global-search {
85
+ width: 100%;
80
86
  height: calc(var(--global-nav-height) - 16px);
81
87
  border-radius: 5px;
82
88
  background: rgba(0,0,0,0.25);
83
89
  color: var(--text-color);
84
90
  border-width: 0px;
85
- padding: 0 1rem;
86
- justify-self: center;
91
+ padding: 0 2.5rem 0 1rem;
92
+ box-sizing: border-box;
93
+ }
94
+
95
+ .search-clear-button {
96
+ position: absolute;
97
+ right: 8px;
98
+ top: 50%;
99
+ transform: translateY(-50%);
100
+ background: none;
101
+ border: none;
102
+ color: var(--text-color);
103
+ font-size: 1.25rem;
104
+ line-height: 1;
105
+ cursor: pointer;
106
+ padding: 4px 8px;
107
+ opacity: 0.6;
108
+ transition: opacity 0.2s ease;
109
+ }
110
+
111
+ .search-clear-button:hover {
112
+ opacity: 1;
113
+ }
114
+
115
+ .search-clear-button.hidden {
116
+ display: none;
87
117
  }
88
118
 
89
119
  .avatar {
@@ -152,10 +182,10 @@ nav#nav-global {
152
182
 
153
183
  nav#nav-main {
154
184
  position: fixed;
155
- top: calc(var(--global-nav-height) + 0.5rem);
185
+ top: calc(var(--global-nav-height));
156
186
  left: 0;
157
187
  width: 260px;
158
- max-height: calc(100vh - var(--global-nav-height) - 1rem);
188
+ max-height: calc(100vh - var(--global-nav-height));
159
189
  overflow-y: auto;
160
190
  padding: 0.5rem 0;
161
191
  font-size: 0.9rem;
@@ -379,12 +409,13 @@ nav#nav-main {
379
409
  /* Table of Contents - Right side navigation */
380
410
  nav#nav-toc {
381
411
  position: fixed;
382
- top: calc(var(--global-nav-height) + 1rem);
383
- right: 1rem;
412
+ top: calc(var(--global-nav-height));
413
+ right: 0;
384
414
  max-width: calc(50vw - var(--article-width) / 2 - 2rem);
385
- max-height: calc(100vh - var(--global-nav-height) - 2rem);
415
+ max-height: calc(100vh - var(--global-nav-height));
386
416
  overflow-y: auto;
387
417
  z-index: 100;
418
+ padding: 0.5rem 0;
388
419
 
389
420
  ul {
390
421
  list-style: none;
@@ -481,12 +512,16 @@ footer#site-footer {
481
512
  nav#nav-global {
482
513
  display: flex;
483
514
 
484
- input#global-search {
515
+ .search-wrapper {
485
516
  flex: 1;
486
517
  width: auto;
487
518
  max-width: none;
488
519
  margin: 0 8px;
489
520
  }
521
+
522
+ input#global-search {
523
+ width: 100%;
524
+ }
490
525
  }
491
526
  article#main-content {
492
527
  width: calc(100vw - 2rem);
package/meta/menu.js CHANGED
@@ -2,6 +2,10 @@ document.addEventListener('DOMContentLoaded', () => {
2
2
  const navMain = document.querySelector('nav#nav-main');
3
3
  if (!navMain) return;
4
4
 
5
+ // Check for custom menu
6
+ const customMenuPath = document.body.dataset.customMenu;
7
+ const isCustomMenu = !!customMenuPath;
8
+
5
9
  // State - menu data will be loaded asynchronously
6
10
  let menuData = null;
7
11
  let menuDataLoaded = false;
@@ -36,13 +40,16 @@ document.addEventListener('DOMContentLoaded', () => {
36
40
  /**
37
41
  * Load menu data from external JSON file
38
42
  * This is done asynchronously to avoid blocking page render
43
+ * Will load custom menu if data-custom-menu is present, otherwise loads auto-menu
39
44
  */
40
45
  async function loadMenuData() {
41
46
  if (menuDataLoaded || menuDataLoading) return;
42
47
  menuDataLoading = true;
43
48
 
44
49
  try {
45
- const response = await fetch('/public/menu-data.json');
50
+ // Use custom menu path if present, otherwise use auto-menu
51
+ const menuUrl = customMenuPath || '/public/menu-data.json';
52
+ const response = await fetch(menuUrl);
46
53
  if (!response.ok) {
47
54
  throw new Error(`HTTP ${response.status}`);
48
55
  }
@@ -50,7 +57,11 @@ document.addEventListener('DOMContentLoaded', () => {
50
57
  menuDataLoaded = true;
51
58
 
52
59
  // Re-render menu now that we have full data
53
- initializeFromCurrentPage();
60
+ if (isCustomMenu) {
61
+ renderCustomMenu();
62
+ } else {
63
+ initializeFromCurrentPage();
64
+ }
54
65
  } catch (error) {
55
66
  console.error('Failed to load menu data:', error);
56
67
  menuDataLoaded = true; // Mark as loaded to prevent retries
@@ -231,6 +242,230 @@ document.addEventListener('DOMContentLoaded', () => {
231
242
  attachClickHandlers();
232
243
  }
233
244
 
245
+ /**
246
+ * Get the custom menu root folder name and parent URL from the current location
247
+ * @returns {{name: string, parentUrl: string}} - Menu root name and parent URL
248
+ */
249
+ function getCustomMenuInfo() {
250
+ const pathname = window.location.pathname;
251
+ // Remove the filename to get the directory
252
+ const pathParts = pathname.split('/').filter(Boolean);
253
+
254
+ // Find the menu root by looking at the custom menu path
255
+ // e.g., /public/custom-menu-systems-system6.json -> systems/system6
256
+ if (customMenuPath) {
257
+ const match = customMenuPath.match(/custom-menu-(.+)\.json$/);
258
+ if (match) {
259
+ const menuId = match[1];
260
+ // Convert dashes back to path (e.g., systems-system6 -> systems/system6)
261
+ // But we need to be careful - the ID uses dashes for path separators
262
+ // The actual menu root is the folder containing the menu file
263
+ // We can infer it from the current URL path
264
+
265
+ // Find common prefix between current path and menu ID
266
+ const menuParts = menuId.split('-');
267
+ let matchedParts = [];
268
+ let currentIndex = 0;
269
+
270
+ for (let i = 0; i < pathParts.length && currentIndex < menuParts.length; i++) {
271
+ // Check if this path part matches the next menu ID parts
272
+ let pathPart = pathParts[i].toLowerCase();
273
+ let menuPart = menuParts[currentIndex].toLowerCase();
274
+
275
+ // Handle multi-word folder names (e.g., "Galactic Horizons" -> "GalacticHorizons")
276
+ if (pathPart.replace(/\s+/g, '').toLowerCase() === menuPart.toLowerCase()) {
277
+ matchedParts.push(pathParts[i]);
278
+ currentIndex++;
279
+ } else if (pathPart === menuPart) {
280
+ matchedParts.push(pathParts[i]);
281
+ currentIndex++;
282
+ }
283
+ }
284
+
285
+ if (matchedParts.length > 0) {
286
+ const menuRootName = matchedParts[matchedParts.length - 1];
287
+ // Parent URL is one level up from the menu root
288
+ const parentParts = matchedParts.slice(0, -1);
289
+ const parentUrl = parentParts.length > 0
290
+ ? '/' + parentParts.join('/') + '/index.html'
291
+ : '/index.html';
292
+
293
+ return { name: menuRootName, parentUrl };
294
+ }
295
+ }
296
+ }
297
+
298
+ // Fallback: use the first directory in the current path
299
+ if (pathParts.length > 0) {
300
+ return {
301
+ name: pathParts[0],
302
+ parentUrl: '/index.html'
303
+ };
304
+ }
305
+
306
+ return { name: 'Menu', parentUrl: '/index.html' };
307
+ }
308
+
309
+ /**
310
+ * Render custom menu with two-level display
311
+ * Custom menus use a simpler structure but same visual style
312
+ */
313
+ function renderCustomMenu() {
314
+ // Wait for menu data to load
315
+ if (!menuData) {
316
+ menuContainer.innerHTML = '<li class="menu-loading">Loading menu...</li>';
317
+ return;
318
+ }
319
+
320
+ // Show breadcrumb for custom menus with menu root name and back button
321
+ if (breadcrumb) {
322
+ const menuInfo = getCustomMenuInfo();
323
+ breadcrumb.style.display = 'flex';
324
+ if (currentPathSpan) {
325
+ currentPathSpan.textContent = menuInfo.name;
326
+ }
327
+ // Update back button to navigate to parent
328
+ if (backButton) {
329
+ backButton.onclick = (e) => {
330
+ e.preventDefault();
331
+ window.location.href = menuInfo.parentUrl;
332
+ };
333
+ }
334
+ // Hide home button for custom menus (back is enough)
335
+ if (homeButton) {
336
+ homeButton.style.display = 'none';
337
+ }
338
+ }
339
+
340
+ // Build HTML for level 1 and level 2 (max 2 levels shown)
341
+ let html = '';
342
+ for (const item of menuData) {
343
+ const isActive = isCurrentPage(item);
344
+ const activeClass = isActive ? ' current-menu-item' : '';
345
+ const hasChildrenClass = item.hasChildren ? ' has-children' : '';
346
+
347
+ // Auto-expand items that contain the current page
348
+ const containsCurrentPage = item.hasChildren && isCurrentPageInSubtree(item);
349
+ const isExpanded = expandedLevel1.has(item.path) || (containsCurrentPage && !collapsedLevel1.has(item.path));
350
+ const expandedClass = isExpanded ? ' expanded' : '';
351
+ const caretIndicator = item.hasChildren
352
+ ? `<span class="menu-caret">${isExpanded ? '▼' : '▶'}</span>`
353
+ : '';
354
+
355
+ const labelHtml = item.href
356
+ ? `<a href="${item.href}" class="menu-label">${item.label}</a>`
357
+ : `<span class="menu-label">${item.label}</span>`;
358
+
359
+ html += `
360
+ <li class="menu-item level-1${hasChildrenClass}${activeClass}${expandedClass}" data-path="${item.path}">
361
+ <div class="menu-item-row">
362
+ ${item.icon || '<span class="menu-icon">📄</span>'}
363
+ ${labelHtml}
364
+ ${caretIndicator}
365
+ </div>`;
366
+
367
+ // Show children if expanded
368
+ if (item.children && item.children.length > 0 && isExpanded) {
369
+ html += '<ul class="menu-sublevel">';
370
+ for (const child of item.children) {
371
+ const childActive = isCurrentPage(child);
372
+ const childActiveClass = childActive ? ' current-menu-item' : '';
373
+ const childHasChildren = child.hasChildren ? ' has-children' : '';
374
+ // Level-2 items with children get triple-dot indicator
375
+ const childMoreIndicator = child.hasChildren ? '<span class="menu-more" title="Has sub-items">⋮</span>' : '';
376
+
377
+ const childLabelHtml = child.href
378
+ ? `<a href="${child.href}" class="menu-label">${child.label}</a>`
379
+ : `<span class="menu-label">${child.label}</span>`;
380
+
381
+ html += `
382
+ <li class="menu-item level-2${childHasChildren}${childActiveClass}" data-path="${child.path}">
383
+ <div class="menu-item-row">
384
+ ${child.icon || '<span class="menu-icon">📄</span>'}
385
+ ${childLabelHtml}
386
+ ${childMoreIndicator}
387
+ </div>
388
+ </li>`;
389
+ }
390
+ html += '</ul>';
391
+ }
392
+
393
+ html += '</li>';
394
+ }
395
+
396
+ menuContainer.innerHTML = html;
397
+
398
+ // Attach click handlers for custom menu
399
+ attachCustomMenuClickHandlers();
400
+ }
401
+
402
+ /**
403
+ * Check if current page is within an item's subtree (for custom menus)
404
+ */
405
+ function isCurrentPageInSubtree(item) {
406
+ if (isCurrentPage(item)) return true;
407
+ if (item.children) {
408
+ for (const child of item.children) {
409
+ if (isCurrentPageInSubtree(child)) return true;
410
+ }
411
+ }
412
+ return false;
413
+ }
414
+
415
+ /**
416
+ * Attach click handlers for custom menu items
417
+ */
418
+ function attachCustomMenuClickHandlers() {
419
+ const menuItems = menuContainer.querySelectorAll('.menu-item.level-1.has-children');
420
+ menuItems.forEach(li => {
421
+ const row = li.querySelector('.menu-item-row');
422
+ const caret = li.querySelector('.menu-caret');
423
+ const link = li.querySelector('a.menu-label');
424
+ const path = li.dataset.path;
425
+
426
+ const toggleExpand = (e) => {
427
+ e.preventDefault();
428
+ e.stopPropagation();
429
+
430
+ const isCurrentlyExpanded = li.classList.contains('expanded');
431
+
432
+ if (isCurrentlyExpanded) {
433
+ expandedLevel1.delete(path);
434
+ collapsedLevel1.add(path);
435
+ } else {
436
+ expandedLevel1.add(path);
437
+ collapsedLevel1.delete(path);
438
+ }
439
+ renderCustomMenu();
440
+ };
441
+
442
+ if (caret) {
443
+ caret.addEventListener('click', toggleExpand);
444
+ }
445
+
446
+ // If clicking the link on current page, toggle instead of navigate
447
+ if (link) {
448
+ link.addEventListener('click', (e) => {
449
+ const linkHref = link.getAttribute('href');
450
+ const currentHref = window.location.pathname;
451
+ const normalizedLinkHref = linkHref.replace(/\/index\.html$/, '').replace(/\.html$/, '');
452
+ const normalizedCurrentHref = currentHref.replace(/\/index\.html$/, '').replace(/\.html$/, '');
453
+
454
+ if (normalizedLinkHref === normalizedCurrentHref) {
455
+ toggleExpand(e);
456
+ }
457
+ });
458
+ }
459
+
460
+ // Clicking row (not link) toggles
461
+ row.addEventListener('click', (e) => {
462
+ if (!e.target.closest('a')) {
463
+ toggleExpand(e);
464
+ }
465
+ });
466
+ });
467
+ }
468
+
234
469
  // Check if an item matches the current page
235
470
  function isCurrentPage(item) {
236
471
  if (!item.href) return false;
package/meta/search.js CHANGED
@@ -18,12 +18,31 @@ class GlobalSearch {
18
18
  }
19
19
 
20
20
  init() {
21
+ this.wrapSearchInput();
21
22
  this.createResultsContainer();
23
+ this.createClearButton();
22
24
  this.bindEvents();
23
25
  // Start loading the index immediately (but don't block)
24
26
  this.loadSearchIndex();
25
27
  }
26
28
 
29
+ wrapSearchInput() {
30
+ // Wrap the search input in a container for positioning the clear button
31
+ this.searchWrapper = document.createElement('div');
32
+ this.searchWrapper.className = 'search-wrapper';
33
+ this.searchInput.parentNode.insertBefore(this.searchWrapper, this.searchInput);
34
+ this.searchWrapper.appendChild(this.searchInput);
35
+ }
36
+
37
+ createClearButton() {
38
+ this.clearButton = document.createElement('button');
39
+ this.clearButton.className = 'search-clear-button hidden';
40
+ this.clearButton.type = 'button';
41
+ this.clearButton.setAttribute('aria-label', 'Clear search');
42
+ this.clearButton.innerHTML = '×';
43
+ this.searchWrapper.appendChild(this.clearButton);
44
+ }
45
+
27
46
  createResultsContainer() {
28
47
  this.searchResults = document.createElement('div');
29
48
  this.searchResults.id = 'search-results';
@@ -72,6 +91,13 @@ class GlobalSearch {
72
91
  // Input events
73
92
  this.searchInput.addEventListener('input', (e) => {
74
93
  this.handleSearch(e.target.value);
94
+ this.updateClearButtonVisibility();
95
+ });
96
+
97
+ // Clear button click
98
+ this.clearButton.addEventListener('click', (e) => {
99
+ e.preventDefault();
100
+ this.clearSearch();
75
101
  });
76
102
 
77
103
  // Keyboard navigation
@@ -247,6 +273,21 @@ class GlobalSearch {
247
273
  this.currentSelection = -1;
248
274
  }
249
275
 
276
+ clearSearch() {
277
+ this.searchInput.value = '';
278
+ this.hideResults();
279
+ this.updateClearButtonVisibility();
280
+ this.searchInput.focus();
281
+ }
282
+
283
+ updateClearButtonVisibility() {
284
+ if (this.searchInput.value.trim()) {
285
+ this.clearButton.classList.remove('hidden');
286
+ } else {
287
+ this.clearButton.classList.add('hidden');
288
+ }
289
+ }
290
+
250
291
  handleKeydown(e) {
251
292
  const items = this.searchResults.querySelectorAll('.search-result-item');
252
293
 
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.53.0",
5
+ "version": "0.55.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
@@ -0,0 +1,197 @@
1
+ // Auto-index generation helpers for build
2
+ import { existsSync } from "fs";
3
+ import { readdir, readFile } from "fs/promises";
4
+ import { basename, dirname, extname, join } from "path";
5
+ import { outputFile } from "fs-extra";
6
+ import { findStyleCss } from "../findStyleCss.js";
7
+ import { toTitleCase } from "./titleCase.js";
8
+ import { addTimestampToHtmlStaticRefs } from "./cacheBust.js";
9
+
10
+ /**
11
+ * Generate automatic index.html files for folders that don't have one
12
+ * @param {string} output - Output directory path
13
+ * @param {string[]} directories - List of source directories
14
+ * @param {string} source - Source directory path
15
+ * @param {object} templates - Template map
16
+ * @param {string} menu - Rendered menu HTML
17
+ * @param {string} footer - Footer HTML
18
+ * @param {string[]} generatedArticles - List of source article paths that were generated
19
+ * @param {Set<string>} copiedCssFiles - Set of CSS files already copied to output
20
+ * @param {Set<string>} existingHtmlFiles - Set of existing HTML files in source (relative paths)
21
+ * @param {string} cacheBustTimestamp - Cache-busting timestamp
22
+ * @param {object} progress - Progress reporter instance
23
+ */
24
+ export async function generateAutoIndices(output, directories, source, templates, menu, footer, generatedArticles, copiedCssFiles, existingHtmlFiles, cacheBustTimestamp, progress) {
25
+ // Alternate index file names to look for (in priority order)
26
+ const INDEX_ALTERNATES = ['_index.html', 'home.html', '_home.html'];
27
+
28
+ // Normalize paths (remove trailing slashes for consistent replacement)
29
+ const sourceNorm = source.replace(/\/+$/, '');
30
+ const outputNorm = output.replace(/\/+$/, '');
31
+
32
+ // Build set of directories that already have an index.html from a source index.md/txt/yml
33
+ const dirsWithSourceIndex = new Set();
34
+ for (const articlePath of generatedArticles) {
35
+ const base = basename(articlePath, extname(articlePath));
36
+ if (base === 'index') {
37
+ const dir = dirname(articlePath);
38
+ const outputDir = dir.replace(sourceNorm, outputNorm);
39
+ dirsWithSourceIndex.add(outputDir);
40
+ }
41
+ }
42
+
43
+ // Get all output directories (including root)
44
+ const outputDirs = new Set([outputNorm]);
45
+ for (const dir of directories) {
46
+ // Handle both with and without trailing slash in source
47
+ const outputDir = dir.replace(sourceNorm, outputNorm);
48
+ outputDirs.add(outputDir);
49
+ }
50
+
51
+ let generatedCount = 0;
52
+ let renamedCount = 0;
53
+ let skippedHtmlCount = 0;
54
+
55
+ for (const dir of outputDirs) {
56
+ const indexPath = join(dir, 'index.html');
57
+
58
+ // Skip if this directory had a source index.md/txt/yml that was already processed
59
+ if (dirsWithSourceIndex.has(dir)) {
60
+ continue;
61
+ }
62
+
63
+ // Check if there's an existing index.html in the source directory (don't overwrite it)
64
+ const sourceDir = dir.replace(outputNorm, sourceNorm);
65
+ const relativeIndexPath = join(sourceDir, 'index.html').replace(sourceNorm + '/', '');
66
+ if (existingHtmlFiles && existingHtmlFiles.has(relativeIndexPath)) {
67
+ skippedHtmlCount++;
68
+ continue; // Don't overwrite existing source HTML
69
+ }
70
+
71
+ // Skip if index.html already exists in output (e.g., created by previous run)
72
+ if (existsSync(indexPath)) {
73
+ continue;
74
+ }
75
+
76
+ // Get folder name for (foldername).html check
77
+ const folderName = basename(dir);
78
+ const folderNameAlternate = `${folderName}.html`;
79
+
80
+ // Check for alternate index files
81
+ let foundAlternate = null;
82
+ for (const alt of [...INDEX_ALTERNATES, folderNameAlternate]) {
83
+ const altPath = join(dir, alt);
84
+ if (existsSync(altPath)) {
85
+ foundAlternate = altPath;
86
+ break;
87
+ }
88
+ }
89
+
90
+ if (foundAlternate) {
91
+ // Rename/copy alternate to index.html
92
+ try {
93
+ const content = await readFile(foundAlternate, 'utf8');
94
+ await outputFile(indexPath, content);
95
+ renamedCount++;
96
+ progress.status('Auto-index', `Promoted ${basename(foundAlternate)} → index.html in ${dir.replace(outputNorm, '') || '/'}`);
97
+ } catch (e) {
98
+ progress.log(`Error promoting ${foundAlternate} to index.html: ${e.message}`);
99
+ }
100
+ } else {
101
+ // Generate a simple index listing direct children
102
+ try {
103
+ const children = await readdir(dir, { withFileTypes: true });
104
+
105
+ // Filter to only include relevant files and folders
106
+ const items = children
107
+ .filter(child => {
108
+ // Skip hidden files and index alternates we just checked
109
+ if (child.name.startsWith('.')) return false;
110
+ if (child.name === 'index.html') return false;
111
+ // Include directories and html files
112
+ return child.isDirectory() || child.name.endsWith('.html');
113
+ })
114
+ .map(child => {
115
+ const isDir = child.isDirectory();
116
+ const name = isDir ? child.name : child.name.replace('.html', '');
117
+ const href = isDir ? `${child.name}/` : child.name;
118
+ const displayName = toTitleCase(name);
119
+ const icon = isDir ? '📁' : '📄';
120
+ return `<li>${icon} <a href="${href}">${displayName}</a></li>`;
121
+ });
122
+
123
+ if (items.length === 0) {
124
+ // Empty folder, skip generating index
125
+ continue;
126
+ }
127
+
128
+ const folderDisplayName = dir === outputNorm ? 'Home' : toTitleCase(folderName);
129
+ const indexHtml = `<h1>${folderDisplayName}</h1>\n<ul class="auto-index">\n${items.join('\n')}\n</ul>`;
130
+
131
+ const template = templates["default-template"];
132
+ if (!template) {
133
+ progress.log(`Warning: No default template for auto-index in ${dir}`);
134
+ continue;
135
+ }
136
+
137
+ // Find nearest style.css for this directory
138
+ let styleLink = "";
139
+ try {
140
+ // Map output dir back to source dir to find style.css
141
+ const sourceDir = dir.replace(outputNorm, sourceNorm);
142
+ const cssPath = await findStyleCss(sourceDir);
143
+ if (cssPath) {
144
+ // Calculate output path for the CSS file (mirrors source structure)
145
+ const cssOutputPath = cssPath.replace(sourceNorm, outputNorm);
146
+ const cssUrlPath = '/' + cssPath.replace(sourceNorm, '');
147
+
148
+ // Copy CSS file if not already copied
149
+ if (!copiedCssFiles.has(cssPath)) {
150
+ const cssContent = await readFile(cssPath, 'utf8');
151
+ await outputFile(cssOutputPath, cssContent);
152
+ copiedCssFiles.add(cssPath);
153
+ }
154
+
155
+ // Generate link tag
156
+ styleLink = `<link rel="stylesheet" href="${cssUrlPath}" />`;
157
+ }
158
+ } catch (e) {
159
+ // ignore CSS lookup errors
160
+ }
161
+
162
+ let finalHtml = template;
163
+ const replacements = {
164
+ "${menu}": menu,
165
+ "${body}": indexHtml,
166
+ "${searchIndex}": "[]",
167
+ "${title}": folderDisplayName,
168
+ "${meta}": "{}",
169
+ "${transformedMetadata}": "",
170
+ "${styleLink}": styleLink,
171
+ "${footer}": footer
172
+ };
173
+ for (const [key, value] of Object.entries(replacements)) {
174
+ finalHtml = finalHtml.replace(key, value);
175
+ }
176
+ // Add cache-busting timestamps to static file references
177
+ finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
178
+
179
+ await outputFile(indexPath, finalHtml);
180
+ generatedCount++;
181
+ progress.status('Auto-index', `Generated index.html for ${dir.replace(outputNorm, '') || '/'}`);
182
+ } catch (e) {
183
+ progress.log(`Error generating auto-index for ${dir}: ${e.message}`);
184
+ }
185
+ }
186
+ }
187
+
188
+ if (generatedCount > 0 || renamedCount > 0 || skippedHtmlCount > 0) {
189
+ let summary = `${generatedCount} generated, ${renamedCount} promoted`;
190
+ if (skippedHtmlCount > 0) {
191
+ summary += `, ${skippedHtmlCount} skipped (existing HTML)`;
192
+ }
193
+ progress.done('Auto-index', summary);
194
+ } else {
195
+ progress.log(`Auto-index: All folders already have index.html`);
196
+ }
197
+ }
@@ -0,0 +1,19 @@
1
+ // Batch processing helpers for build
2
+
3
+ /**
4
+ * Process items in batches to limit memory usage
5
+ * @param {Array} items - Items to process
6
+ * @param {Function} processor - Async function to process each item
7
+ * @param {number} batchSize - Max concurrent operations
8
+ */
9
+ export async function processBatched(items, processor, batchSize = 50) {
10
+ const results = [];
11
+ for (let i = 0; i < items.length; i += batchSize) {
12
+ const batch = items.slice(i, i + batchSize);
13
+ const batchResults = await Promise.all(batch.map(processor));
14
+ results.push(...batchResults);
15
+ // Allow GC to run between batches
16
+ if (global.gc) global.gc();
17
+ }
18
+ return results;
19
+ }