@kenjura/ursa 0.76.0 → 0.78.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +41 -17
  2. package/README.md +1 -1
  3. package/meta/default.css +33 -0
  4. package/meta/templates/default-template/default.css +1268 -0
  5. package/meta/{default-template.html → templates/default-template/index.html} +15 -0
  6. package/meta/{menu.js → templates/default-template/menu.js} +1 -1
  7. package/meta/templates/default-template/sectionify.js +46 -0
  8. package/meta/{widgets.js → templates/default-template/widgets.js} +126 -0
  9. package/package.json +4 -2
  10. package/src/dev.js +73 -28
  11. package/src/helper/assetBundler.js +471 -0
  12. package/src/helper/build/autoIndex.js +24 -23
  13. package/src/helper/build/cacheBust.js +79 -0
  14. package/src/helper/build/navCache.js +4 -0
  15. package/src/helper/build/templates.js +176 -19
  16. package/src/helper/build/watchCache.js +7 -0
  17. package/src/helper/customMenu.js +4 -2
  18. package/src/helper/dependencyTracker.js +269 -0
  19. package/src/helper/findStyleCss.js +29 -0
  20. package/src/helper/portUtils.js +132 -0
  21. package/src/jobs/generate.js +234 -62
  22. package/src/serve.js +446 -162
  23. package/meta/character-sheet.css +0 -50
  24. /package/meta/{goudy_bookletter_1911-webfont.woff → shared/goudy_bookletter_1911-webfont.woff} +0 -0
  25. /package/meta/{character-sheet/css → templates/character-sheet-template}/character-sheet.css +0 -0
  26. /package/meta/{character-sheet/js → templates/character-sheet-template}/components.js +0 -0
  27. /package/meta/{cssui.bundle.min.css → templates/character-sheet-template/cssui.bundle.min.css} +0 -0
  28. /package/meta/{character-sheet-template.html → templates/character-sheet-template/index.html} +0 -0
  29. /package/meta/{character-sheet/js → templates/character-sheet-template}/main.js +0 -0
  30. /package/meta/{character-sheet/js → templates/character-sheet-template}/model.js +0 -0
  31. /package/meta/{search.js → templates/default-template/search.js} +0 -0
  32. /package/meta/{sticky.js → templates/default-template/sticky.js} +0 -0
  33. /package/meta/{toc-generator.js → templates/default-template/toc-generator.js} +0 -0
  34. /package/meta/{toc.js → templates/default-template/toc.js} +0 -0
  35. /package/meta/{template2.html → templates/template2/index.html} +0 -0
@@ -1,30 +1,187 @@
1
1
  // Template helpers for build
2
- import { readFile } from "fs/promises";
3
- import { parse } from "path";
4
- import { recurse } from "../recursive-readdir.js";
2
+ import { readFile, readdir } from "fs/promises";
3
+ import { join, basename } from "path";
4
+ import { existsSync } from "fs";
5
5
 
6
6
  /**
7
7
  * Get all templates from meta directory
8
+ * Templates are now organized in meta/templates/{templateName}/index.html
9
+ * Falls back to legacy flat structure if templates folder doesn't exist
8
10
  * @param {string} meta - Full path to meta files directory
9
- * @returns {Promise<Object>} Map of templateName to templateBody
11
+ * @returns {Promise<Object>} Map of templateName to { html, dir }
10
12
  */
