@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 +31 -0
- package/README.md +47 -0
- package/package.json +2 -1
- package/src/helper/build/autoIndex.js +143 -1
- package/src/helper/frontmatterTable.js +4 -0
- package/src/helper/imageProcessor.js +63 -0
- package/src/helper/metadataExtractor.js +13 -0
- package/src/jobs/generate.js +120 -19
- package/src/serve.js +190 -42
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.
|
|
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
|
package/src/jobs/generate.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(() =>
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
/**
|