@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.
- package/CHANGELOG.md +41 -17
- package/README.md +1 -1
- package/meta/default.css +33 -0
- package/meta/templates/default-template/default.css +1268 -0
- package/meta/{default-template.html → templates/default-template/index.html} +15 -0
- package/meta/{menu.js → templates/default-template/menu.js} +1 -1
- package/meta/templates/default-template/sectionify.js +46 -0
- package/meta/{widgets.js → templates/default-template/widgets.js} +126 -0
- package/package.json +4 -2
- package/src/dev.js +73 -28
- package/src/helper/assetBundler.js +471 -0
- package/src/helper/build/autoIndex.js +24 -23
- package/src/helper/build/cacheBust.js +79 -0
- package/src/helper/build/navCache.js +4 -0
- package/src/helper/build/templates.js +176 -19
- package/src/helper/build/watchCache.js +7 -0
- package/src/helper/customMenu.js +4 -2
- package/src/helper/dependencyTracker.js +269 -0
- package/src/helper/findStyleCss.js +29 -0
- package/src/helper/portUtils.js +132 -0
- package/src/jobs/generate.js +234 -62
- package/src/serve.js +446 -162
- package/meta/character-sheet.css +0 -50
- /package/meta/{goudy_bookletter_1911-webfont.woff → shared/goudy_bookletter_1911-webfont.woff} +0 -0
- /package/meta/{character-sheet/css → templates/character-sheet-template}/character-sheet.css +0 -0
- /package/meta/{character-sheet/js → templates/character-sheet-template}/components.js +0 -0
- /package/meta/{cssui.bundle.min.css → templates/character-sheet-template/cssui.bundle.min.css} +0 -0
- /package/meta/{character-sheet-template.html → templates/character-sheet-template/index.html} +0 -0
- /package/meta/{character-sheet/js → templates/character-sheet-template}/main.js +0 -0
- /package/meta/{character-sheet/js → templates/character-sheet-template}/model.js +0 -0
- /package/meta/{search.js → templates/default-template/search.js} +0 -0
- /package/meta/{sticky.js → templates/default-template/sticky.js} +0 -0
- /package/meta/{toc-generator.js → templates/default-template/toc-generator.js} +0 -0
- /package/meta/{toc.js → templates/default-template/toc.js} +0 -0
- /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 {
|
|
4
|
-
import {
|
|
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
|
|
11
|
+
* @returns {Promise<Object>} Map of templateName to { html, dir }
|
|
10
12
|
*/
|
|
11
13
|
export async function getTemplates(meta) {
|
|
12
|
-
const
|
|
13
|
-
const allHtmlFilenames = allMetaFilenames.filter((filename) =>
|
|
14
|
-
filename.match(/\.html/)
|
|
15
|
-
);
|
|
16
|
-
|
|
14
|
+
const templatesDir = join(meta, "templates");
|
|
17
15
|
let templates = {};
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
}
|
package/src/helper/customMenu.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
+
}
|