11
13
  export async function getTemplates(meta) {
12
- const allMetaFilenames = await recurse(meta);
13
- const allHtmlFilenames = allMetaFilenames.filter((filename) =>
14
- filename.match(/\.html/)
15
- );
16
-
14
+ const templatesDir = join(meta, "templates");
17
15
  let templates = {};
18
- const templatesArray = await Promise.all(
19
- allHtmlFilenames.map(async (filename) => {
20
- const { name } = parse(filename);
21
- const fileContent = await readFile(filename, "utf8");
22
- return [name, fileContent];
23
- })
24
- );
25
- templatesArray.forEach(
26
- ([templateName, templateText]) => (templates[templateName] = templateText)
27
- );
16
+
17
+ // Check if new templates folder structure exists
18
+ if (existsSync(templatesDir)) {
19
+ const entries = await readdir(templatesDir, { withFileTypes: true });
20
+ const templateFolders = entries.filter(e => e.isDirectory());
21
+
22
+ const templatesArray = await Promise.all(
23
+ templateFolders.map(async (folder) => {
24
+ const templateName = folder.name;
25
+ const templateDir = join(templatesDir, templateName);
26
+ const indexPath = join(templateDir, "index.html");
27
+
28
+ if (existsSync(indexPath)) {
29
+ const fileContent = await readFile(indexPath, "utf8");
30
+ return [templateName, { html: fileContent, dir: templateDir }];
31
+ }
32
+ return null;
33
+ })
34
+ );
35
+
36
+ templatesArray
37
+ .filter(Boolean)
38
+ .forEach(([name, data]) => (templates[name] = data));
39
+ }
40
+
41
+ // Legacy fallback: check for *-template.html files in meta root
42
+ // This allows backward compatibility during migration
43
+ if (Object.keys(templates).length === 0) {
44
+ const { recurse } = await import("../recursive-readdir.js");
45
+ const allMetaFilenames = await recurse(meta);
46
+ const legacyTemplates = allMetaFilenames.filter((filename) =>
47
+ filename.match(/-template\.html$/)
48
+ );
49
+
50
+ const templatesArray = await Promise.all(
51
+ legacyTemplates.map(async (filename) => {
52
+ const name = basename(filename).replace("-template.html", "");
53
+ const fileContent = await readFile(filename, "utf8");
54
+ // For legacy templates, the dir is the meta folder itself
55
+ return [name, { html: fileContent, dir: meta }];
56
+ })
57
+ );
58
+ templatesArray.forEach(
59
+ ([templateName, data]) => (templates[templateName] = data)
60
+ );
61
+ }
28
62
 
29
63
  return templates;
30
64
  }
