@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/CHANGELOG.md +796 -737
- package/bin/ursa.js +7 -1
- package/meta/templates/default-template/index.html +1 -0
- package/package.json +1 -1
- package/src/dev.js +8 -4
- package/src/helper/__test__/hiddenPaths.test.js +106 -0
- package/src/helper/__test__/portUtils.test.js +100 -0
- package/src/helper/__test__/staticAssets.test.js +47 -0
- package/src/helper/automenu.js +43 -3
- package/src/helper/build/autoIndex.js +10 -5
- package/src/helper/contentHash.js +19 -0
- package/src/helper/hiddenPaths.js +62 -0
- package/src/helper/portUtils.js +71 -22
- package/src/helper/staticAssets.js +31 -0
- package/src/jobs/generate.js +1771 -1714
- package/src/serve.js +907 -906
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);
|
package/package.json
CHANGED
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
});
|
package/src/helper/automenu.js
CHANGED
|
@@ -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
|
-
|
|
452
|
-
|
|
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
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
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
|
+
}
|
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);
|
|
@@ -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);
|