@kenjura/ursa 0.61.1 → 0.62.2

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,34 @@
1
+ # 0.62.2
2
+ 2026-01-29
3
+
4
+ - Fixed CI issue with pnpm using npm-specific syntax
5
+
6
+ # 0.62.1
7
+ 2026-01-29
8
+
9
+ - CI now uses pnpm to avoid redundant package managers
10
+
11
+ # 0.62.0
12
+ 2026-01-29
13
+
14
+ - **Enhanced Auto-Index**: Index documents can now configure auto-index generation via frontmatter
15
+ - `generate-auto-index: true` - Include an auto-generated index listing in the page
16
+ - `auto-index-depth: N` - Control recursion depth (1 = current folder, 2 = current + subfolders, etc.)
17
+ - `auto-index-position: top|bottom` - Insert the auto-index before or after the document content
18
+
19
+ - **Faster Serve Mode**: The `serve` command now uses deferred image processing for significantly faster startup
20
+ - HTML files are generated immediately with original image paths
21
+ - Image preview generation runs in the background after HTML is ready
22
+ - Site is browsable within seconds instead of waiting for full image processing
23
+ - Images will display as originals until preview generation completes (then show optimized WebP previews)
24
+ - Added hot reloading for regeneration
25
+
26
+ - **Bug Fixes**:
27
+ - Fixed issue where style.css changes were not reflected until server restart in `serve` mode
28
+ - Fixed issue where root-level auto-index had incorrect HREFs
29
+ - Fixed issue where re-generating an index document with `generate-auto-index:true` did not include the auto-index until server restart
30
+ - Fixed issue where auto-indexer was rendering 'img' folders
31
+
1
32
  # 0.61.1
2
33
  2026-01-14
3
34
 
package/README.md CHANGED
@@ -216,6 +216,53 @@ your-project/
216
216
  └── output/ # Generated site (created automatically)