65
+
66
+ /**
67
+ * Get template HTML content (backward compatible)
68
+ * @param {Object} templates - Templates map from getTemplates()
69
+ * @param {string} name - Template name
70
+ * @returns {string|null} Template HTML or null
71
+ */
72
+ export function getTemplateHtml(templates, name) {
73
+ const template = templates[name];
74
+ if (!template) return null;
75
+ // Support both new { html, dir } format and legacy string format
76
+ return typeof template === 'string' ? template : template.html;
77
+ }
78
+
79
+ /**
80
+ * Get template directory (for resolving assets)
81
+ * @param {Object} templates - Templates map from getTemplates()
82
+ * @param {string} name - Template name
83
+ * @returns {string|null} Template directory path or null
84
+ */
85
+ export function getTemplateDir(templates, name) {
86
+ const template = templates[name];
87
+ if (!template) return null;
88
+ return typeof template === 'string' ? null : template.dir;
89
+ }
90
+
91
+ /**
92
+ * Copy meta assets to output/public directory.
93
+ * Handles the new template folder structure:
94
+ * - meta/shared/* → output/public/*
95
+ * - meta/templates/{name}/* (except index.html) → output/public/*
96
+ *
97
+ * Also handles legacy flat structure for backward compatibility.
98
+ *
99
+ * @param {string} metaDir - Path to meta directory
100
+ * @param {string} outputPublicDir - Path to output/public directory
101
+ * @returns {Promise<{ copiedFiles: string[], orphanedFiles: string[] }>}
102
+ */
103
+ export async function copyMetaAssets(metaDir, outputPublicDir) {
104
+ const { copy } = await import('fs-extra');
105
+ const { readdir, stat } = await import('fs/promises');
106
+ const { relative } = await import('path');
107
+
108
+ const copiedFiles = [];
109
+ const orphanedFiles = [];
110
+ const templatesDir = join(metaDir, 'templates');
111
+ const sharedDir = join(metaDir, 'shared');
112
+
113
+ // Helper to copy a file
114
+ const copyFile = async (src, dest) => {
115
+ await copy(src, dest, { overwrite: true });
116
+ copiedFiles.push(relative(metaDir, src));
117
+ };
118
+
119
+ // Helper to recursively copy directory contents (not the directory itself)
120
+ const copyDirContents = async (srcDir, destDir, excludeFiles = []) => {
121
+ if (!existsSync(srcDir)) return;
122
+
123
+ const entries = await readdir(srcDir, { withFileTypes: true });
124
+ for (const entry of entries) {
125
+ if (entry.name.startsWith('.')) continue; // Skip hidden files
126
+ if (excludeFiles.includes(entry.name)) continue;
127
+
128
+ const srcPath = join(srcDir, entry.name);
129
+ const destPath = join(destDir, entry.name);
130
+
131
+ if (entry.isDirectory()) {
132
+ await copyDirContents(srcPath, destPath, excludeFiles);
133
+ } else {
134
+ await copyFile(srcPath, destPath);
135
+ }
136
+ }
137
+ };
138
+
139
+ // Check if new structure exists
140
+ if (existsSync(templatesDir)) {
141
+ // Copy shared assets
142
+ await copyDirContents(sharedDir, outputPublicDir);
143
+
144
+ // Copy each template's assets (except index.html)
145
+ const templateFolders = await readdir(templatesDir, { withFileTypes: true });
146
+ for (const folder of templateFolders) {
147
+ if (!folder.isDirectory()) continue;
148
+ const templateDir = join(templatesDir, folder.name);
149
+ await copyDirContents(templateDir, outputPublicDir, ['index.html']);
150
+ }
151
+
152
+ // Check for orphaned files in meta root (excluding templates and shared folders)
153
+ const rootEntries = await readdir(metaDir, { withFileTypes: true });
154
+ for (const entry of rootEntries) {
155
+ if (entry.name.startsWith('.')) continue;
156
+ if (entry.name === 'templates' || entry.name === 'shared') continue;
157
+
158
+ // This is an orphaned file/folder in meta root
159
+ const fullPath = join(metaDir, entry.name);
160
+ if (entry.isDirectory()) {
161
+ // Recursively find all orphaned files
162
+ const findOrphans = async (dir) => {
163
+ const entries = await readdir(dir, { withFileTypes: true });
164
+ for (const e of entries) {
165
+ if (e.name.startsWith('.')) continue;
166
+ const p = join(dir, e.name);
167
+ if (e.isDirectory()) {
168
+ await findOrphans(p);
169
+ } else {
170
+ orphanedFiles.push(relative(metaDir, p));
171
+ }
172
+ }
173
+ };
174
+ await findOrphans(fullPath);
175
+ } else {
176
+ orphanedFiles.push(entry.name);
177
+ }
178
+ }
179
+ } else {
180
+ // Legacy: copy entire meta folder to public (old behavior)
181
+ await copy(metaDir, outputPublicDir, {
182
+ filter: (src) => !basename(src).startsWith('.') && !src.endsWith('.html')
183
+ });
184
+ }
185
+
186
+ return { copiedFiles, orphanedFiles };
187
+ }
@@ -1,5 +1,7 @@
1
1
  // Watch mode cache and clear function for build
2
2
 
