@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.
@@ -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
- const templateName = fileName.replace(".html", "");
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
+ }
@@ -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
- * @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
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
- const alternative = await findClosestAvailablePort(port);
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
- // 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;
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);