217
217
  ```
218
218
 
219
+ ## Auto-Index Generation
220
+
221
+ Ursa automatically generates index pages for folders that don't have one. You can also explicitly control auto-index generation in your index documents using frontmatter:
222
+
223
+ ```yaml
224
+ ---
225
+ title: My Section
226
+ generate-auto-index: true
227
+ auto-index-depth: 2
228
+ auto-index-position: bottom
229
+ ---
230
+
231
+ # Welcome to My Section
232
+
233
+ This is the introduction to my section. The auto-generated file listing will appear below.
234
+ ```
235
+
236
+ ### Auto-Index Frontmatter Options
237
+
238
+ | Property | Type | Default | Description |
239
+ |----------|------|---------|-------------|
240
+ | `generate-auto-index` | boolean | false | When true, generates an auto-index listing for this folder |
241
+ | `auto-index-depth` | number | 1 | Recursion depth: 1 = current folder only, 2 = include subfolders, etc. |
242
+ | `auto-index-position` | 'top' \| 'bottom' | 'top' | Where to insert the auto-index relative to document content |
243
+
244
+ ### Examples
245
+
246
+ **Basic auto-index (top of page):**
247
+ ```yaml
248
+ ---
249
+ generate-auto-index: true
250
+ ---
251
+ ```
252
+
253
+ **Deep auto-index at bottom:**
254
+ ```yaml
255
+ ---
256
+ generate-auto-index: true
257
+ auto-index-depth: 3
258
+ auto-index-position: bottom
259
+ ---
260
+
261
+ # Section Overview
262
+
263
+ Here's some content explaining this section...
264
+ ```
265
+
219
266
  ## Developing
220
267
 
221
268
  For development on ursa itself:
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.61.1",
5
+ "version": "0.62.2",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
@@ -36,6 +36,7 @@
36
36
  "node-watch": "^0.7.3",
37
37
  "object-to-xml": "^2.0.0",
38
38
  "sharp": "^0.33.2",
39
+ "ws": "^8.19.0",
39
40
  "yaml": "^2.1.3",
40
41
  "yargs": "^17.7.2"
41
42
  },
@@ -6,7 +6,149 @@ import { outputFile } from "fs-extra";
6
6
  import { findStyleCss } from "../findStyleCss.js";
7
7
  import { toTitleCase } from "./titleCase.js";
8
8
  import { addTimestampToHtmlStaticRefs } from "./cacheBust.js";
9
- import { isMetadataOnly } from "../metadataExtractor.js";
9
+ import { isMetadataOnly, extractMetadata, getAutoIndexConfig } from "../metadataExtractor.js";
10
+
11
+ /**
12
+ * Generate auto-index HTML content for a directory from the OUTPUT folder
13
+ * (used by fallback auto-index generation after all files are generated)
14
+ * @param {string} dir - The directory path to generate index for (in output folder)
15
+ * @param {number} depth - How deep to recurse (1 = current level only, 2 = current + children, etc.)
16
+ * @param {number} [currentDepth=0] - Current recursion depth (internal use)
17
+ * @param {string} [pathPrefix=''] - Path prefix for generating correct hrefs (internal use)
18
+ * @returns {Promise<string>} HTML content for the auto-index
19
+ */
20
+ export async function generateAutoIndexHtml(dir, depth = 1, currentDepth = 0, pathPrefix = '') {
21
+ try {
22
+ const children = await readdir(dir, { withFileTypes: true });
23
+
24
+ // Filter to only include relevant files and folders
25
+ const filteredChildren = children
26
+ .filter(child => {
27
+ // Skip hidden files
28
+ if (child.name.startsWith('.')) return false;
29
+ // Skip index.html - we're generating it or it's the current page
30
+ if (child.name === 'index.html') return false;
31
+ // Skip img folders (contain images, not content)
32
+ if (child.isDirectory() && child.name === 'img') return false;
33
+ // Include directories and html files
34
+ return child.isDirectory() || child.name.endsWith('.html');
35
+ })
36
+ .sort((a, b) => {
37
+ // Directories first, then files, alphabetically within each group
38
+ if (a.isDirectory() && !b.isDirectory()) return -1;
39
+ if (!a.isDirectory() && b.isDirectory()) return 1;
40
+ return a.name.localeCompare(b.name);
41
+ });
42
+
43
+ if (filteredChildren.length === 0) {
44
+ return '';
45
+ }
46
+
47
+ const items = [];
48
+
49
+ for (const child of filteredChildren) {
50
+ const isDir = child.isDirectory();
51
+ const name = isDir ? child.name : child.name.replace('.html', '');
52
+ // Use pathPrefix to ensure hrefs are correct relative to the document root
53
+ const childPath = pathPrefix ? `${pathPrefix}/${child.name}` : child.name;
54
+ const href = isDir ? `${childPath}/index.html` : (pathPrefix ? `${pathPrefix}/${child.name}` : child.name);
55
+ const displayName = toTitleCase(name);
56
+ const icon = isDir ? '📁' : '📄';
57
+
58
+ let itemHtml = `<li>${icon} <a href="${href}">${displayName}</a>`;
59
+
60
+ // If this is a directory and we need to go deeper, recurse
61
+ if (isDir && currentDepth + 1 < depth) {
62
+ const childDir = join(dir, child.name);
63
+ const childHtml = await generateAutoIndexHtml(childDir, depth, currentDepth + 1, childPath);
64
+ if (childHtml) {
65
+ itemHtml += `\n${childHtml}`;
66
+ }
67
+ }
68
+
69
+ itemHtml += '</li>';
70
+ items.push(itemHtml);
71
+ }
72
+
73
+ return `<ul class="auto-index depth-${currentDepth + 1}">\n${items.join('\n')}\n</ul>`;
74
+ } catch (e) {
75
+ console.error(`Error generating auto-index HTML for ${dir}: ${e.message}`);
76
+ return '';
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Generate auto-index HTML content from the SOURCE folder
82
+ * (used for inline auto-index generation in index.md files with generate-auto-index: true)
83
+ * This version reads from source to avoid race conditions with concurrent file generation
84
+ * @param {string} sourceDir - The source directory path
85
+ * @param {number} depth - How deep to recurse (1 = current level only, 2 = current + children, etc.)
86
+ * @param {number} [currentDepth=0] - Current recursion depth (internal use)
87
+ * @param {string} [pathPrefix=''] - Path prefix for generating correct hrefs (internal use)
88
+ * @returns {Promise<string>} HTML content for the auto-index
89
+ */
90
+ export async function generateAutoIndexHtmlFromSource(sourceDir, depth = 1, currentDepth = 0, pathPrefix = '') {
91
+ try {
92
+ const children = await readdir(sourceDir, { withFileTypes: true });
93
+
94
+ // Filter to only include relevant files and folders
95
+ const filteredChildren = children
96
+ .filter(child => {
97
+ // Skip hidden files
98
+ if (child.name.startsWith('.')) return false;
99
+ // Skip index files (we're generating into the index)
100
+ if (child.name.match(/^index\.(md|txt|yml|html)$/i)) return false;
101
+ // Skip img folders (contain images, not content)
102
+ if (child.isDirectory() && child.name === 'img') return false;
103
+ // Include directories and article files (md, txt, yml, html)
104
+ return child.isDirectory() || child.name.match(/\.(md|txt|yml|html)$/i);
105
+ })
106
+ .sort((a, b) => {
107
+ // Directories first, then files, alphabetically within each group
108
+ if (a.isDirectory() && !b.isDirectory()) return -1;
109
+ if (!a.isDirectory() && b.isDirectory()) return 1;
110
+ return a.name.localeCompare(b.name);
111
+ });
112
+
113
+ if (filteredChildren.length === 0) {
114
+ return '';
115
+ }
116
+
117
+ const items = [];
118
+
119
+ for (const child of filteredChildren) {
120
+ const isDir = child.isDirectory();
121
+ // Get name without extension for display
122
+ const ext = isDir ? '' : extname(child.name);
123
+ const nameWithoutExt = isDir ? child.name : basename(child.name, ext);
124
+ // Generate href - directories link to folder/index.html, files convert to .html
125
+ // Use pathPrefix to ensure hrefs are correct relative to the document root
126
+ const childPath = pathPrefix ? `${pathPrefix}/${child.name}` : child.name;
127
+ const href = isDir ? `${childPath}/index.html` : `${pathPrefix ? pathPrefix + '/' : ''}${nameWithoutExt}.html`;
128
+ const displayName = toTitleCase(nameWithoutExt);
129
+ const icon = isDir ? '📁' : '📄';
130
+
131
+ let itemHtml = `<li>${icon} <a href="${href}">${displayName}</a>`;
132
+
133
+ // If this is a directory and we need to go deeper, recurse
134
+ if (isDir && currentDepth + 1 < depth) {
135
+ const childDir = join(sourceDir, child.name);
136
+ const childHtml = await generateAutoIndexHtmlFromSource(childDir, depth, currentDepth + 1, childPath);
137
+ if (childHtml) {
138
+ itemHtml += `\n${childHtml}`;
139
+ }
140
+ }
141
+
142
+ itemHtml += '</li>';
143
+ items.push(itemHtml);
144
+ }
145
+
146
+ return `<ul class="auto-index depth-${currentDepth + 1}">\n${items.join('\n')}\n</ul>`;
147
+ } catch (e) {
148
+ console.error(`Error generating auto-index HTML for ${sourceDir}: ${e.message}`);
149
+ return '';
150
+ }
151
+ }
10
152
 
11
153
  /**
12
154
  * Generate automatic index.html files for folders that don't have one
@@ -65,12 +65,16 @@ export function metadataToTable(metadata) {
65
65
  // Filter out Ursa-internal keys that shouldn't be displayed in the metadata table
66
66
  // These are used by Ursa for rendering/menu behavior, not document metadata
67
67
  const excludeKeys = [
68
+ 'title', // Document title
68
69
  'template', // Specifies which HTML template to use
69
70
  'layout', // Alternative name for template
70
71
  'draft', // Marks document as draft (not published)
71
72
  'published', // Publication status
72
73
  'menu-label', // Custom label for menu display
73
74
  'menu-sort-as', // Custom sort key for menu ordering
75
+ 'generate-auto-index', // Auto-indexing control
76
+ 'auto-index-depth', // Auto-indexing depth
77
+ 'auto-index-position' // Auto-indexing position
74
78
  ];
75
79
  const entries = Object.entries(metadata).filter(
76
80
  ([key]) => !excludeKeys.includes(key.toLowerCase())
@@ -151,6 +151,69 @@ export async function processImage(sourcePath, outputDir, relativeDir) {
151
151
  }
152
152
  }
153
153
 
154
+ /**
155
+ * Copy a single image file without preview generation (fast copy).
156
+ * Used for deferred image processing mode where HTML is generated first.
157
+ *
158
+ * @param {string} sourcePath - Absolute path to source image
159
+ * @param {string} outputDir - Absolute path to output directory
160
+ * @param {string} relativeDir - Relative directory path for URL generation
161
+ * @returns {Promise<{original: string, preview: string}|null>} Paths or null if not an image
162
+ */
163
+ export async function copyImageFast(sourcePath, outputDir, relativeDir) {
164
+ const ext = extname(sourcePath).toLowerCase();
165
+ const filename = basename(sourcePath);
166
+
167
+ // Ensure output directory exists
168
+ await mkdir(outputDir, { recursive: true });
169
+
170
+ const originalOutputPath = join(outputDir, filename);
171
+ // Ensure URL is absolute (starts with /)
172
+ let originalUrl = join(relativeDir, filename).replace(/\\/g, '/');
173
+ if (!originalUrl.startsWith('/')) {
174
+ originalUrl = '/' + originalUrl;
175
+ }
176
+
177
+ // Check if this is an image we handle
178
+ const allImageExtensions = [...COPY_ONLY_EXTENSIONS, ...PROCESSABLE_EXTENSIONS];
179
+ if (!allImageExtensions.includes(ext)) {
180
+ return null;
181
+ }
182
+
183
+ // Just copy the original (no preview generation)
184
+ await copyFile(sourcePath, originalOutputPath);
185
+
186
+ return {
187
+ original: originalUrl,
188
+ preview: originalUrl, // Same as original - no preview yet
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Copy all images without processing (fast copy).
194
+ * Used for deferred image processing mode.
195
+ *
196
+ * @param {string[]} imageFiles - Array of absolute paths to image files
197
+ * @param {string} sourceDir - Source directory root
198
+ * @param {string} outputDir - Output directory root
199
+ * @param {Function} progressCallback - Optional callback for progress updates
200
+ * @returns {Promise<void>}
201
+ */
202
+ export async function copyAllImagesFast(imageFiles, sourceDir, outputDir, progressCallback) {
203
+ for (let i = 0; i < imageFiles.length; i++) {
204
+ const file = imageFiles[i];
205
+ const relativePath = file.replace(sourceDir, '');
206
+ const relativeDir = dirname(relativePath);
207
+ const absoluteOutputDir = join(outputDir, relativeDir);
208
+
209
+ if (progressCallback) {
210
+ progressCallback(i + 1, imageFiles.length, relativePath);
211
+ }
212
+
213
+ await copyImageFast(file, absoluteOutputDir, relativeDir);
214
+ }
215
+ }
216
+
154
217
  /**
155
218
  * Build a map of all image paths to their preview/original URLs.
156
219
  * This is used to transform img tags in HTML.
@@ -37,6 +37,19 @@ export function isMetadataOnly(rawBody) {
37
37
  return contentAfter.length === 0;
38
38
  }
39
39
 
40
+ /**
41
+ * Extract auto-index configuration from metadata
42
+ * @param {object} metadata - Parsed frontmatter metadata
43
+ * @returns {{enabled: boolean, depth: number, position: 'top'|'bottom'}} Auto-index configuration
44
+ */
45
+ export function getAutoIndexConfig(metadata) {
46
+ return {
47
+ enabled: metadata?.['generate-auto-index'] === true,
48
+ depth: typeof metadata?.['auto-index-depth'] === 'number' ? metadata['auto-index-depth'] : 1,
49
+ position: metadata?.['auto-index-position'] === 'bottom' ? 'bottom' : 'top'
50
+ };
51
+ }
52
+
40
53
  function matchFrontMatter(str) {
41
54
  // Only match YAML front matter at the start of the file
42
55
  // Must have --- at line start, content, then closing --- also at line start
@@ -8,6 +8,7 @@ import {
8
8
  extractMetadata,
9
9
  extractRawMetadata,
10
10
  isMetadataOnly,
11
+ getAutoIndexConfig,
11
12
  } from "../helper/metadataExtractor.js";
12
13
  import { injectFrontmatterTable } from "../helper/frontmatterTable.js";
13
14
  import {
@@ -33,7 +34,7 @@ import o2x from "object-to-xml";
33
34
  import { existsSync } from "fs";
34
35
  import { fileExists } from "../helper/fileExists.js";
35
36
  import { createWhitelistFilter } from "../helper/whitelistFilter.js";
36
- import { processAllImages, transformImageTags, clearImageCache } from "../helper/imageProcessor.js";
37
+ import { processAllImages, transformImageTags, clearImageCache, copyAllImagesFast } from "../helper/imageProcessor.js";
37
38
 
38
39
  // Import build helpers from organized modules
39
40
  import {
@@ -55,6 +56,7 @@ import {
55
56
  getTransformedMetadata,
56
57
  getFooter,
57
58
  generateAutoIndices,
59
+ generateAutoIndexHtmlFromSource,
58
60
  } from "../helper/build/index.js";
59
61
 
60
62
  // Concurrency limiter for batch processing to avoid memory exhaustion
@@ -81,8 +83,9 @@ export async function generate({
81
83
  _exclude = null,
82
84
  _incremental = false, // Legacy flag, now ignored (always incremental)
83
85
  _clean = false, // When true, ignore cache and regenerate all files
86
+ _deferImages = false, // When true, copy images without processing, return promise for background processing
84
87
  } = {}) {
85
- console.log({ _source, _meta, _output, _whitelist, _exclude, _clean });
88
+ console.log({ _source, _meta, _output, _whitelist, _exclude, _clean, _deferImages });
86
89
  const source = resolve(_source) + "/";
87
90
  const meta = resolve(_meta);
88
91
  const output = resolve(_output) + "/";
@@ -225,23 +228,63 @@ export async function generate({
225
228
  // Track CSS files that have been copied to avoid duplicates
226
229
  const copiedCssFiles = new Set();
227
230
 
228
- // Process all images FIRST to build the preview image map
229
- // This is done before articles so we can transform img tags in the HTML
231
+ // Identify all image files
230
232
  const imageExtensions = /\.(jpg|jpeg|png|gif|webp|svg|ico)/;
231
233
  const allSourceFilenamesThatAreImages = allSourceFilenames.filter(
232
234
  (filename) => filename.match(imageExtensions) && !filename.match(hiddenOrSystemDirs)
233
235
  );
234
236
 
235
- progress.log(`Processing ${allSourceFilenamesThatAreImages.length} images for preview generation...`);
236
- const imageMap = await processAllImages(
237
- allSourceFilenamesThatAreImages,
238
- source,
239
- output,
240
- (current, total, path) => {
241
- progress.status('Images', `${current}/${total} ${path}`);
242
- }
243
- );
244
- progress.done('Images', `${allSourceFilenamesThatAreImages.length} done (${imageMap.size} with previews)`);
237
+ // Handle images based on deferred mode
238
+ let imageMap = new Map();
239
+ let deferredImageProcessingPromise = null;
240
+
241
+ if (_deferImages) {
242
+ // Fast mode: just copy images without processing, defer preview generation
243
+ progress.log(`Copying ${allSourceFilenamesThatAreImages.length} images (preview generation deferred)...`);
244
+ await copyAllImagesFast(
245
+ allSourceFilenamesThatAreImages,
246
+ source,
247
+ output,
248
+ (current, total, path) => {
249
+ progress.status('Images (copy)', `${current}/${total} ${path}`);
250
+ }
251
+ );
252
+ progress.done('Images (copy)', `${allSourceFilenamesThatAreImages.length} copied (previews deferred)`);
253
+
254
+ // Create promise for background image processing (will be returned to caller)
255
+ deferredImageProcessingPromise = (async () => {
256
+ progress.log(`\n🖼️ Starting deferred image preview generation...`);
257
+ const startTime = Date.now();
258
+ const processedImageMap = await processAllImages(
259
+ allSourceFilenamesThatAreImages,
260
+ source,
261
+ output,
262
+ (current, total, path) => {
263
+ progress.status('Images (previews)', `${current}/${total} ${path}`);
264
+ }
265
+ );
266
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
267
+ progress.done('Images (previews)', `${allSourceFilenamesThatAreImages.length} done (${processedImageMap.size} with previews) in ${elapsed}s`);
268
+
269
+ // Update the watch cache with the processed image map
270
+ watchModeCache.imageMap = processedImageMap;
271
+
272
+ return processedImageMap;
273
+ })();
274
+ } else {
275
+ // Normal mode: process all images FIRST to build the preview image map
276
+ // This is done before articles so we can transform img tags in the HTML
277
+ progress.log(`Processing ${allSourceFilenamesThatAreImages.length} images for preview generation...`);
278
+ imageMap = await processAllImages(
279
+ allSourceFilenamesThatAreImages,
280
+ source,
281
+ output,
282
+ (current, total, path) => {
283
+ progress.status('Images', `${current}/${total} ${path}`);
284
+ }
285
+ );
286
+ progress.done('Images', `${allSourceFilenamesThatAreImages.length} done (${imageMap.size} with previews)`);
287
+ }
245
288
 
246
289
  // Track files that were regenerated (for incremental mode stats)
247
290
  let regeneratedCount = 0;
@@ -348,11 +391,32 @@ export async function generate({
348
391
  body = injectFrontmatterTable(body, fileMeta);
349
392
  }
350
393
 
394
+ // Handle auto-index generation for index files with generate-auto-index: true
395
+ if (base === 'index' && fileMeta) {
396
+ const autoIndexConfig = getAutoIndexConfig(fileMeta);
397
+ if (autoIndexConfig.enabled) {
398
+ // Generate auto-index HTML for this directory from source
399
+ // Using source avoids race conditions with concurrent file generation
400
+ const sourceDir = dirname(file);
401
+ const autoIndexHtml = await generateAutoIndexHtmlFromSource(sourceDir, autoIndexConfig.depth);
402
+
403
+ if (autoIndexHtml) {
404
+ if (autoIndexConfig.position === 'bottom') {
405
+ body = body + '\n' + autoIndexHtml;
406
+ } else {
407
+ body = autoIndexHtml + '\n' + body;
408
+ }
409
+ }
410
+ }
411
+ }
412
+
351
413
  // Find nearest style.css or _style.css up the tree and copy to output
352
414
  // Use cache to avoid repeated filesystem walks for same directory
353
415
  let styleLink = "";
354
416
  try {
355
- const dirKey = resolve(_source, dir);
417
+ // For root-level files, dir may be "/" which would resolve to filesystem root
418
+ // Use source directory directly in that case
419
+ const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
356
420
  let cssPath = cssPathCache.get(dirKey);
357
421
  if (cssPath === undefined) {
358
422
  cssPath = await findStyleCss(dirKey);
@@ -417,7 +481,10 @@ export async function generate({
417
481
  finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
418
482
 
419
483
  // Transform image tags to use preview images with data-fullsrc for originals
420
- finalHtml = transformImageTags(finalHtml, imageMap, docUrlPath);
484
+ // Skip in deferred mode - images will use original paths until preview generation completes
485
+ if (!_deferImages) {
486
+ finalHtml = transformImageTags(finalHtml, imageMap, docUrlPath);
487
+ }
421
488
 
422
489
  // Add cache-busting timestamps to static file references
423
490
  finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
@@ -613,7 +680,10 @@ export async function generate({
613
680
  // Resolve internal links to have proper .html extensions
614
681
  htmlContent = markInactiveLinks(htmlContent, validPaths, docUrlPath, false);
615
682
  // Transform image tags to use preview images with data-fullsrc for originals
616
- htmlContent = transformImageTags(htmlContent, imageMap, docUrlPath);
683
+ // Skip in deferred mode - images will use original paths until preview generation completes
684
+ if (!_deferImages) {
685
+ htmlContent = transformImageTags(htmlContent, imageMap, docUrlPath);
686
+ }
617
687
  // Add cache-busting timestamps
618
688
  htmlContent = addTimestampToHtmlStaticRefs(htmlContent, cacheBustTimestamp);
619
689
  await outputFile(outputFilename, htmlContent);
@@ -686,6 +756,12 @@ export async function generate({
686
756
  } else {
687
757
  progress.log(`\n✅ Generation complete with no errors.\n`);
688
758
  }
759
+
760
+ // Return deferred image processing promise if in deferred mode
761
+ // Caller can await this to know when image previews are ready
762
+ return {
763
+ deferredImageProcessing: deferredImageProcessingPromise
764
+ };
689
765
  }
690
766
 
691
767
  /**
@@ -751,17 +827,42 @@ export async function regenerateSingleFile(changedFile, {
751
827
  const docUrlPath = '/' + dir + base + '.html';
752
828
 
753
829
  // Render body
754
- const body = renderFile({
830
+ let body = renderFile({
755
831
  fileContents: rawBody,
756
832
  type,
757
833
  dirname: dir,
758
834
  basename: base,
759
835
  });
760
836
 
837
+ // Inject frontmatter table for markdown files
838
+ if (type === '.md' && fileMeta) {
839
+ body = injectFrontmatterTable(body, fileMeta);
840
+ }
841
+
842
+ // Handle auto-index generation for index files with generate-auto-index: true
843
+ if (base === 'index' && fileMeta) {
844
+ const autoIndexConfig = getAutoIndexConfig(fileMeta);
845
+ if (autoIndexConfig.enabled) {
846
+ // Generate auto-index HTML for this directory from source
847
+ const sourceDir = dirname(changedFile);
848
+ const autoIndexHtml = await generateAutoIndexHtmlFromSource(sourceDir, autoIndexConfig.depth);
849
+
850
+ if (autoIndexHtml) {
851
+ if (autoIndexConfig.position === 'bottom') {
852
+ body = body + '\n' + autoIndexHtml;
853
+ } else {
854
+ body = autoIndexHtml + '\n' + body;
855
+ }
856
+ }
857
+ }
858
+ }
859
+
761
860
  // Find CSS and copy to output
762
861
  let styleLink = "";
763
862
  try {
764
- const cssPath = await findStyleCss(resolve(_source, dir));
863
+ // For root-level files, dir may be "/" which would resolve to filesystem root
864
+ const dirKey = (dir === "/" || dir === "") ? _source : resolve(_source, dir);
865
+ const cssPath = await findStyleCss(dirKey);
765
866
  if (cssPath) {
766
867
  // Calculate output path for the CSS file
767
868
  const cssOutputPath = cssPath.replace(source, output);
package/src/serve.js CHANGED
@@ -7,8 +7,92 @@ import fs from "fs";
7
7
  import { promises } from "fs";
8
8
  import { outputFile } from "fs-extra";
9
9
  import { processImage } from "./helper/imageProcessor.js";
10
+ import { WebSocketServer } from "ws";
11
+ import { createServer } from "http";
10
12
  const { readdir, mkdir, readFile, copyFile } = promises;
11
13
 
14
+ // WebSocket server for hot reloading
15
+ let wss = null;
16
+
17
+ /**
18
+ * Broadcast a reload message to all connected clients
19
+ * @param {string} [changedFile] - Optional path of the changed file
20
+ */
21
+ function broadcastReload(changedFile = null) {
22
+ if (!wss) return;
23
+ const message = JSON.stringify({
24
+ type: 'reload',
25
+ file: changedFile,
26
+ timestamp: Date.now()
27
+ });
28
+ wss.clients.forEach(client => {
29
+ if (client.readyState === 1) { // WebSocket.OPEN
30
+ client.send(message);
31
+ }
32
+ });
33
+ const clientCount = wss.clients.size;
34
+ if (clientCount > 0) {
35
+ console.log(`🔄 Hot reload: notified ${clientCount} browser${clientCount > 1 ? 's' : ''}`);
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Generate the hot reload client script
41
+ * @param {number} wsPort - WebSocket server port
42
+ * @returns {string} JavaScript code to inject
43
+ */
44
+ function getHotReloadScript(wsPort) {
45
+ return `
46
+ <!-- Ursa Hot Reload -->
47
+ <script>
48
+ (function() {
49
+ const wsUrl = 'ws://' + window.location.hostname + ':${wsPort}';
50
+ let ws;
51
+ let reconnectAttempts = 0;
52
+ const maxReconnectAttempts = 10;
53
+ const reconnectDelay = 1000;
54
+
55
+ function connect() {
56
+ ws = new WebSocket(wsUrl);
57
+
58
+ ws.onopen = function() {
59
+ console.log('[Ursa] Hot reload connected');
60
+ reconnectAttempts = 0;
61
+ };
62
+
63
+ ws.onmessage = function(event) {
64
+ try {
65
+ const data = JSON.parse(event.data);
66
+ if (data.type === 'reload') {
67
+ console.log('[Ursa] Reloading page...');
68
+ window.location.reload();
69
+ }
70
+ } catch (e) {
71
+ console.error('[Ursa] Hot reload error:', e);
72
+ }
73
+ };
74
+
75
+ ws.onclose = function() {
76
+ if (reconnectAttempts < maxReconnectAttempts) {
77
+ reconnectAttempts++;
78
+ console.log('[Ursa] Hot reload disconnected, reconnecting... (' + reconnectAttempts + '/' + maxReconnectAttempts + ')');
79
+ setTimeout(connect, reconnectDelay);
80
+ } else {
81
+ console.log('[Ursa] Hot reload: max reconnect attempts reached');
82
+ }
83
+ };
84
+
85
+ ws.onerror = function(error) {
86
+ console.error('[Ursa] Hot reload WebSocket error');
87
+ };
88
+ }
89
+
90
+ connect();
91
+ })();
92
+ </script>
93
+ `;
94
+ }
95
+
12
96
  // Debounce timer and lock for preventing concurrent regenerations
13
97
  let debounceTimer = null;
14
98
  let isRegenerating = false;
@@ -98,12 +182,25 @@ export async function serve({
98
182
  serveFiles(outputDir, port);
99
183
  console.log(`🚀 Development server running at http://localhost:${port}`);