3
+ import { dependencyTracker } from "../dependencyTracker.js";
4
+
3
5
  export const watchModeCache = {
4
6
  templates: null,
5
7
  menu: null,
@@ -10,6 +12,8 @@ export const watchModeCache = {
10
12
  output: null,
11
13
  hashCache: null,
12
14
  cacheBustTimestamp: null,
15
+ cacheBustHashes: null, // CacheBustHashMap instance for per-file cache-busting
16
+ allArticlePaths: null, // Array of all article paths (for full rebuild tracking)
13
17
  lastFullBuild: 0,
14
18
  isInitialized: false,
15
19
  };
@@ -20,7 +24,10 @@ export function clearWatchCache(cssPathCache) {
20
24
  watchModeCache.footer = null;
21
25
  watchModeCache.validPaths = null;
22
26
  watchModeCache.hashCache = null;
27
+ watchModeCache.cacheBustHashes = null;
28
+ watchModeCache.allArticlePaths = null;
23
29
  watchModeCache.isInitialized = false;
30
+ dependencyTracker.init("");
24
31
  if (cssPathCache) cssPathCache.clear();
25
32
  console.log('Watch cache cleared');
26
33
  }
@@ -267,11 +267,13 @@ export function autoGenerateMenuFromFolder(folderPath, sourceRoot, depth = 10, i
267
267
  const topLevelFolders = processedItems.filter(item => item.hasChildren);
268
268
  const topLevelFiles = processedItems.filter(item => !item.hasChildren);
269
269
 
270
- const relativePath = '/' + relative(sourceRoot, folderPath).replace(/\\/g, '/');
270
+ const relPath = relative(sourceRoot, folderPath).replace(/\\/g, '/');
271
+ // Avoid double slash: if relPath is empty (root), href is '/index.html', otherwise '/relPath/index.html'
272
+ const homeHref = relPath ? `/${relPath}/index.html` : '/index.html';
271
273
  const homeItem = {
272
274
  label: 'Home',
273
275
  path: 'home',
274
- href: relativePath + '/index.html',
276
+ href: homeHref,
275
277
  hasChildren: topLevelFiles.length > 0,
276
278
  icon: `<span class="menu-icon">${HOME_ICON}</span>`,
277
279
  children: topLevelFiles,
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Dependency tracker for Ursa's regeneration system.
3
+ *
4
+ * Tracks which documents depend on which files so that when a file changes,
5
+ * we can determine exactly which documents need regeneration.
6
+ *
7
+ * Dependency types:
8
+ * - template: document uses a specific meta template
9
+ * - style: document inherits a specific style.css
10
+ * - script: document inherits a specific script.js
11
+ * - static: document references a static asset (image, font, etc.)
12
+ * - meta-asset: document depends on a meta CSS/JS file (via template bundling)
13
+ *
14
+ * The tracker maintains two indexes:
15
+ * 1. fileToDocuments: Map<dependencyPath, Set<documentPath>> — given a changed file, which documents need regeneration?
16
+ * 2. documentToFiles: Map<documentPath, Set<dependencyPath>> — given a document, what are its dependencies? (for cleanup)
17
+ */
18
+
19
+ import { dirname, join, relative, resolve } from "path";
20
+
21
+ export class DependencyTracker {
22
+ constructor() {
23
+ /** @type {Map<string, Set<string>>} dependency file path → set of document paths */
24
+ this.fileToDocuments = new Map();
25
+ /** @type {Map<string, Set<string>>} document path → set of dependency file paths */
26
+ this.documentToFiles = new Map();
27
+ /** @type {string} source directory root (absolute, with trailing slash) */
28
+ this.sourceDir = "";
29
+ }
30
+
31
+ /**
32
+ * Initialize with the source directory.
33
+ * @param {string} sourceDir - Absolute path to source directory (with or without trailing slash)
34
+ */
35
+ init(sourceDir) {
36
+ this.sourceDir = resolve(sourceDir) + "/";
37
+ this.fileToDocuments.clear();
38
+ this.documentToFiles.clear();
39
+ }
40
+
41
+ /**
42
+ * Register that a document depends on a file.
43
+ * @param {string} documentPath - Absolute path to the document
44
+ * @param {string} dependencyPath - Absolute path to the dependency file
45
+ */
46
+ addDependency(documentPath, dependencyPath) {
47
+ // file → documents
48
+ if (!this.fileToDocuments.has(dependencyPath)) {
49
+ this.fileToDocuments.set(dependencyPath, new Set());
50
+ }
51
+ this.fileToDocuments.get(dependencyPath).add(documentPath);
52
+
53
+ // document → files
54
+ if (!this.documentToFiles.has(documentPath)) {
55
+ this.documentToFiles.set(documentPath, new Set());
56
+ }
57
+ this.documentToFiles.get(documentPath).add(dependencyPath);
58
+ }
59
+
60
+ /**
61
+ * Register dependencies for a document: template, style.css files, script.js files.
62
+ * @param {string} documentPath - Absolute path to the document
63
+ * @param {{ templateName: string, cssPaths: string[], scriptPaths: string[], metaAssets: string[] }} deps
64
+ */
65
+ registerDocument(documentPath, { templateName, cssPaths = [], scriptPaths = [], metaAssets = [] } = {}) {
66
+ // Clear old dependencies for this document
67
+ this.clearDocument(documentPath);
68
+
69
+ // Template dependency (use a virtual path so template changes can be looked up)
70
+ if (templateName) {
71
+ this.addDependency(documentPath, `template:${templateName}`);
72
+ }
73
+
74
+ // Style.css dependencies
75
+ for (const cssPath of cssPaths) {
76
+ this.addDependency(documentPath, cssPath);
77
+ }
78
+
79
+ // Script.js dependencies
80
+ for (const scriptPath of scriptPaths) {
81
+ this.addDependency(documentPath, scriptPath);
82
+ }
83
+
84
+ // Meta template assets (CSS/JS referenced by the template)
85
+ for (const metaAsset of metaAssets) {
86
+ this.addDependency(documentPath, metaAsset);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Clear all dependencies for a document.
92
+ * @param {string} documentPath
93
+ */
94
+ clearDocument(documentPath) {
95
+ const deps = this.documentToFiles.get(documentPath);
96
+ if (deps) {
97
+ for (const dep of deps) {
98
+ const docSet = this.fileToDocuments.get(dep);
99
+ if (docSet) {
100
+ docSet.delete(documentPath);
101
+ if (docSet.size === 0) this.fileToDocuments.delete(dep);
102
+ }
103
+ }
104
+ this.documentToFiles.delete(documentPath);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Get all documents that depend on a given file.
110
+ * @param {string} filePath - Absolute path to the changed file
111
+ * @returns {Set<string>} Set of document paths that need regeneration
112
+ */
113
+ getAffectedDocuments(filePath) {
114
+ return this.fileToDocuments.get(filePath) || new Set();
115
+ }
116
+
117
+ /**
118
+ * Get all documents that use a specific template.
119
+ * @param {string} templateName - Template name (e.g., "default-template")
120
+ * @returns {Set<string>} Set of document paths
121
+ */
122
+ getDocumentsUsingTemplate(templateName) {
123
+ return this.getAffectedDocuments(`template:${templateName}`);
124
+ }
125
+
126
+ /**
127
+ * Determine which documents are affected by a changed file.
128
+ * This is the main entry point for the invalidation logic.
129
+ *
130
+ * @param {string} changedFile - Absolute path to the changed file
131
+ * @param {string} sourceDir - Absolute path to source directory
132
+ * @returns {{ affectedDocuments: string[], reason: string, requiresFullRebuild: boolean }}
133
+ */
134
+ getInvalidationPlan(changedFile, sourceDir) {
135
+ const normalizedSource = resolve(sourceDir) + "/";
136
+ const relativePath = changedFile.replace(normalizedSource, "");
137
+ const fileName = relativePath.split("/").pop();
138
+
139
+ // 1. Menu/config changes → full rebuild (affects navigation structure)
140
+ if (
141
+ fileName === "menu.md" || fileName === "menu.txt" || fileName === "_menu" ||
142
+ fileName === "config.json" || fileName === "_config"
143
+ ) {
144
+ return {
145
+ affectedDocuments: [],
146
+ reason: `Menu/config change: ${relativePath}`,
147
+ requiresFullRebuild: true,
148
+ };
149
+ }
150
+
151
+ // 2. style.css or script.js → affects current folder + all subfolders
152
+ // These are "inherited" files — documents in the folder and all subfolders include them.
153
+ if (
154
+ fileName === "style.css" || fileName === "_style.css" || fileName === "style-ursa.css" ||
155
+ fileName === "script.js" || fileName === "_script.js"
156
+ ) {
157
+ // Get all documents directly registered as depending on this file
158
+ const directDeps = this.getAffectedDocuments(changedFile);
159
+
160
+ if (directDeps.size > 0) {
161
+ return {
162
+ affectedDocuments: [...directDeps],
163
+ reason: `Inherited ${fileName} changed: ${relativePath} (${directDeps.size} documents)`,
164
+ requiresFullRebuild: false,
165
+ };
166
+ }
167
+
168
+ // Fallback: if dependency tracker wasn't populated, scope by directory
169
+ const changedDir = dirname(changedFile);
170
+ const allDocs = [...this.documentToFiles.keys()];
171
+ const affected = allDocs.filter((doc) => doc.startsWith(changedDir));
172
+ return {
173
+ affectedDocuments: affected,
174
+ reason: `Inherited ${fileName} changed: ${relativePath} (${affected.length} documents in subtree, fallback)`,
175
+ requiresFullRebuild: false,
176
+ };
177
+ }
178
+
179
+ // 3. Article file changes → just that document
180
+ if (/\.(md|mdx|txt|yml|yaml)$/.test(fileName)) {
181
+ return {
182
+ affectedDocuments: [changedFile],
183
+ reason: `Article changed: ${relativePath}`,
184
+ requiresFullRebuild: false,
185
+ };
186
+ }
187
+
188
+ // 4. Other static files (images, fonts, etc.) → find documents that reference them
189
+ const directDeps = this.getAffectedDocuments(changedFile);
190
+ if (directDeps.size > 0) {
191
+ return {
192
+ affectedDocuments: [...directDeps],
193
+ reason: `Static asset changed: ${relativePath} (${directDeps.size} referencing documents)`,
194
+ requiresFullRebuild: false,
195
+ };
196
+ }
197
+
198
+ // If we don't track this file, it's safe to just broadcast reload (dev mode only)
199
+ return {
200
+ affectedDocuments: [],
201
+ reason: `Unknown file changed: ${relativePath} (no registered dependents)`,
202
+ requiresFullRebuild: false,
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Determine which documents are affected by a meta file change.
208
+ * @param {string} changedFile - Absolute path to the changed meta file
209
+ * @param {string} metaDir - Absolute path to meta directory
210
+ * @returns {{ affectedDocuments: string[], reason: string, requiresFullRebuild: boolean }}
211
+ */
212
+ getMetaInvalidationPlan(changedFile, metaDir) {
213
+ const normalizedMeta = resolve(metaDir) + "/";
214
+ const relativePath = changedFile.replace(normalizedMeta, "");
215
+ const fileName = relativePath.split("/").pop();
216
+
217
+ // Template file changed → regenerate all documents using that template
218
+ if (fileName.endsWith(".html")) {
219
+ const templateName = fileName.replace(".html", "");
220
+ const affected = this.getDocumentsUsingTemplate(templateName);
221
+ if (affected.size > 0) {
222
+ return {
223
+ affectedDocuments: [...affected],
224
+ reason: `Template changed: ${templateName} (${affected.size} documents)`,
225
+ requiresFullRebuild: false,
226
+ };
227
+ }
228
+ // If no documents tracked, fall back to full rebuild (safe default)
229
+ return {
230
+ affectedDocuments: [],
231
+ reason: `Template changed: ${templateName} (no tracked documents, full rebuild)`,
232
+ requiresFullRebuild: true,
233
+ };
234
+ }
235
+
236
+ // Meta CSS or JS file changed → all documents are affected
237
+ // (meta assets are bundled into every template)
238
+ if (fileName.endsWith(".css") || fileName.endsWith(".js")) {
239
+ const allDocs = [...this.documentToFiles.keys()];
240
+ return {
241
+ affectedDocuments: allDocs,
242
+ reason: `Meta asset changed: ${relativePath} (affects all ${allDocs.length} documents)`,
243
+ requiresFullRebuild: false,
244
+ };
245
+ }
246
+
247
+ // Other meta file → full rebuild to be safe
248
+ return {
249
+ affectedDocuments: [],
250
+ reason: `Meta file changed: ${relativePath}`,
251
+ requiresFullRebuild: true,
252
+ };
253
+ }
254
+
255
+ /**
256
+ * Get stats about the dependency graph.
257
+ * @returns {{ totalDocuments: number, totalDependencies: number, uniqueFiles: number }}
258
+ */
259
+ getStats() {
260
+ return {
261
+ totalDocuments: this.documentToFiles.size,
262
+ totalDependencies: [...this.documentToFiles.values()].reduce((sum, s) => sum + s.size, 0),
263
+ uniqueFiles: this.fileToDocuments.size,
264
+ };
265
+ }
266
+ }
267
+
268
+ // Singleton instance
269
+ export const dependencyTracker = new DependencyTracker();
@@ -24,3 +24,32 @@ export async function findStyleCss(startDir, names = ["style-ursa.css", "style.c
24
24
  }
25
25
  return null;
26
26
  }
27
+
28
+ /**
29
+ * Find ALL style.css or _style.css files from the docroot down to startDir.
30
+ * Walks up from startDir to docroot collecting all matches, then returns them
31
+ * sorted from shortest path (closest to docroot) to longest (closest to startDir).
32
+ * @param {string} startDir - Directory to start searching from (deepest)
33
+ * @param {string} docroot - The root directory to stop at (shallowest)
34
+ * @param {string[]} [names=["style-ursa.css", "style.css", "_style.css"]] - Filenames to look for
35
+ * @returns {Promise<string[]>} Array of CSS file paths, ordered from shallowest to deepest
36
+ */
37
+ export async function findAllStyleCss(startDir, docroot, names = ["style-ursa.css", "style.css", "_style.css"]) {
38
+ const found = [];
39
+ let dir = resolve(startDir);
40
+ const base = resolve(docroot);
41
+ while (true) {
42
+ for (const name of names) {
43
+ const candidate = join(dir, name);
44
+ if (existsSync(candidate)) {
45
+ found.push(candidate);
46
+ break; // Only one match per directory (prefer style-ursa.css > style.css > _style.css)
47
+ }
48
+ }
49
+ if (dir === base || dir === dirname(dir)) break;
50
+ dir = dirname(dir);
51
+ }
52
+ // Sort from shortest path (docroot) to longest (startDir)
53
+ found.sort((a, b) => a.length - b.length);
54
+ return found;
55
+ }
@@ -0,0 +1,132 @@
1
+ import net from 'net';
2
+ import readline from 'readline';
3
+
4
+ /**
5
+ * Check if a specific port is available.
6
+ * @param {number} port
7
+ * @returns {Promise<boolean>}
8
+ */
9
+ export function isPortAvailable(port) {
10
+ return new Promise((resolve) => {
11
+ const server = net.createServer();
12
+ server.once('error', () => resolve(false));
13
+ server.once('listening', () => {
14
+ server.close(() => resolve(true));
15
+ });
16
+ server.listen(port);
17
+ });
18
+ }
19
+
20
+ /**
21
+ * Find the closest available port to the preferred port.
22
+ * Searches both upward and downward from the preferred port, returning
23
+ * the closest available one.
24
+ * @param {number} preferred - The preferred port number
25
+ * @param {number} [maxDistance=100] - Maximum distance to search from preferred port
26
+ * @returns {Promise<number|null>} The closest available port, or null if none found
27
+ */
28
+ export async function findClosestAvailablePort(preferred, maxDistance = 100) {
29
+ for (let offset = 1; offset <= maxDistance; offset++) {
30
+ const candidates = [];
31
+ if (preferred + offset <= 65535) candidates.push(preferred + offset);
32
+ if (preferred - offset >= 1024) candidates.push(preferred - offset);
33
+
34
+ // Check both candidates (up and down) in parallel
35
+ const results = await Promise.all(
36
+ candidates.map(async (port) => ({
37
+ port,
38
+ available: await isPortAvailable(port),
39
+ }))
40
+ );
41
+
42
+ // Return the first available candidate (lower offset = closer)
43
+ // Since we push +offset first, it's preferred over -offset at the same distance
44
+ const found = results.find((r) => r.available);
45
+ if (found) return found.port;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /**
51
+ * Prompt the user via stdin to confirm using an alternative port.
52
+ * @param {number} originalPort
53
+ * @param {number} alternativePort
54
+ * @returns {Promise<boolean>} True if user accepts the alternative port
55
+ */
56
+ function promptUser(originalPort, alternativePort) {
57
+ return new Promise((resolve) => {
58
+ const rl = readline.createInterface({
59
+ input: process.stdin,
60
+ output: process.stdout,
61
+ });
62
+
63
+ rl.question(
64
+ `⚠️ Port ${originalPort} is already in use. Use port ${alternativePort} instead? (Y/n) `,
65
+ (answer) => {
66
+ rl.close();
67
+ const normalized = answer.trim().toLowerCase();
68
+ resolve(normalized === '' || normalized === 'y' || normalized === 'yes');
69
+ }
70
+ );
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Resolve an available port for the server. If the requested port is occupied,
76
+ * find the closest available port and prompt the user to accept it.
77
+ *
78
+ * Also checks wsPort (port + 1) availability since the WebSocket server needs it.
79
+ *
80
+ * @param {number} port - The desired port
81
+ * @returns {Promise<number>} The port to use (original or user-accepted alternative)
82
+ * @throws {Error} If no available port is found or user declines the alternative
83
+ */
84
+ export async function resolvePort(port) {
85
+ const httpAvailable = await isPortAvailable(port);
86
+ const wsAvailable = await isPortAvailable(port + 1);
87
+
88
+ if (httpAvailable && wsAvailable) {
89
+ return port;
90
+ }
91
+
92
+ const reason = !httpAvailable
93
+ ? `Port ${port} is already in use`
94
+ : `WebSocket port ${port + 1} is already in use`;
95
+
96
+ console.log(`\n⚠️ ${reason}.`);
97
+ console.log(`🔍 Searching for an available port...`);
98
+
99
+ const alternative = await findClosestAvailablePort(port);
100
+
101
+ if (!alternative) {
102
+ throw new Error(
103
+ `Could not find an available port near ${port}. Please free up a port and try again.`
104
+ );
105
+ }
106
+
107
+ // Also verify the ws port for the alternative
108
+ const altWsAvailable = await isPortAvailable(alternative + 1);
109
+ if (!altWsAvailable) {
110
+ // Try again, skipping this one
111
+ const secondTry = await findClosestAvailablePort(alternative + 1);
112
+ if (!secondTry) {
113
+ throw new Error(
114
+ `Could not find an available port pair (HTTP + WebSocket) near ${port}.`
115
+ );
116
+ }
117
+ const accepted = await promptUser(port, secondTry);
118
+ if (!accepted) {
119
+ console.log('👋 Server startup cancelled.');
120
+ process.exit(0);
121
+ }
122
+ return secondTry;
123
+ }
124
+
125
+ const accepted = await promptUser(port, alternative);
126
+ if (!accepted) {
127
+ console.log('👋 Server startup cancelled.');
128
+ process.exit(0);
129
+ }
130
+
131
+ return alternative;
132
+ }