@kenjura/ursa 0.86.0 → 0.88.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 +785 -714
- package/bin/ursa.js +7 -1
- package/meta/templates/default-template/index.html +1 -0
- package/package.json +1 -1
- package/src/dev.js +24 -5
- package/src/helper/__test__/dependencyTracker.test.js +157 -0
- package/src/helper/__test__/hiddenPaths.test.js +106 -0
- package/src/helper/__test__/portUtils.test.js +100 -0
- package/src/helper/automenu.js +43 -3
- package/src/helper/build/__test__/autoIndex.test.js +95 -0
- package/src/helper/build/__test__/graph.test.js +529 -0
- package/src/helper/build/autoIndex.js +17 -6
- package/src/helper/build/graph.js +542 -0
- package/src/helper/build/index.js +1 -0
- package/src/helper/build/ursaMetadata.js +62 -0
- package/src/helper/contentHash.js +19 -0
- package/src/helper/dependencyTracker.js +116 -1
- package/src/helper/hiddenPaths.js +62 -0
- package/src/helper/portUtils.js +71 -22
- package/src/jobs/generate.js +1758 -1672
- package/src/serve.js +907 -874
|
@@ -17,6 +17,16 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { dirname, join, relative, resolve } from "path";
|
|
20
|
+
import { existsSync } from "fs";
|
|
21
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
22
|
+
import { getUrsaDir } from "./contentHash.js";
|
|
23
|
+
|
|
24
|
+
const DEP_GRAPH_FILE = "dependency-graph.json";
|
|
25
|
+
const DEP_GRAPH_VERSION = 1;
|
|
26
|
+
|
|
27
|
+
// Static assets in meta (copied verbatim to output/public by copyMetaAssets);
|
|
28
|
+
// these are never embedded in document HTML, so no regeneration is needed.
|
|
29
|
+
const META_STATIC_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|ico|woff|woff2|ttf|eot|pdf|mp3|mp4|webm|ogg)$/i;
|
|
20
30
|
|
|
21
31
|
export class DependencyTracker {
|
|
22
32
|
constructor() {
|
|
@@ -216,7 +226,13 @@ export class DependencyTracker {
|
|
|
216
226
|
|
|
217
227
|
// Template file changed → regenerate all documents using that template
|
|
218
228
|
if (fileName.endsWith(".html")) {
|
|
219
|
-
|
|
229
|
+
// New structure: templates/{templateName}/index.html → name is the folder;
|
|
230
|
+
// legacy flat structure: {templateName}.html at the meta root
|
|
231
|
+
const parts = relativePath.split("/");
|
|
232
|
+
const templateName =
|
|
233
|
+
parts[0] === "templates" && parts.length >= 3
|
|
234
|
+
? parts[1]
|
|
235
|
+
: fileName.replace(".html", "");
|
|
220
236
|
const affected = this.getDocumentsUsingTemplate(templateName);
|
|
221
237
|
if (affected.size > 0) {
|
|
222
238
|
return {
|
|
@@ -244,6 +260,17 @@ export class DependencyTracker {
|
|
|
244
260
|
};
|
|
245
261
|
}
|
|
246
262
|
|
|
263
|
+
// Static asset in meta (image, font, PDF, media) → copyMetaAssets already
|
|
264
|
+
// re-copied it to output/public; documents reference it by URL, so no
|
|
265
|
+
// document regeneration (and no full rebuild) is needed.
|
|
266
|
+
if (META_STATIC_EXTENSIONS.test(fileName)) {
|
|
267
|
+
return {
|
|
268
|
+
affectedDocuments: [],
|
|
269
|
+
reason: `Meta static asset copied: ${relativePath}`,
|
|
270
|
+
requiresFullRebuild: false,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
247
274
|
// Other meta file → full rebuild to be safe
|
|
248
275
|
return {
|
|
249
276
|
affectedDocuments: [],
|
|
@@ -263,7 +290,95 @@ export class DependencyTracker {
|
|
|
263
290
|
uniqueFiles: this.fileToDocuments.size,
|
|
264
291
|
};
|
|
265
292
|
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Serialize the tracker for persistence to .ursa/dependency-graph.json.
|
|
296
|
+
* @returns {{ version: number, sourceDir: string, documents: Object<string, string[]> }}
|
|
297
|
+
*/
|
|
298
|
+
serialize() {
|
|
299
|
+
return {
|
|
300
|
+
version: DEP_GRAPH_VERSION,
|
|
301
|
+
sourceDir: this.sourceDir,
|
|
302
|
+
documents: Object.fromEntries(
|
|
303
|
+
[...this.documentToFiles.entries()].map(([doc, deps]) => [doc, [...deps]])
|
|
304
|
+
),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Load persisted registrations, merging with the current run: documents
|
|
310
|
+
* already registered in this run keep their (fresher) edges; persisted
|
|
311
|
+
* edges only fill in documents not yet registered (e.g. hash-skipped docs).
|
|
312
|
+
* Rejects data from a different source directory or schema version.
|
|
313
|
+
* @param {object} data - Previously serialized tracker
|
|
314
|
+
* @returns {boolean} Whether the data was loaded
|
|
315
|
+
*/
|
|
316
|
+
load(data) {
|
|
317
|
+
if (!data || data.version !== DEP_GRAPH_VERSION) return false;
|
|
318
|
+
if (data.sourceDir && this.sourceDir && data.sourceDir !== this.sourceDir) return false;
|
|
319
|
+
for (const [doc, deps] of Object.entries(data.documents || {})) {
|
|
320
|
+
if (this.documentToFiles.has(doc)) continue; // live registrations win
|
|
321
|
+
if (!Array.isArray(deps)) continue;
|
|
322
|
+
for (const dep of deps) {
|
|
323
|
+
this.addDependency(doc, dep);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Drop registrations for documents not in the given set (e.g. deleted or
|
|
331
|
+
* excluded files), so persisted state doesn't accumulate stale entries.
|
|
332
|
+
* @param {Set<string>} keepDocuments - Document paths that should survive
|
|
333
|
+
*/
|
|
334
|
+
prune(keepDocuments) {
|
|
335
|
+
for (const doc of [...this.documentToFiles.keys()]) {
|
|
336
|
+
if (!keepDocuments.has(doc)) this.clearDocument(doc);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
266
339
|
}
|
|
267
340
|
|
|
268
341
|
// Singleton instance
|
|
269
342
|
export const dependencyTracker = new DependencyTracker();
|
|
343
|
+
|
|
344
|
+
/** Path to the persisted dependency graph for a source directory. */
|
|
345
|
+
export function getDependencyGraphPath(sourceDir) {
|
|
346
|
+
return join(getUrsaDir(sourceDir), DEP_GRAPH_FILE);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Load the persisted dependency tracker state from .ursa/dependency-graph.json
|
|
351
|
+
* and merge it into the tracker (current-run registrations win).
|
|
352
|
+
* @param {string} sourceDir - Source directory root
|
|
353
|
+
* @param {DependencyTracker} [tracker] - Defaults to the singleton
|
|
354
|
+
* @returns {Promise<boolean>} Whether a valid graph was loaded
|
|
355
|
+
*/
|
|
356
|
+
export async function loadDependencyTracker(sourceDir, tracker = dependencyTracker) {
|
|
357
|
+
const path = getDependencyGraphPath(sourceDir);
|
|
358
|
+
try {
|
|
359
|
+
if (!existsSync(path)) return false;
|
|
360
|
+
const data = JSON.parse(await readFile(path, "utf8"));
|
|
361
|
+
return tracker.load(data);
|
|
362
|
+
} catch (e) {
|
|
363
|
+
console.warn(`Could not load dependency graph: ${e.message}`);
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Persist the dependency tracker to .ursa/dependency-graph.json so that
|
|
370
|
+
* hash-skipped documents keep their edges across warm starts.
|
|
371
|
+
* @param {string} sourceDir - Source directory root
|
|
372
|
+
* @param {DependencyTracker} [tracker] - Defaults to the singleton
|
|
373
|
+
* @returns {Promise<boolean>} Whether the graph was saved
|
|
374
|
+
*/
|
|
375
|
+
export async function saveDependencyTracker(sourceDir, tracker = dependencyTracker) {
|
|
376
|
+
try {
|
|
377
|
+
await mkdir(getUrsaDir(sourceDir), { recursive: true });
|
|
378
|
+
await writeFile(getDependencyGraphPath(sourceDir), JSON.stringify(tracker.serialize()));
|
|
379
|
+
return true;
|
|
380
|
+
} catch (e) {
|
|
381
|
+
console.warn(`Could not save dependency graph: ${e.message}`);
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { resolve } from "path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hidden / system folder detection.
|
|
5
|
+
*
|
|
6
|
+
* The pattern matches a path segment that starts with a dot (but not `..`),
|
|
7
|
+
* `node_modules`, or `_templates`. A leading separator is required, which is
|
|
8
|
+
* why {@link toSourceRelative} always returns a path that begins with one.
|
|
9
|
+
*/
|
|
10
|
+
export const HIDDEN_OR_SYSTEM_DIRS =
|
|
11
|
+
/[\/\\]\.(?!\.)|[\/\\]node_modules[\/\\]|[\/\\]_templates[\/\\]|[\/\\]_templates$/;
|
|
12
|
+
|
|
13
|
+
/** The dev server's narrower pattern — no `_templates`, which it does not process. */
|
|
14
|
+
export const HIDDEN_OR_SYSTEM_DIRS_DEV = /[\/\\]\.(?!\.)|[\/\\]node_modules[\/\\]/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A file's path relative to the site source root, always separator-prefixed.
|
|
18
|
+
*
|
|
19
|
+
* **This is the whole point of this module.** These patterns must be tested
|
|
20
|
+
* against the path *relative to `source`*, never against the absolute path. A
|
|
21
|
+
* docroot that happens to live under a dot-directory — `~/.config/site`, a git
|
|
22
|
+
* worktree under `.claude/worktrees/…`, anything inside `.local` — is not a
|
|
23
|
+
* site full of hidden folders, but an absolute-path test reads it as one and
|
|
24
|
+
* classifies **every** article as hidden. The failure is silent and
|
|
25
|
+
* particularly nasty: generation reports success, and writes a site with zero
|
|
26
|
+
* pages, so the first symptom is an empty deploy.
|
|
27
|
+
*
|
|
28
|
+
* The separator prefix preserves detection of a genuinely hidden folder at the
|
|
29
|
+
* top of the docroot (`<source>/.drafts/x.md` → `/.drafts/x.md`, still a match).
|
|
30
|
+
*
|
|
31
|
+
* @param {string} filePath - Absolute path to a file or directory
|
|
32
|
+
* @param {string} source - Absolute path of the source root (trailing slash optional)
|
|
33
|
+
* @returns {string} The relative path, beginning with `/`
|
|
34
|
+
*/
|
|
35
|
+
export function toSourceRelative(filePath, source) {
|
|
36
|
+
if (!source) return filePath;
|
|
37
|
+
|
|
38
|
+
// Normalize both sides so a trailing slash on either cannot change the result.
|
|
39
|
+
const root = resolve(source);
|
|
40
|
+
const full = resolve(filePath);
|
|
41
|
+
|
|
42
|
+
if (full === root) return "/";
|
|
43
|
+
if (!full.startsWith(root)) return full; // outside the docroot; test it as-is
|
|
44
|
+
|
|
45
|
+
const rel = full.slice(root.length);
|
|
46
|
+
return rel.startsWith("/") || rel.startsWith("\\") ? rel : `/${rel}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* True when a path lies inside a hidden or system folder *within the docroot*.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} filePath - Absolute path to a file or directory
|
|
53
|
+
* @param {string} source - Absolute path of the source root
|
|
54
|
+
* @param {RegExp} [pattern] - Defaults to {@link HIDDEN_OR_SYSTEM_DIRS}
|
|
55
|
+
*/
|
|
56
|
+
export function isHiddenOrSystemPath(
|
|
57
|
+
filePath,
|
|
58
|
+
source,
|
|
59
|
+
pattern = HIDDEN_OR_SYSTEM_DIRS
|
|
60
|
+
) {
|
|
61
|
+
return pattern.test(toSourceRelative(filePath, source));
|
|
62
|
+
}
|
package/src/helper/portUtils.js
CHANGED
|
@@ -47,6 +47,35 @@ export async function findClosestAvailablePort(preferred, maxDistance = 100) {
|
|
|
47
47
|
return null;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Find the closest port P such that both P (HTTP) and P+1 (WebSocket) are
|
|
52
|
+
* available. Searches both upward and downward from the preferred port.
|
|
53
|
+
* @param {number} preferred - The preferred port number
|
|
54
|
+
* @param {number} [maxDistance=100] - Maximum distance to search from preferred port
|
|
55
|
+
* @returns {Promise<number|null>} The closest available port pair base, or null if none found
|
|
56
|
+
*/
|
|
57
|
+
export async function findClosestAvailablePortPair(preferred, maxDistance = 100) {
|
|
58
|
+
for (let offset = 1; offset <= maxDistance; offset++) {
|
|
59
|
+
const candidates = [];
|
|
60
|
+
if (preferred + offset <= 65534) candidates.push(preferred + offset);
|
|
61
|
+
if (preferred - offset >= 1024) candidates.push(preferred - offset);
|
|
62
|
+
|
|
63
|
+
// Check both candidates (up and down) in parallel
|
|
64
|
+
const results = await Promise.all(
|
|
65
|
+
candidates.map(async (port) => ({
|
|
66
|
+
port,
|
|
67
|
+
available: (await isPortAvailable(port)) && (await isPortAvailable(port + 1)),
|
|
68
|
+
}))
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// Return the first available candidate (lower offset = closer)
|
|
72
|
+
// Since we push +offset first, it's preferred over -offset at the same distance
|
|
73
|
+
const found = results.find((r) => r.available);
|
|
74
|
+
if (found) return found.port;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
50
79
|
/**
|
|
51
80
|
* Prompt the user via stdin to confirm using an alternative port.
|
|
52
81
|
* @param {number} originalPort
|
|
@@ -77,11 +106,30 @@ function promptUser(originalPort, alternativePort) {
|
|
|
77
106
|
*
|
|
78
107
|
* Also checks wsPort (port + 1) availability since the WebSocket server needs it.
|
|
79
108
|
*
|
|
109
|
+
* ## Why `strict` and the TTY check exist
|
|
110
|
+
*
|
|
111
|
+
* `ursa serve` is increasingly run as one process among several — a `pnpm dev`
|
|
112
|
+
* that starts an app, an API and this wiki in parallel. Two things go wrong
|
|
113
|
+
* there that do not go wrong at an interactive terminal:
|
|
114
|
+
*
|
|
115
|
+
* 1. **The prompt has nobody to answer it.** Sibling processes share stdin, so
|
|
116
|
+
* the question either hangs the whole dev command or eats a keystroke meant
|
|
117
|
+
* for another process. Hence: never prompt when stdin is not a TTY.
|
|
118
|
+
* 2. **A different port is not automatically a good outcome.** Whatever embeds
|
|
119
|
+
* the wiki — an iframe, a proxy, a link — was configured with the port that
|
|
120
|
+
* was asked for. Silently serving on another one produces a broken embed
|
|
121
|
+
* with no error anywhere. Hence `strict`: fail loudly instead.
|
|
122
|
+
*
|
|
80
123
|
* @param {number} port - The desired port
|
|
81
|
-
* @
|
|
82
|
-
* @
|
|
124
|
+
* @param {object} [options]
|
|
125
|
+
* @param {boolean} [options.strict=false] - Fail rather than use another port
|
|
126
|
+
* @param {boolean} [options.interactive] - Defaults to whether stdin is a TTY
|
|
127
|
+
* @returns {Promise<number>} The port to use
|
|
128
|
+
* @throws {Error} If no port is available, or `strict` and the port is taken
|
|
83
129
|
*/
|
|
84
|
-
export async function resolvePort(port) {
|
|
130
|
+
export async function resolvePort(port, options = {}) {
|
|
131
|
+
const { strict = false, interactive = Boolean(process.stdin.isTTY) } = options;
|
|
132
|
+
|
|
85
133
|
const httpAvailable = await isPortAvailable(port);
|
|
86
134
|
const wsAvailable = await isPortAvailable(port + 1);
|
|
87
135
|
|
|
@@ -91,35 +139,36 @@ export async function resolvePort(port) {
|
|
|
91
139
|
|
|
92
140
|
const reason = !httpAvailable
|
|
93
141
|
? `Port ${port} is already in use`
|
|
94
|
-
: `WebSocket port ${port + 1} is already in use`;
|
|
142
|
+
: `WebSocket port ${port + 1} is already in use (ursa serves hot-reload there)`;
|
|
143
|
+
|
|
144
|
+
if (strict) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`${reason}. Refusing to use a different port because --strict-port was ` +
|
|
147
|
+
`given. Free the port, or pass --port <n> to choose another deliberately.`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
95
150
|
|
|
96
151
|
console.log(`\n⚠️ ${reason}.`);
|
|
97
152
|
console.log(`🔍 Searching for an available port...`);
|
|
98
153
|
|
|
99
|
-
|
|
154
|
+
// The alternative must have both its HTTP port and its WebSocket port (port + 1) free
|
|
155
|
+
const alternative = await findClosestAvailablePortPair(port);
|
|
100
156
|
|
|
101
157
|
if (!alternative) {
|
|
102
158
|
throw new Error(
|
|
103
|
-
`Could not find an available port near ${port}. Please free up a port and try again.`
|
|
159
|
+
`Could not find an available port pair (HTTP + WebSocket) near ${port}. Please free up a port and try again.`
|
|
104
160
|
);
|
|
105
161
|
}
|
|
106
162
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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;
|
|
163
|
+
if (!interactive) {
|
|
164
|
+
// Nobody can answer the prompt; asking would hang. Be loud instead, since
|
|
165
|
+
// anything pointed at the original port is now pointed at nothing.
|
|
166
|
+
console.log(
|
|
167
|
+
`⚠️ stdin is not a TTY, so using port ${alternative} without asking.\n` +
|
|
168
|
+
` Anything configured for port ${port} must be updated, or pass ` +
|
|
169
|
+
`--strict-port to fail instead.`
|
|
170
|
+
);
|
|
171
|
+
return alternative;
|
|
123
172
|
}
|
|
124
173
|
|
|
125
174
|
const accepted = await promptUser(port, alternative);
|