100
184
  console.log("📁 Serving files from:", outputDir);
101
- console.log("⏳ Generating site in background...\n");
185
+ console.log("⏳ Generating site in background (deferred image processing)...\n");
102
186
 
103
- // Initial generation (use _clean flag only for initial generation)
187
+ // Initial generation with deferred image processing for faster startup
104
188
  // This also initializes the watch cache for fast single-file updates
105
- generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude, _clean })
106
- .then(() => console.log("\n✅ Initial generation complete. Fast single-file regeneration enabled.\n"))
189
+ generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude, _clean, _deferImages: true })
190
+ .then(async (result) => {
191
+ console.log("\n✅ Initial HTML generation complete. Fast single-file regeneration enabled.");
192
+ console.log(" Note: Images may show as originals until preview generation completes.\n");
193
+
194
+ // Wait for deferred image processing to complete
195
+ if (result && result.deferredImageProcessing) {
196
+ try {
197
+ await result.deferredImageProcessing;
198
+ console.log("\n✅ Image preview generation complete. Full site ready.\n");
199
+ } catch (error) {
200
+ console.error("Error during image processing:", error.message);
201
+ }
202
+ }
203
+ })
107
204
  .catch((error) => console.error("Error during initial generation:", error.message));
108
205
 
109
206
  // Watch for changes
