@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
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,8 +33,9 @@ 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
|
-
import { getTemplates, getMenu, findAllCustomMenus, getCustomMenuForFile, getTransformedMetadata, getFooter, toTitleCase, addTrailingSlash, generateAutoIndexHtmlFromSource, copyMetaAssets } from "./helper/build/index.js";
|
|
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";
|
|
39
40
|
import { getAndIncrementBuildId } from "./helper/ursaConfig.js";
|
|
40
41
|
import { resolvePort } from "./helper/portUtils.js";
|
|
@@ -47,6 +48,9 @@ const devState = {
|
|
|
47
48
|
source: null,
|
|
48
49
|
meta: null,
|
|
49
50
|
output: null,
|
|
51
|
+
|
|
52
|
+
// Ursa + doc repo versions, embedded in JSON response headers
|
|
53
|
+
ursaMetadata: null,
|
|
50
54
|
|
|
51
55
|
// Background cache status
|
|
52
56
|
cacheReady: false,
|
|
@@ -594,11 +598,14 @@ async function buildBackgroundCaches() {
|
|
|
594
598
|
const allSourceFiles = await recurse(source, [() => false]);
|
|
595
599
|
|
|
596
600
|
// Filter hidden folders
|
|
597
|
-
|
|
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);
|
|
598
605
|
const articleExtensions = /\.(md|mdx|txt|yml)/;
|
|
599
606
|
|
|
600
607
|
const allArticles = allSourceFiles.filter(f =>
|
|
601
|
-
f.match(articleExtensions) && !f
|
|
608
|
+
f.match(articleExtensions) && !isHiddenOrSystem(f) && !isFolderHidden(dirname(f), source)
|
|
602
609
|
);
|
|
603
610
|
|
|
604
611
|
const allDirectories = [];
|
|
@@ -606,7 +613,7 @@ async function buildBackgroundCaches() {
|
|
|
606
613
|
for (const f of allSourceFiles) {
|
|
607
614
|
try {
|
|
608
615
|
const s = await stat(f);
|
|
609
|
-
if (s.isDirectory() && !f
|
|
616
|
+
if (s.isDirectory() && !isHiddenOrSystem(f) && !isFolderHidden(f, source)) {
|
|
610
617
|
if (!seenDirs.has(f)) {
|
|
611
618
|
seenDirs.add(f);
|
|
612
619
|
allDirectories.push(f);
|
|
@@ -620,7 +627,7 @@ async function buildBackgroundCaches() {
|
|
|
620
627
|
let dir = dirname(article);
|
|
621
628
|
while (dir.startsWith(source) && !seenDirs.has(dir)) {
|
|
622
629
|
seenDirs.add(dir);
|
|
623
|
-
if (!dir
|
|
630
|
+
if (!isHiddenOrSystem(dir)) {
|
|
624
631
|
allDirectories.push(dir);
|
|
625
632
|
}
|
|
626
633
|
dir = dirname(dir);
|
|
@@ -766,6 +773,7 @@ export async function dev({
|
|
|
766
773
|
devState.source = sourceDir + '/';
|
|
767
774
|
devState.meta = metaDir;
|
|
768
775
|
devState.output = outputDir + '/';
|
|
776
|
+
devState.ursaMetadata = await getUrsaMetadata(_source);
|
|
769
777
|
|
|
770
778
|
console.log('🚀 Ursa Dev Mode');
|
|
771
779
|
console.log('━'.repeat(50));
|
|
@@ -810,6 +818,17 @@ export async function dev({
|
|
|
810
818
|
threshold: 1024,
|
|
811
819
|
level: 6
|
|
812
820
|
}));
|
|
821
|
+
|
|
822
|
+
// Add ursa-version and doc-version headers to all JSON responses
|
|
823
|
+
// (per-document JSON, directory index arrays, and public/*.json index files)
|
|
824
|
+
app.use((req, res, next) => {
|
|
825
|
+
if (req.path.endsWith('.json')) {
|
|
826
|
+
const meta = devState.ursaMetadata || {};
|
|
827
|
+
res.setHeader('X-ursa-version', meta.ursaVersion || 'unknown');
|
|
828
|
+
res.setHeader('X-doc-version', meta.docVersion || 'unknown');
|
|
829
|
+
}
|
|
830
|
+
next();
|
|
831
|
+
});
|
|
813
832
|
|
|
814
833
|
// Dev mode document handler
|
|
815
834
|
app.use(async (req, res, next) => {
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { mkdtemp, rm, readFile } from "fs/promises";
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import {
|
|
6
|
+
DependencyTracker,
|
|
7
|
+
loadDependencyTracker,
|
|
8
|
+
saveDependencyTracker,
|
|
9
|
+
getDependencyGraphPath,
|
|
10
|
+
} from "../dependencyTracker.js";
|
|
11
|
+
|
|
12
|
+
let tempDir;
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
tempDir = await mkdtemp(join(tmpdir(), "ursa-deptracker-"));
|
|
15
|
+
});
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function makeTracker(sourceDir) {
|
|
21
|
+
const tracker = new DependencyTracker();
|
|
22
|
+
tracker.init(sourceDir);
|
|
23
|
+
return tracker;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("serialize / load", () => {
|
|
27
|
+
it("round-trips registrations through serialize + load", () => {
|
|
28
|
+
const t1 = makeTracker("/site/docs");
|
|
29
|
+
t1.registerDocument("/site/docs/a.md", {
|
|
30
|
+
templateName: "default-template",
|
|
31
|
+
cssPaths: ["/site/docs/style.css"],
|
|
32
|
+
scriptPaths: ["/site/docs/script.js"],
|
|
33
|
+
});
|
|
34
|
+
t1.registerDocument("/site/docs/sub/b.md", {
|
|
35
|
+
templateName: "wiki",
|
|
36
|
+
cssPaths: ["/site/docs/style.css", "/site/docs/sub/style.css"],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const data = JSON.parse(JSON.stringify(t1.serialize()));
|
|
40
|
+
const t2 = makeTracker("/site/docs");
|
|
41
|
+
expect(t2.load(data)).toBe(true);
|
|
42
|
+
|
|
43
|
+
expect([...t2.getAffectedDocuments("/site/docs/style.css")].sort()).toEqual([
|
|
44
|
+
"/site/docs/a.md",
|
|
45
|
+
"/site/docs/sub/b.md",
|
|
46
|
+
]);
|
|
47
|
+
expect([...t2.getDocumentsUsingTemplate("wiki")]).toEqual(["/site/docs/sub/b.md"]);
|
|
48
|
+
expect(t2.getStats()).toEqual(t1.getStats());
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("merges with current-run registrations, which take precedence", () => {
|
|
52
|
+
const t1 = makeTracker("/site/docs");
|
|
53
|
+
t1.registerDocument("/site/docs/a.md", { templateName: "old-template" });
|
|
54
|
+
t1.registerDocument("/site/docs/b.md", { templateName: "default-template" });
|
|
55
|
+
const persisted = t1.serialize();
|
|
56
|
+
|
|
57
|
+
// New run: a.md was re-rendered with a different template before load
|
|
58
|
+
const t2 = makeTracker("/site/docs");
|
|
59
|
+
t2.registerDocument("/site/docs/a.md", { templateName: "new-template" });
|
|
60
|
+
expect(t2.load(persisted)).toBe(true);
|
|
61
|
+
|
|
62
|
+
// Live registration wins; persisted fills in the hash-skipped doc
|
|
63
|
+
expect([...t2.getDocumentsUsingTemplate("new-template")]).toEqual(["/site/docs/a.md"]);
|
|
64
|
+
expect([...t2.getDocumentsUsingTemplate("old-template")]).toEqual([]);
|
|
65
|
+
expect([...t2.getDocumentsUsingTemplate("default-template")]).toEqual(["/site/docs/b.md"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("rejects mismatched schema versions and source dirs", () => {
|
|
69
|
+
const t = makeTracker("/site/docs");
|
|
70
|
+
expect(t.load(null)).toBe(false);
|
|
71
|
+
expect(t.load({ version: 99, documents: {} })).toBe(false);
|
|
72
|
+
const other = makeTracker("/different/source").serialize();
|
|
73
|
+
other.documents["/different/source/a.md"] = ["template:default-template"];
|
|
74
|
+
expect(t.load(other)).toBe(false);
|
|
75
|
+
expect(t.getStats().totalDocuments).toBe(0);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("prune", () => {
|
|
80
|
+
it("drops registrations for documents not in the keep set", () => {
|
|
81
|
+
const t = makeTracker("/site/docs");
|
|
82
|
+
t.registerDocument("/site/docs/keep.md", { cssPaths: ["/site/docs/style.css"] });
|
|
83
|
+
t.registerDocument("/site/docs/deleted.md", { cssPaths: ["/site/docs/style.css"] });
|
|
84
|
+
|
|
85
|
+
t.prune(new Set(["/site/docs/keep.md"]));
|
|
86
|
+
|
|
87
|
+
expect([...t.getAffectedDocuments("/site/docs/style.css")]).toEqual(["/site/docs/keep.md"]);
|
|
88
|
+
expect(t.getStats().totalDocuments).toBe(1);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("file persistence helpers", () => {
|
|
93
|
+
it("saves to and loads from .ursa/dependency-graph.json", async () => {
|
|
94
|
+
const t1 = makeTracker(tempDir);
|
|
95
|
+
t1.registerDocument(join(tempDir, "a.md"), {
|
|
96
|
+
templateName: "default-template",
|
|
97
|
+
cssPaths: [join(tempDir, "style.css")],
|
|
98
|
+
});
|
|
99
|
+
expect(await saveDependencyTracker(tempDir, t1)).toBe(true);
|
|
100
|
+
expect(existsSync(getDependencyGraphPath(tempDir))).toBe(true);
|
|
101
|
+
const onDisk = JSON.parse(await readFile(getDependencyGraphPath(tempDir), "utf8"));
|
|
102
|
+
expect(onDisk.version).toBe(1);
|
|
103
|
+
|
|
104
|
+
const t2 = makeTracker(tempDir);
|
|
105
|
+
expect(await loadDependencyTracker(tempDir, t2)).toBe(true);
|
|
106
|
+
expect([...t2.getAffectedDocuments(join(tempDir, "style.css"))]).toEqual([
|
|
107
|
+
join(tempDir, "a.md"),
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("returns false when no persisted graph exists", async () => {
|
|
112
|
+
const t = makeTracker(tempDir);
|
|
113
|
+
expect(await loadDependencyTracker(tempDir, t)).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("getMetaInvalidationPlan", () => {
|
|
118
|
+
it("does not force a full rebuild for static assets in meta", () => {
|
|
119
|
+
const t = makeTracker("/site/docs");
|
|
120
|
+
t.registerDocument("/site/docs/a.md", { templateName: "default-template" });
|
|
121
|
+
|
|
122
|
+
for (const file of ["logo.png", "font.woff2", "manual.pdf", "icon.SVG"]) {
|
|
123
|
+
const plan = t.getMetaInvalidationPlan(`/site/meta/shared/${file}`, "/site/meta");
|
|
124
|
+
expect(plan.requiresFullRebuild).toBe(false);
|
|
125
|
+
expect(plan.affectedDocuments).toEqual([]);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("still regenerates documents for template and css/js meta changes", () => {
|
|
130
|
+
const t = makeTracker("/site/docs");
|
|
131
|
+
t.registerDocument("/site/docs/a.md", { templateName: "default-template" });
|
|
132
|
+
|
|
133
|
+
// New folder structure: templates/{name}/index.html → name from the folder
|
|
134
|
+
const tplPlan = t.getMetaInvalidationPlan(
|
|
135
|
+
"/site/meta/templates/default-template/index.html",
|
|
136
|
+
"/site/meta"
|
|
137
|
+
);
|
|
138
|
+
expect(tplPlan.requiresFullRebuild).toBe(false);
|
|
139
|
+
expect(tplPlan.affectedDocuments).toEqual(["/site/docs/a.md"]);
|
|
140
|
+
|
|
141
|
+
// Legacy flat structure: {name}.html at the meta root
|
|
142
|
+
const legacyPlan = t.getMetaInvalidationPlan(
|
|
143
|
+
"/site/meta/default-template.html",
|
|
144
|
+
"/site/meta"
|
|
145
|
+
);
|
|
146
|
+
expect(legacyPlan.requiresFullRebuild).toBe(false);
|
|
147
|
+
expect(legacyPlan.affectedDocuments).toEqual(["/site/docs/a.md"]);
|
|
148
|
+
|
|
149
|
+
const cssPlan = t.getMetaInvalidationPlan("/site/meta/shared/theme.css", "/site/meta");
|
|
150
|
+
expect(cssPlan.requiresFullRebuild).toBe(false);
|
|
151
|
+
expect(cssPlan.affectedDocuments).toEqual(["/site/docs/a.md"]);
|
|
152
|
+
|
|
153
|
+
// Unknown meta file types still fall back to a full rebuild
|
|
154
|
+
const unknownPlan = t.getMetaInvalidationPlan("/site/meta/shared/data.json", "/site/meta");
|
|
155
|
+
expect(unknownPlan.requiresFullRebuild).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -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
|
+
});
|
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);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, rm, readFile } from "fs/promises";
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { generateAutoIndices } from "../autoIndex.js";
|
|
6
|
+
|
|
7
|
+
let tempDir;
|
|
8
|
+
let source;
|
|
9
|
+
let output;
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
tempDir = await mkdtemp(join(tmpdir(), "ursa-autoindex-"));
|
|
12
|
+
source = join(tempDir, "source");
|
|
13
|
+
output = join(tempDir, "output");
|
|
14
|
+
await mkdir(source, { recursive: true });
|
|
15
|
+
await mkdir(output, { recursive: true });
|
|
16
|
+
});
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const TEMPLATE =
|
|
22
|
+
"<html><head>${styleLink}</head><body>${menu}${body}${footer}${customScript}</body></html>";
|
|
23
|
+
|
|
24
|
+
function makeProgress() {
|
|
25
|
+
const logs = [];
|
|
26
|
+
return {
|
|
27
|
+
logs,
|
|
28
|
+
log: (msg) => logs.push(msg),
|
|
29
|
+
status: () => {},
|
|
30
|
+
done: () => {},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function runAutoIndices(directories, generatedArticles, progress) {
|
|
35
|
+
return generateAutoIndices(
|
|
36
|
+
output,
|
|
37
|
+
directories,
|
|
38
|
+
source,
|
|
39
|
+
{ "default-template": TEMPLATE },
|
|
40
|
+
"",
|
|
41
|
+
"",
|
|
42
|
+
generatedArticles,
|
|
43
|
+
new Set(),
|
|
44
|
+
new Set(),
|
|
45
|
+
"20260101000000",
|
|
46
|
+
progress,
|
|
47
|
+
null
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("generateAutoIndices with empty source folders", () => {
|
|
52
|
+
it("skips output directories that were never created instead of logging an error", async () => {
|
|
53
|
+
// Source has an empty folder (guides) and a folder with a document (docs).
|
|
54
|
+
// Only docs produced output files, so output/guides does not exist.
|
|
55
|
+
await mkdir(join(source, "guides"));
|
|
56
|
+
await mkdir(join(source, "docs"));
|
|
57
|
+
await writeFile(join(source, "docs", "hello.md"), "# Hello\n\nWorld\n");
|
|
58
|
+
await mkdir(join(output, "docs"));
|
|
59
|
+
await writeFile(join(output, "docs", "hello.html"), "<html><body>Hello</body></html>");
|
|
60
|
+
|
|
61
|
+
const progress = makeProgress();
|
|
62
|
+
await runAutoIndices(
|
|
63
|
+
[join(source, "guides"), join(source, "docs")],
|
|
64
|
+
[join(source, "docs", "hello.md")],
|
|
65
|
+
progress
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const errors = progress.logs.filter((m) => /Error generating auto-index/i.test(m));
|
|
69
|
+
expect(errors).toEqual([]);
|
|
70
|
+
// The missing output directory is skipped, not created
|
|
71
|
+
expect(existsSync(join(output, "guides"))).toBe(false);
|
|
72
|
+
expect(existsSync(join(output, "guides", "index.html"))).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("still generates auto-indices for folders that produced output", async () => {
|
|
76
|
+
await mkdir(join(source, "guides"));
|
|
77
|
+
await mkdir(join(source, "docs"));
|
|
78
|
+
await writeFile(join(source, "docs", "hello.md"), "# Hello\n\nWorld\n");
|
|
79
|
+
await mkdir(join(output, "docs"));
|
|
80
|
+
await writeFile(join(output, "docs", "hello.html"), "<html><body>Hello</body></html>");
|
|
81
|
+
|
|
82
|
+
const progress = makeProgress();
|
|
83
|
+
await runAutoIndices(
|
|
84
|
+
[join(source, "guides"), join(source, "docs")],
|
|
85
|
+
[join(source, "docs", "hello.md")],
|
|
86
|
+
progress
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// Root and docs both exist in output, so both get an index.html
|
|
90
|
+
const docsIndex = await readFile(join(output, "docs", "index.html"), "utf8");
|
|
91
|
+
expect(docsIndex).toContain('<a href="hello.html">');
|
|
92
|
+
const rootIndex = await readFile(join(output, "index.html"), "utf8");
|
|
93
|
+
expect(rootIndex).toContain('<a href="docs/index.html">');
|
|
94
|
+
});
|
|
95
|
+
});
|