@kenjura/ursa 0.87.1 → 0.89.1

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/bin/ursa.js CHANGED
@@ -123,6 +123,11 @@ yargs(hideBin(process.argv))
123
123
  describe: 'Port to serve on',
124
124
  type: 'number'
125
125
  })
126
+ .option('strict-port', {
127
+ describe: 'Fail if the port is taken instead of falling back to another',
128
+ type: 'boolean',
129
+ default: false
130
+ })
126
131
  .option('whitelist', {
127
132
  alias: 'w',
128
133
  describe: 'Path to whitelist file containing patterns for files to include',
@@ -177,7 +182,8 @@ yargs(hideBin(process.argv))
177
182
  port: port,
178
183
  _whitelist: whitelist,
179
184
  _exclude: exclude,
180
- _clean: clean
185
+ _clean: clean,
186
+ strictPort: argv['strict-port']
181
187
  });
182
188
  } catch (error) {
183
189
  console.error('Error starting development server:', error.message);
@@ -1,3 +1,4 @@
1
+ <!DOCTYPE html>
1
2
  <html>
2
3
 
3
4
  <head>
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@kenjura/ursa",
3
3
  "author": "Andrew London <andrew@kenjura.com>",
4
4
  "type": "module",
5
- "version": "0.87.1",
5
+ "version": "0.89.1",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
package/src/dev.js CHANGED
@@ -33,6 +33,7 @@ import { generateBreadcrumbs } from "./helper/breadcrumbs.js";
33
33
  import { extractImageReferences } from "./helper/imageExtractor.js";
34
34
  import { recurse } from "./helper/recursive-readdir.js";
35
35
  import { isFolderHidden, clearConfigCache } from "./helper/folderConfig.js";
36
+ import { isHiddenOrSystemPath, HIDDEN_OR_SYSTEM_DIRS_DEV } from "./helper/hiddenPaths.js";
36
37
  import { extractSections } from "./helper/sectionExtractor.js";
37
38
  import { getTemplates, getMenu, findAllCustomMenus, getCustomMenuForFile, getTransformedMetadata, getFooter, getUrsaMetadata, toTitleCase, addTrailingSlash, generateAutoIndexHtmlFromSource, copyMetaAssets } from "./helper/build/index.js";
38
39
  import { findCustomMenu, extractMenuFrontmatter, parseCustomMenu, combineAutoAndManualMenu } from "./helper/customMenu.js";
@@ -597,11 +598,14 @@ async function buildBackgroundCaches() {
597
598
  const allSourceFiles = await recurse(source, [() => false]);
598
599
 
599
600
  // Filter hidden folders
600
- const hiddenOrSystemDirs = /[\/\\]\.(?!\.)|[\/\\]node_modules[\/\\]/;
601
+ // Judged RELATIVE to the docroot — see helper/hiddenPaths.js for why
602
+ // testing the absolute path silently yields an empty site.
603
+ const isHiddenOrSystem = (f) =>
604
+ isHiddenOrSystemPath(f, source, HIDDEN_OR_SYSTEM_DIRS_DEV);
601
605
  const articleExtensions = /\.(md|mdx|txt|yml)/;
602
606
 
603
607
  const allArticles = allSourceFiles.filter(f =>
604
- f.match(articleExtensions) && !f.match(hiddenOrSystemDirs) && !isFolderHidden(dirname(f), source)
608
+ f.match(articleExtensions) && !isHiddenOrSystem(f) && !isFolderHidden(dirname(f), source)
605
609
  );
606
610
 
607
611
  const allDirectories = [];
@@ -609,7 +613,7 @@ async function buildBackgroundCaches() {
609
613
  for (const f of allSourceFiles) {
610
614
  try {
611
615
  const s = await stat(f);
612
- if (s.isDirectory() && !f.match(hiddenOrSystemDirs) && !isFolderHidden(f, source)) {
616
+ if (s.isDirectory() && !isHiddenOrSystem(f) && !isFolderHidden(f, source)) {
613
617
  if (!seenDirs.has(f)) {
614
618
  seenDirs.add(f);
615
619
  allDirectories.push(f);
@@ -623,7 +627,7 @@ async function buildBackgroundCaches() {
623
627
  let dir = dirname(article);
624
628
  while (dir.startsWith(source) && !seenDirs.has(dir)) {
625
629
  seenDirs.add(dir);
626
- if (!dir.match(hiddenOrSystemDirs)) {
630
+ if (!isHiddenOrSystem(dir)) {
627
631
  allDirectories.push(dir);
628
632
  }
629
633
  dir = dirname(dir);
@@ -0,0 +1,106 @@
1
+ import {
2
+ HIDDEN_OR_SYSTEM_DIRS,
3
+ HIDDEN_OR_SYSTEM_DIRS_DEV,
4
+ isHiddenOrSystemPath,
5
+ toSourceRelative,
6
+ } from "../hiddenPaths.js";
7
+
8
+ describe("toSourceRelative", () => {
9
+ it("strips the docroot and keeps a leading separator", () => {
10
+ expect(toSourceRelative("/srv/site/a/b.md", "/srv/site")).toBe("/a/b.md");
11
+ });
12
+
13
+ it("does not care whether the docroot has a trailing slash", () => {
14
+ expect(toSourceRelative("/srv/site/a.md", "/srv/site/")).toBe("/a.md");
15
+ expect(toSourceRelative("/srv/site/a.md", "/srv/site")).toBe("/a.md");
16
+ });
17
+
18
+ it("returns '/' for the docroot itself", () => {
19
+ expect(toSourceRelative("/srv/site", "/srv/site")).toBe("/");
20
+ expect(toSourceRelative("/srv/site/", "/srv/site")).toBe("/");
21
+ });
22
+
23
+ it("passes through a path outside the docroot unchanged", () => {
24
+ expect(toSourceRelative("/elsewhere/a.md", "/srv/site")).toBe(
25
+ "/elsewhere/a.md"
26
+ );
27
+ });
28
+ });
29
+
30
+ describe("isHiddenOrSystemPath", () => {
31
+ it("hides a dot-folder inside the docroot", () => {
32
+ expect(isHiddenOrSystemPath("/srv/site/.drafts/a.md", "/srv/site")).toBe(
33
+ true
34
+ );
35
+ });
36
+
37
+ it("hides a dot-folder nested inside the docroot", () => {
38
+ expect(isHiddenOrSystemPath("/srv/site/a/.x/b.md", "/srv/site")).toBe(true);
39
+ });
40
+
41
+ it("hides node_modules and _templates inside the docroot", () => {
42
+ expect(
43
+ isHiddenOrSystemPath("/srv/site/node_modules/p/a.md", "/srv/site")
44
+ ).toBe(true);
45
+ expect(isHiddenOrSystemPath("/srv/site/_templates/a.md", "/srv/site")).toBe(
46
+ true
47
+ );
48
+ });
49
+
50
+ it("does not hide an ordinary article", () => {
51
+ expect(isHiddenOrSystemPath("/srv/site/a/b.md", "/srv/site")).toBe(false);
52
+ });
53
+
54
+ /*
55
+ * The regression this module exists for.
56
+ *
57
+ * Every one of these docroots is perfectly ordinary; only its ANCESTRY
58
+ * contains a dot-directory. Testing the absolute path marked all of them
59
+ * hidden, so `generate` classified zero articles, reported success, and wrote
60
+ * an empty site.
61
+ */
62
+ it.each([
63
+ ["a git worktree", "/Users/x/repo/.claude/worktrees/wt/docs/help"],
64
+ ["a dotfile config dir", "/Users/x/.config/site"],
65
+ ["~/.local", "/Users/x/.local/share/site"],
66
+ ])("does not hide the docroot because of %s", (_label, root) => {
67
+ expect(isHiddenOrSystemPath(`${root}/index.md`, root)).toBe(false);
68
+ expect(isHiddenOrSystemPath(`${root}/api/action-api.md`, root)).toBe(false);
69
+ });
70
+
71
+ it("still hides a dot-folder inside a docroot that is itself under one", () => {
72
+ const root = "/Users/x/repo/.claude/worktrees/wt/docs/help";
73
+ expect(isHiddenOrSystemPath(`${root}/.drafts/a.md`, root)).toBe(true);
74
+ });
75
+
76
+ it("does not treat '..' as a hidden folder", () => {
77
+ expect(isHiddenOrSystemPath("/srv/site/a/../b.md", "/srv/site")).toBe(false);
78
+ });
79
+
80
+ it("accepts an alternative pattern for the dev server", () => {
81
+ const root = "/srv/site";
82
+ // The dev pattern omits _templates, which dev mode does not process.
83
+ expect(
84
+ isHiddenOrSystemPath(
85
+ `${root}/_templates/a.md`,
86
+ root,
87
+ HIDDEN_OR_SYSTEM_DIRS_DEV
88
+ )
89
+ ).toBe(false);
90
+ expect(
91
+ isHiddenOrSystemPath(`${root}/.x/a.md`, root, HIDDEN_OR_SYSTEM_DIRS_DEV)
92
+ ).toBe(true);
93
+ });
94
+
95
+ it("exports patterns that are not sticky or global", () => {
96
+ // A /g or /y regex would carry lastIndex between calls and answer
97
+ // differently on alternate invocations.
98
+ for (const re of [HIDDEN_OR_SYSTEM_DIRS, HIDDEN_OR_SYSTEM_DIRS_DEV]) {
99
+ expect(re.global).toBe(false);
100
+ expect(re.sticky).toBe(false);
101
+ }
102
+ const p = "/srv/site/.x/a.md";
103
+ expect(isHiddenOrSystemPath(p, "/srv/site")).toBe(true);
104
+ expect(isHiddenOrSystemPath(p, "/srv/site")).toBe(true);
105
+ });
106
+ });
@@ -0,0 +1,100 @@
1
+ import net from "net";
2
+ import { jest } from "@jest/globals";
3
+ import { isPortAvailable, resolvePort } from "../portUtils.js";
4
+
5
+ /** Hold a port for the duration of `fn`. */
6
+ async function withPortHeld(port, fn) {
7
+ const server = net.createServer();
8
+ await new Promise((resolve, reject) => {
9
+ server.once("error", reject);
10
+ server.listen(port, resolve);
11
+ });
12
+ try {
13
+ return await fn();
14
+ } finally {
15
+ await new Promise((resolve) => server.close(resolve));
16
+ }
17
+ }
18
+
19
+ /** A port pair (n, n+1) that is currently free, so tests do not fight the machine. */
20
+ async function findFreePair(start = 39000) {
21
+ for (let p = start; p < start + 400; p += 2) {
22
+ if ((await isPortAvailable(p)) && (await isPortAvailable(p + 1))) return p;
23
+ }
24
+ throw new Error("no free port pair for test");
25
+ }
26
+
27
+ describe("resolvePort", () => {
28
+ let logSpy;
29
+ beforeEach(() => {
30
+ logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
31
+ });
32
+ afterEach(() => logSpy.mockRestore());
33
+
34
+ it("returns the requested port when it and its ws port are free", async () => {
35
+ const port = await findFreePair();
36
+ await expect(resolvePort(port, { strict: true })).resolves.toBe(port);
37
+ });
38
+
39
+ describe("strict", () => {
40
+ it("throws rather than moving when the HTTP port is taken", async () => {
41
+ const port = await findFreePair();
42
+ await withPortHeld(port, async () => {
43
+ await expect(resolvePort(port, { strict: true })).rejects.toThrow(
44
+ /already in use[\s\S]*--strict-port/
45
+ );
46
+ });
47
+ });
48
+
49
+ /*
50
+ * The WS port is the trap: ursa serves hot-reload on port+1, so a port pair
51
+ * is only usable if BOTH halves are free. A caller that checked only the
52
+ * HTTP port would call this fine and then die with EADDRINUSE later.
53
+ */
54
+ it("throws when only the WEBSOCKET port is taken", async () => {
55
+ const port = await findFreePair();
56
+ await withPortHeld(port + 1, async () => {
57
+ await expect(resolvePort(port, { strict: true })).rejects.toThrow(
58
+ /WebSocket port/
59
+ );
60
+ });
61
+ });
62
+ });
63
+
64
+ describe("non-interactive", () => {
65
+ /*
66
+ * The regression that motivated this: `ursa serve` running as one process
67
+ * of a parallel `pnpm dev`. Prompting there hangs the whole dev command,
68
+ * because sibling processes share stdin and nobody is reading this one.
69
+ */
70
+ it("falls back without prompting when stdin is not a TTY", async () => {
71
+ const port = await findFreePair();
72
+ const resolved = await withPortHeld(port, () =>
73
+ resolvePort(port, { interactive: false })
74
+ );
75
+ expect(resolved).not.toBe(port);
76
+ expect(typeof resolved).toBe("number");
77
+ });
78
+
79
+ it("says loudly which port it moved to", async () => {
80
+ const port = await findFreePair();
81
+ const resolved = await withPortHeld(port, () =>
82
+ resolvePort(port, { interactive: false })
83
+ );
84
+ const said = logSpy.mock.calls.flat().join("\n");
85
+ expect(said).toContain(String(resolved));
86
+ expect(said).toMatch(/not a TTY/);
87
+ // The whole point of being loud: whoever pointed at the old port must act.
88
+ expect(said).toMatch(/must be updated|--strict-port/);
89
+ });
90
+
91
+ it("prefers strict over falling back when both are in play", async () => {
92
+ const port = await findFreePair();
93
+ await withPortHeld(port, async () => {
94
+ await expect(
95
+ resolvePort(port, { strict: true, interactive: false })
96
+ ).rejects.toThrow(/--strict-port/);
97
+ });
98
+ });
99
+ });
100
+ });
@@ -0,0 +1,47 @@
1
+ import {
2
+ isImage,
3
+ isMedia,
4
+ isStaticAsset,
5
+ } from "../staticAssets.js";
6
+
7
+ describe("staticAssets", () => {
8
+ it("treats the image formats as images", () => {
9
+ for (const f of ["a.jpg", "a.jpeg", "a.PNG", "a.gif", "a.webp", "a.svg", "a.ico"]) {
10
+ expect(isImage(f)).toBe(true);
11
+ expect(isMedia(f)).toBe(false);
12
+ }
13
+ });
14
+
15
+ // The regression this module exists for: generate() copied images and HTML
16
+ // and nothing else, so every one of these 404'd in a built site.
17
+ it("treats fonts, audio, video and documents as media", () => {
18
+ for (const f of ["a.woff", "a.woff2", "a.ttf", "a.eot", "a.otf", "a.pdf",
19
+ "a.mp3", "a.m4a", "a.wav", "a.flac", "a.mp4", "a.m4v", "a.webm", "a.ogv", "a.ogg", "a.zip"]) {
20
+ expect(isMedia(f)).toBe(true);
21
+ expect(isImage(f)).toBe(false);
22
+ }
23
+ });
24
+
25
+ it("counts both as static assets", () => {
26
+ expect(isStaticAsset("clip.mp4")).toBe(true);
27
+ expect(isStaticAsset("Herculanum Regular.ttf")).toBe(true);
28
+ expect(isStaticAsset("photo.jpg")).toBe(true);
29
+ });
30
+
31
+ it("leaves the processed formats alone", () => {
32
+ for (const f of ["page.md", "page.mdx", "page.html", "style.css", "script.js", "data.json"]) {
33
+ expect(isStaticAsset(f)).toBe(false);
34
+ }
35
+ });
36
+
37
+ it("matches on the extension, not on a name that merely contains one", () => {
38
+ expect(isStaticAsset("mp4")).toBe(false);
39
+ expect(isStaticAsset("notes-about-mp4-encoding.md")).toBe(false);
40
+ expect(isStaticAsset("my.mp4.md")).toBe(false);
41
+ });
42
+
43
+ it("is case insensitive, as filesystems are not", () => {
44
+ expect(isStaticAsset("CLIP.MP4")).toBe(true);
45
+ expect(isStaticAsset("Font.TTF")).toBe(true);
46
+ });
47
+ });
@@ -1,4 +1,5 @@
1
1
  import dirTree from "directory-tree";
2
+ import { isHiddenOrSystemPath } from "./hiddenPaths.js";
2
3
  import { extname, basename, join, dirname } from "path";
3
4
  import { existsSync, readFileSync } from "fs";
4
5
  import { getFolderConfig, isFolderHidden, getRootConfig } from "./folderConfig.js";
@@ -447,10 +448,49 @@ function collapseSingleDocFolders(items) {
447
448
  });
448
449
  }
449
450
 
451
+ /**
452
+ * Drop hidden/system nodes from a directory-tree, judging each node's path
453
+ * RELATIVE to the docroot. See helper/hiddenPaths.js for why relative.
454
+ *
455
+ * Returns a new tree; the input is not mutated.
456
+ *
457
+ * @param {object} node - A directory-tree node
458
+ * @param {string} source - Absolute path of the docroot
459
+ * @returns {object} The pruned node
460
+ */
461
+ export function pruneHiddenNodes(node, source) {
462
+ if (!node.children) return node;
463
+ return {
464
+ ...node,
465
+ children: node.children
466
+ .filter((child) => !isHiddenOrSystemPath(child.path, source))
467
+ .map((child) => pruneHiddenNodes(child, source)),
468
+ };
469
+ }
470
+
450
471
  export async function getAutomenu(source, validPaths) {
451
- const tree = dirTree(source, {
452
- exclude: /[\/\\]\.|node_modules|_templates/, // Exclude hidden folders (starting with .), node_modules, and _templates
453
- });
472
+ /*
473
+ * Walk first, prune second.
474
+ *
475
+ * `dirTree`'s `exclude` is tested against each item's ABSOLUTE path,
476
+ * including the root's. A docroot that merely lives under a dot-directory —
477
+ * a git worktree under `.claude/worktrees/…`, anything in `~/.config` —
478
+ * therefore excluded ITSELF, and `dirTree` returned null, which reached
479
+ * `buildMenuData` as `Cannot read properties of null (reading 'children')`.
480
+ *
481
+ * Pruning afterwards judges each node relative to the docroot instead, which
482
+ * is what "hidden folder" was always meant to mean. The cost is that a
483
+ * `node_modules` sitting inside a docroot is now walked before being
484
+ * discarded; docroots do not normally contain one, and correctness on every
485
+ * ordinary path is worth more than speed on a pathological one.
486
+ */
487
+ const fullTree = dirTree(source);
488
+ if (!fullTree) {
489
+ throw new Error(
490
+ `Cannot read docroot for menu generation: ${source} (does it exist and is it a directory?)`
491
+ );
492
+ }
493
+ const tree = pruneHiddenNodes(fullTree, source);
454
494
 
455
495
  // Build menu data WITHOUT debug fields for smaller JSON
456
496
  let menuData = buildMenuData(tree, source, validPaths, '', false);
@@ -273,11 +273,16 @@ export async function generateAutoIndices(output, directories, source, templates
273
273
  continue; // Don't overwrite existing source HTML
274
274
  }
275
275
 
276
- // Skip if index.html already exists in output (e.g., created by previous run or current run)
277
- if (existsSync(indexPath)) {
278
- continue;
279
- }
280
-
276
+ // NOTE: an existing index.html in output is deliberately NOT a reason to
277
+ // skip. This listing describes the folder's contents, so it goes stale the
278
+ // moment a document or subfolder is added or removed, and warm builds kept
279
+ // whatever was written the first time — indefinitely. Everything that has a
280
+ // rightful claim on index.html is already excluded above: folders with a
281
+ // source index document (dirsWithSourceIndex, built from every source
282
+ // article rather than only the regenerated ones) and hand-written source
283
+ // HTML (existingHtmlFiles). Alternates are re-promoted below, so those stay
284
+ // authoritative too.
285
+
281
286
  // Get folder name for (foldername).html check
282
287
  const folderName = basename(dir);
283
288
  const folderNameAlternate = `${folderName}.html`;
@@ -61,6 +61,25 @@ export function needsRegeneration(filePath, content, hashCache) {
61
61
  return newHash !== oldHash;
62
62
  }
63
63
 
64
+ /**
65
+ * Check whether every expected output file for a source document exists.
66
+ *
67
+ * A matching content hash only proves the *source* is unchanged — it says
68
+ * nothing about whether the output was ever written to this particular output
69
+ * directory. The hash cache lives in the source tree (`<source>/.ursa/`) and is
70
+ * shared by every output directory built from that source, so a hash written
71
+ * during a build to one output dir will hash-skip the same file during a build
72
+ * to another. Deleting (or partially losing) an output dir has the same effect.
73
+ * Callers must combine this with needsRegeneration() so a missing output always
74
+ * forces a rebuild.
75
+ *
76
+ * @param {string[]} outputPaths - Absolute paths to every file the build emits for this document
77
+ * @returns {boolean} True only if all of them are present
78
+ */
79
+ export function outputsExist(outputPaths) {
80
+ return outputPaths.every((p) => existsSync(p));
81
+ }
82
+
64
83
  /**
65
84
  * Update the hash for a file in the cache
66
85
  */
@@ -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);
@@ -0,0 +1,31 @@
1
+ /**
2
+ * What counts as a static asset.
3
+ *
4
+ * This list used to be written out separately in serve.js and in
5
+ * dependencyTracker.js and — fatally — not at all in generate.js, which knew
6
+ * only about *image* extensions. `ursa serve` therefore served fonts, audio and
7
+ * video quite happily while `ursa generate` left them out of the build
8
+ * entirely, so they worked all the way through development and 404'd in
9
+ * production. One list, in one place, used by both.
10
+ */
11
+
12
+ /** Extensions that get preview generation and image transformation. */
13
+ export const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
14
+
15
+ /**
16
+ * Everything else copied through untouched: fonts, documents, audio, video.
17
+ * Deliberately not images, which take a different path, and deliberately not
18
+ * .css, .js, .html or the document formats, all of which are processed rather
19
+ * than copied.
20
+ */
21
+ export const MEDIA_EXTENSIONS = /\.(woff2?|ttf|eot|otf|pdf|mp3|m4a|wav|flac|mp4|m4v|webm|ogv|ogg|zip)$/i;
22
+
23
+ /** Any file the build should place in the output as-is. */
24
+ export const STATIC_ASSET_EXTENSIONS = new RegExp(
25
+ `(?:${IMAGE_EXTENSIONS.source})|(?:${MEDIA_EXTENSIONS.source})`,
26
+ 'i'
27
+ );
28
+
29
+ export const isImage = (filename) => IMAGE_EXTENSIONS.test(filename);
30
+ export const isMedia = (filename) => MEDIA_EXTENSIONS.test(filename);
31
+ export const isStaticAsset = (filename) => STATIC_ASSET_EXTENSIONS.test(filename);