@@ -118,8 +215,15 @@ export async function serve({
118
215
  console.log("Full rebuild required (meta files affect all pages)...");
119
216
  clearWatchCache(); // Clear cache since templates/CSS may have changed
120
217
  try {
121
- await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude, _clean: true });
122
- console.log("Regeneration complete.");
218
+ // Use deferred images for meta rebuilds too
219
+ const result = await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude, _clean: true, _deferImages: true });
220
+ console.log("HTML regeneration complete.");
221
+ broadcastReload(name);
222
+ if (result && result.deferredImageProcessing) {
223
+ result.deferredImageProcessing.then(() => {
224
+ console.log("Image preview generation complete.");
225
+ }).catch(e => console.error("Image processing error:", e.message));
226
+ }
123
227
  } catch (error) {
124
228
  console.error("Error during regeneration:", error.message);
125
229
  }
@@ -155,6 +259,7 @@ export async function serve({
155
259
  const result = await copyCssFile(name, sourceDir + '/', outputDir + '/');
156
260
  if (result.success) {
157
261
  console.log(`✅ ${result.message}`);
262
+ broadcastReload(name);
158
263
  } else {
159
264
  console.log(`⚠️ ${result.message}`);
160
265
  }
@@ -188,6 +293,7 @@ export async function serve({
188
293
  const result = await copyStaticFile(name, sourceDir + '/', outputDir + '/');
189
294
  if (result.success) {
190
295
  console.log(`✅ ${result.message}`);
296
+ broadcastReload(name);
191
297
  } else {
192
298
  console.log(`⚠️ ${result.message}`);
193
299
  }
@@ -208,6 +314,7 @@ export async function serve({
208
314
  try {
209
315
  await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude, _clean: true });
210
316
  console.log("Regeneration complete.");
317
+ broadcastReload(name);
211
318
  } catch (error) {
212
319
  console.error("Error during regeneration:", error.message);
213
320
  } finally {
@@ -230,6 +337,7 @@ export async function serve({
230
337
 
231
338
  if (result.success) {
232
339
  console.log(`✅ ${result.message}`);
340
+ broadcastReload(name);
233
341
  return;
234
342
  }
235
343
 
@@ -238,6 +346,7 @@ export async function serve({
238
346
  console.log("Falling back to full rebuild...");
239
347
  await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude });
240
348
  console.log("Regeneration complete.");
349
+ broadcastReload(name);
241
350
  } catch (error) {
242
351
  console.error("Error during regeneration:", error.message);
243
352
  } finally {
@@ -253,6 +362,7 @@ export async function serve({
253
362
  try {
254
363
  await generate({ _source: sourceDir, _meta: metaDir, _output: outputDir, _whitelist, _exclude });
255
364
  console.log("Regeneration complete.");
365
+ broadcastReload(name);
256
366
  } catch (error) {
257
367
  console.error("Error during regeneration:", error.message);
258
368
  } finally {
@@ -262,10 +372,14 @@ export async function serve({
262
372
  }
263
373
 
264
374
  /**
265
- * Start HTTP server to serve static files
375
+ * Start HTTP server to serve static files with hot reload support
376
+ * @param {string} outputDir - Directory to serve files from
377
+ * @param {number} port - HTTP server port
378
+ * @returns {object} Object containing httpServer and wsPort
266
379
  */
267
380
  function serveFiles(outputDir, port = 8080) {
268
381
  const app = express();
382
+ const wsPort = port + 1; // WebSocket on port+1
269
383
 
270
384
  // Enable gzip compression for all responses
271
385
  // This significantly reduces transfer size for JSON and HTML files
@@ -276,49 +390,83 @@ function serveFiles(outputDir, port = 8080) {
276
390
  level: 6
277
391
  }));
278
392
 
393
+ // Middleware to inject hot reload script into HTML responses
394
+ app.use(async (req, res, next) => {
395
+ // Only intercept HTML requests
396
+ const url = req.url;
397
+ const isHtmlRequest = url.endsWith('.html') ||
398
+ url.endsWith('/') ||
399
+ !url.includes('.') ||
400
+ url === '/';
401
+
402
+ if (!isHtmlRequest) {
403
+ return next();
404
+ }
405
+
406
+ // Determine the file path
407
+ let filePath;
408
+ if (url === '/' || url.endsWith('/')) {
409
+ filePath = join(outputDir, url, 'index.html');
410
+ } else if (url.endsWith('.html')) {
411
+ filePath = join(outputDir, url);
412
+ } else {
413
+ // Try adding .html extension
414
+ filePath = join(outputDir, url + '.html');
415
+ if (!fs.existsSync(filePath)) {
416
+ filePath = join(outputDir, url, 'index.html');
417
+ }
418
+ }
419
+
420
+ try {
421
+ if (fs.existsSync(filePath)) {
422
+ let html = await readFile(filePath, 'utf8');
423
+ // Inject hot reload script before </body>
424
+ const hotReloadScript = getHotReloadScript(wsPort);
425
+ if (html.includes('</body>')) {
426
+ html = html.replace('</body>', hotReloadScript + '</body>');
427
+ } else {
428
+ html += hotReloadScript;
429
+ }
430
+ res.setHeader('Content-Type', 'text/html');
431
+ res.send(html);
432
+ } else {
433
+ next();
434
+ }
435
+ } catch (error) {
436
+ next();
437
+ }
438
+ });
439
+
440
+ // Fallback static file serving for non-HTML files
279
441
  app.use(
280
442
  express.static(outputDir, { extensions: ["html"], index: "index.html" })
281
443
  );
282
444
 
283
- app.get("/", async (req, res) => {
284
- try {
285
- console.log({ output: outputDir });
286
- const dir = await readdir(outputDir);
287
- const html = `
288
- <!DOCTYPE html>
289
- <html>
290
- <head>
291
- <title>Ursa Development Server</title>
292
- <style>
293
- body { font-family: Arial, sans-serif; margin: 40px; }
294
- h1 { color: #333; }
295
- ul { list-style-type: none; padding: 0; }
296
- li { margin: 8px 0; }
297
- a { color: #0066cc; text-decoration: none; }
298
- a:hover { text-decoration: underline; }
299
- </style>
300
- </head>
301
- <body>
302
- <h1>Ursa Development Server</h1>
303
- <p>Files in ${outputDir}:</p>
304
- <ul>
305
- ${dir
306
- .map((file) => `<li><a href="${file}">${file}</a></li>`)
307
- .join("")}
308
- </ul>
309
- </body>
310
- </html>
311
- `;
312
- res.setHeader("Content-Type", "text/html");
313
- res.send(html);
314
- } catch (error) {
315
- res.status(500).send("Error reading directory");
316
- }
445
+ // Create HTTP server
446
+ const httpServer = createServer(app);
447
+
448
+ // Create WebSocket server for hot reload
449
+ wss = new WebSocketServer({ port: wsPort });
450
+
451
+ wss.on('connection', (ws) => {
452
+ // Send a ping to keep connection alive
453
+ const pingInterval = setInterval(() => {
454
+ if (ws.readyState === 1) {
455
+ ws.ping();
456
+ }
457
+ }, 30000);
458
+
459
+ ws.on('close', () => {
460
+ clearInterval(pingInterval);
461
+ });
317
462
  });
318
463
 
319
- app.listen(port, () => {
464
+ httpServer.listen(port, () => {
320
465
  console.log(`🌐 Server listening on port ${port}`);
466
+ console.log(`🔥 Hot reload WebSocket on port ${wsPort}`);
321
467
  });
468
+
469
+ return { httpServer, wsPort };
322
470
  }
323
471
 
324
472
  /**