@kenjura/ursa 0.96.0 → 0.98.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 +44 -0
- package/README.md +144 -16
- package/bin/ursa.js +14 -1
- package/meta/templates/default-template/default.css +144 -0
- package/meta/templates/default-template/menu.js +18 -1
- package/meta/templates/default-template/search.js +11 -0
- package/meta/templates/default-template/sectionify.js +17 -9
- package/meta/templates/default-template/widgets.js +4 -0
- package/package.json +1 -2
- package/src/dev.js +13 -23
- package/src/helper/__test__/contentHash.test.js +16 -6
- package/src/helper/__test__/inlineMenu.test.js +142 -0
- package/src/helper/assetBundler.js +93 -19
- package/src/helper/automenu.js +39 -13
- package/src/helper/build/__test__/autoIndex.test.js +2 -132
- package/src/helper/build/__test__/graph.test.js +259 -3
- package/src/helper/build/__test__/pass.test.js +664 -0
- package/src/helper/build/autoIndex.js +6 -371
- package/src/helper/build/excludeFilter.js +1 -2
- package/src/helper/build/footer.js +27 -14
- package/src/helper/build/graph.js +575 -152
- package/src/helper/build/index.js +0 -2
- package/src/helper/build/metadata.js +19 -5
- package/src/helper/build/pass.js +497 -0
- package/src/helper/build/precedence.js +174 -0
- package/src/helper/build/site.js +1392 -0
- package/src/helper/build/templates.js +1 -2
- package/src/helper/build/tracedFs.js +247 -0
- package/src/helper/contentHash.js +0 -78
- package/src/helper/customMenu.js +27 -4
- package/src/helper/fileRenderer.js +119 -111
- package/src/helper/findScriptJs.js +1 -1
- package/src/helper/findStyleCss.js +1 -1
- package/src/helper/folderConfig.js +7 -18
- package/src/helper/fullTextIndex.js +41 -29
- package/src/helper/imageProcessor.js +45 -0
- package/src/helper/inlineMenu.js +275 -0
- package/src/helper/linkValidator.js +118 -127
- package/src/helper/mdxRenderer.js +27 -5
- package/src/helper/menuLabels.js +30 -5
- package/src/helper/whitelistFilter.js +1 -2
- package/src/jobs/generate.js +67 -1829
- package/src/serve.js +317 -697
- package/src/helper/__test__/dependencyTracker.test.js +0 -157
- package/src/helper/build/cacheBust.js +0 -141
- package/src/helper/build/navCache.js +0 -145
- package/src/helper/build/watchCache.js +0 -33
- package/src/helper/dependencyTracker.js +0 -384
|
@@ -3,13 +3,17 @@ import { mkdtemp, writeFile, rm, unlink, readFile } from "fs/promises";
|
|
|
3
3
|
import { tmpdir } from "os";
|
|
4
4
|
import {
|
|
5
5
|
BuildGraph,
|
|
6
|
+
GRAPH_SCHEMA_VERSION,
|
|
6
7
|
GraphComputeError,
|
|
8
|
+
dirNodeId,
|
|
7
9
|
fileNodeId,
|
|
8
10
|
lookupNodeId,
|
|
9
11
|
loadGraph,
|
|
10
12
|
saveGraph,
|
|
11
13
|
getGraphPath,
|
|
12
14
|
} from "../graph.js";
|
|
15
|
+
import * as tfs from "../tracedFs.js";
|
|
16
|
+
import { mkdir } from "fs/promises";
|
|
13
17
|
|
|
14
18
|
let tempDir;
|
|
15
19
|
beforeEach(async () => {
|
|
@@ -353,7 +357,7 @@ describe("persistence", () => {
|
|
|
353
357
|
await g1.build(["c"]);
|
|
354
358
|
expect(await saveGraph(tempDir, g1)).toBe(true);
|
|
355
359
|
const onDisk = JSON.parse(await readFile(getGraphPath(tempDir), "utf8"));
|
|
356
|
-
expect(onDisk.version).toBe(
|
|
360
|
+
expect(onDisk.version).toBe(GRAPH_SCHEMA_VERSION);
|
|
357
361
|
|
|
358
362
|
const g2 = new BuildGraph();
|
|
359
363
|
const counters2 = { b: 0, c: 0 };
|
|
@@ -458,7 +462,7 @@ describe("engine behavior", () => {
|
|
|
458
462
|
});
|
|
459
463
|
await graph.build(["parent"]);
|
|
460
464
|
|
|
461
|
-
graph.removeNode("dep");
|
|
465
|
+
await graph.removeNode("dep");
|
|
462
466
|
const r = await graph.build(["parent"]);
|
|
463
467
|
expect(r.results.get("parent")).toBe("alone");
|
|
464
468
|
});
|
|
@@ -471,7 +475,7 @@ describe("engine behavior", () => {
|
|
|
471
475
|
graph.node("pageB", (ctx) => ctx.read(p("b.txt")));
|
|
472
476
|
await graph.build(["pageA", "pageB"]);
|
|
473
477
|
|
|
474
|
-
graph.gc(new Set(["pageA"]));
|
|
478
|
+
await graph.gc(new Set(["pageA"]));
|
|
475
479
|
expect(graph.edges.has("pageB")).toBe(false);
|
|
476
480
|
expect(graph.fingerprints.has("pageB")).toBe(false);
|
|
477
481
|
expect(graph.fingerprints.has(fileNodeId(p("b.txt")))).toBe(false);
|
|
@@ -527,3 +531,255 @@ describe("engine behavior", () => {
|
|
|
527
531
|
expect(stats.failed).toBe(0);
|
|
528
532
|
});
|
|
529
533
|
});
|
|
534
|
+
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
// Directory leaves
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
describe("directory leaves", () => {
|
|
539
|
+
it("adding, removing or renaming an entry changes the listing; editing content does not", async () => {
|
|
540
|
+
await mkdir(p("d"));
|
|
541
|
+
await writeFile(p("d/a.md"), "one");
|
|
542
|
+
const graph = new BuildGraph();
|
|
543
|
+
let count = 0;
|
|
544
|
+
graph.node("names", async (ctx) => {
|
|
545
|
+
count++;
|
|
546
|
+
return (await ctx.listDir(p("d"))).map((e) => e.name);
|
|
547
|
+
});
|
|
548
|
+
const r1 = await graph.build(["names"]);
|
|
549
|
+
expect(r1.results.get("names")).toEqual(["a.md"]);
|
|
550
|
+
|
|
551
|
+
// Content edit: the listing is unchanged
|
|
552
|
+
await writeFile(p("d/a.md"), "two");
|
|
553
|
+
graph.invalidatePath(p("d/a.md"));
|
|
554
|
+
await graph.build(["names"]);
|
|
555
|
+
expect(count).toBe(1);
|
|
556
|
+
|
|
557
|
+
// New entry
|
|
558
|
+
await writeFile(p("d/b.md"), "x");
|
|
559
|
+
graph.invalidatePath(p("d/b.md"));
|
|
560
|
+
const r2 = await graph.build(["names"]);
|
|
561
|
+
expect(r2.results.get("names")).toEqual(["a.md", "b.md"]);
|
|
562
|
+
expect(count).toBe(2);
|
|
563
|
+
|
|
564
|
+
// Scratch files are invisible
|
|
565
|
+
await writeFile(p("d/.b.md.swp"), "x");
|
|
566
|
+
await writeFile(p("d/4913"), "x");
|
|
567
|
+
graph.invalidatePath(p("d/4913"));
|
|
568
|
+
await graph.build(["names"]);
|
|
569
|
+
expect(count).toBe(2);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it("a missing directory lists as [] and its creation is observed", async () => {
|
|
573
|
+
const graph = new BuildGraph();
|
|
574
|
+
graph.node("names", async (ctx) => (await ctx.listDir(p("later"))).map((e) => e.name));
|
|
575
|
+
const r1 = await graph.build(["names"]);
|
|
576
|
+
expect(r1.results.get("names")).toEqual([]);
|
|
577
|
+
await mkdir(p("later"));
|
|
578
|
+
await writeFile(p("later/x.md"), "x");
|
|
579
|
+
graph.invalidateSubtree(p("later"));
|
|
580
|
+
const r2 = await graph.build(["names"]);
|
|
581
|
+
expect(r2.results.get("names")).toEqual(["x.md"]);
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
// Traced fs: helpers that read the disk directly still record edges
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
describe("traced filesystem", () => {
|
|
589
|
+
it("records sync reads made by nested helpers as edges of the computing node", async () => {
|
|
590
|
+
await writeFile(p("cfg.json"), '{"label":"A"}');
|
|
591
|
+
const helper = () => (tfs.existsSync(p("cfg.json")) ? JSON.parse(tfs.readFileSync(p("cfg.json"), "utf8")).label : "none");
|
|
592
|
+
const graph = new BuildGraph();
|
|
593
|
+
let count = 0;
|
|
594
|
+
graph.node("label", async () => {
|
|
595
|
+
count++;
|
|
596
|
+
return helper();
|
|
597
|
+
});
|
|
598
|
+
const r1 = await graph.build(["label"]);
|
|
599
|
+
expect(r1.results.get("label")).toBe("A");
|
|
600
|
+
expect(graph.edges.get("label").has(fileNodeId(p("cfg.json")))).toBe(true);
|
|
601
|
+
expect(graph.edges.get("label").has(lookupNodeId(p("cfg.json")))).toBe(true);
|
|
602
|
+
|
|
603
|
+
await writeFile(p("cfg.json"), '{"label":"B"}');
|
|
604
|
+
graph.invalidatePath(p("cfg.json"));
|
|
605
|
+
const r2 = await graph.build(["label"]);
|
|
606
|
+
expect(r2.results.get("label")).toBe("B");
|
|
607
|
+
expect(count).toBe(2);
|
|
608
|
+
|
|
609
|
+
await unlink(p("cfg.json"));
|
|
610
|
+
graph.invalidatePath(p("cfg.json"));
|
|
611
|
+
const r3 = await graph.build(["label"]);
|
|
612
|
+
expect(r3.results.get("label")).toBe("none");
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("attributes concurrent computes to the right node", async () => {
|
|
616
|
+
await writeFile(p("x.txt"), "x");
|
|
617
|
+
await writeFile(p("y.txt"), "y");
|
|
618
|
+
const graph = new BuildGraph();
|
|
619
|
+
graph.node("x", async () => {
|
|
620
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
621
|
+
return tfs.readFileSync(p("x.txt"), "utf8");
|
|
622
|
+
});
|
|
623
|
+
graph.node("y", async () => {
|
|
624
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
625
|
+
return tfs.readFileSync(p("y.txt"), "utf8");
|
|
626
|
+
});
|
|
627
|
+
await graph.build(["x", "y"], { concurrency: 2 });
|
|
628
|
+
expect([...graph.edges.get("x").keys()]).toEqual([fileNodeId(p("x.txt"))]);
|
|
629
|
+
expect([...graph.edges.get("y").keys()]).toEqual([fileNodeId(p("y.txt"))]);
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
// ---------------------------------------------------------------------------
|
|
634
|
+
// Concurrency
|
|
635
|
+
// ---------------------------------------------------------------------------
|
|
636
|
+
describe("concurrent roots", () => {
|
|
637
|
+
it("computes a shared dependency once when two roots demand it at the same time", async () => {
|
|
638
|
+
await writeFile(p("a.txt"), "a");
|
|
639
|
+
const graph = new BuildGraph();
|
|
640
|
+
let shared = 0;
|
|
641
|
+
graph.node("shared", async (ctx) => {
|
|
642
|
+
shared++;
|
|
643
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
644
|
+
return ctx.read(p("a.txt"));
|
|
645
|
+
});
|
|
646
|
+
graph.node("r1", async (ctx) => "1:" + (await ctx.get("shared")));
|
|
647
|
+
graph.node("r2", async (ctx) => "2:" + (await ctx.get("shared")));
|
|
648
|
+
const r = await graph.build(["r1", "r2"], { concurrency: 2 });
|
|
649
|
+
expect(r.ok).toBe(true);
|
|
650
|
+
expect(shared).toBe(1);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("still detects a real cycle under concurrency", async () => {
|
|
654
|
+
const graph = new BuildGraph();
|
|
655
|
+
graph.node("a", (ctx) => ctx.get("b"));
|
|
656
|
+
graph.node("b", (ctx) => ctx.get("a"));
|
|
657
|
+
const r = await graph.build(["a"], { concurrency: 2 });
|
|
658
|
+
expect(r.ok).toBe(false);
|
|
659
|
+
expect(String(r.errors.get("a"))).toMatch(/cycle/i);
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// ---------------------------------------------------------------------------
|
|
664
|
+
// Ownership and orphan deletion
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
describe("output ownership", () => {
|
|
667
|
+
it("hands files a node stops owning to onOrphans, and all of them on removal", async () => {
|
|
668
|
+
await writeFile(p("a.txt"), "a");
|
|
669
|
+
const orphaned = [];
|
|
670
|
+
const graph = new BuildGraph({ onOrphans: (paths) => { orphaned.push(...paths); } });
|
|
671
|
+
let extra = true;
|
|
672
|
+
graph.node("out", async (ctx) => {
|
|
673
|
+
const v = await ctx.read(p("a.txt"));
|
|
674
|
+
ctx.own("/out/a.html");
|
|
675
|
+
if (extra) ctx.own("/out/a.xml");
|
|
676
|
+
return v;
|
|
677
|
+
});
|
|
678
|
+
await graph.build(["out"]);
|
|
679
|
+
expect(graph.ownerOfPath("/out/a.xml")).toBe("out");
|
|
680
|
+
|
|
681
|
+
extra = false;
|
|
682
|
+
await writeFile(p("a.txt"), "b");
|
|
683
|
+
graph.invalidatePath(p("a.txt"));
|
|
684
|
+
await graph.build(["out"]);
|
|
685
|
+
expect(orphaned).toEqual(["/out/a.xml"]);
|
|
686
|
+
expect(graph.ownerOfPath("/out/a.xml")).toBe(null);
|
|
687
|
+
|
|
688
|
+
await graph.removeNode("out");
|
|
689
|
+
expect(orphaned).toEqual(["/out/a.xml", "/out/a.html"]);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it("a path whose ownership moves to another node is not an orphan", async () => {
|
|
693
|
+
const orphaned = [];
|
|
694
|
+
const graph = new BuildGraph({ onOrphans: (paths) => { orphaned.push(...paths); } });
|
|
695
|
+
graph.node("first", async (ctx) => { ctx.own("/index.html"); return 1; });
|
|
696
|
+
graph.node("second", async (ctx) => { ctx.own("/index.html"); return 2; });
|
|
697
|
+
await graph.build(["first"]);
|
|
698
|
+
await graph.build(["second"]);
|
|
699
|
+
await graph.removeNode("first");
|
|
700
|
+
expect(orphaned).toEqual([]);
|
|
701
|
+
expect(graph.ownerOfPath("/index.html")).toBe("second");
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it("persists ownership", async () => {
|
|
705
|
+
const g1 = new BuildGraph();
|
|
706
|
+
g1.node("n", async (ctx) => { ctx.own("/n.html"); return 1; });
|
|
707
|
+
await g1.build(["n"]);
|
|
708
|
+
const g2 = new BuildGraph();
|
|
709
|
+
expect(g2.load(g1.serialize())).toBe(true);
|
|
710
|
+
expect(g2.ownedBy("n")).toEqual(["/n.html"]);
|
|
711
|
+
expect(g2.ownerOfPath("/n.html")).toBe("n");
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// ---------------------------------------------------------------------------
|
|
716
|
+
// Node families, relocatable ids, dirty sets, explain
|
|
717
|
+
// ---------------------------------------------------------------------------
|
|
718
|
+
describe("families and roots", () => {
|
|
719
|
+
it("resolves node families on demand", async () => {
|
|
720
|
+
await writeFile(p("a.txt"), "A");
|
|
721
|
+
const graph = new BuildGraph();
|
|
722
|
+
graph.resolver((id) => {
|
|
723
|
+
if (id.startsWith("upper:")) {
|
|
724
|
+
const file = id.slice("upper:".length);
|
|
725
|
+
return { fn: async (ctx) => (await ctx.read(p(file))).toUpperCase() };
|
|
726
|
+
}
|
|
727
|
+
return null;
|
|
728
|
+
});
|
|
729
|
+
graph.node("page", async (ctx) => "<" + (await ctx.get("upper:a.txt")) + ">");
|
|
730
|
+
const r = await graph.build(["page"]);
|
|
731
|
+
expect(r.results.get("page")).toBe("<A>");
|
|
732
|
+
expect(graph.hasNode("upper:a.txt")).toBe(true);
|
|
733
|
+
expect(graph.hasNode("nope:x")).toBe(false);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it("stores leaf ids relative to a root and resolves them back", async () => {
|
|
737
|
+
await mkdir(p("src"));
|
|
738
|
+
await writeFile(p("src/a.txt"), "A");
|
|
739
|
+
const g1 = new BuildGraph();
|
|
740
|
+
g1.setRoots({ S: p("src") });
|
|
741
|
+
g1.node("n", (ctx) => ctx.read(p("src/a.txt")));
|
|
742
|
+
await g1.build(["n"]);
|
|
743
|
+
expect([...g1.edges.get("n").keys()]).toEqual([fileNodeId("$S/a.txt")]);
|
|
744
|
+
|
|
745
|
+
// Relocate: the same tree under another root still verifies clean
|
|
746
|
+
await mkdir(p("moved"));
|
|
747
|
+
await writeFile(p("moved/a.txt"), "A");
|
|
748
|
+
const g2 = new BuildGraph();
|
|
749
|
+
g2.setRoots({ S: p("moved") });
|
|
750
|
+
g2.node("n", (ctx) => ctx.read(p("moved/a.txt")));
|
|
751
|
+
expect(g2.load(g1.serialize())).toBe(true);
|
|
752
|
+
await g2.scanLeaves();
|
|
753
|
+
const r = await g2.build(["n"]);
|
|
754
|
+
expect(r.computed.size).toBe(0);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it("dependents() gives the transitive dirty set of changed leaves", async () => {
|
|
758
|
+
await writeFile(p("a.txt"), "a");
|
|
759
|
+
await writeFile(p("b.txt"), "b");
|
|
760
|
+
const graph = new BuildGraph();
|
|
761
|
+
graph.node("A", (ctx) => ctx.read(p("a.txt")));
|
|
762
|
+
graph.node("B", (ctx) => ctx.read(p("b.txt")));
|
|
763
|
+
graph.node("AB", async (ctx) => (await ctx.get("A")) + (await ctx.get("B")));
|
|
764
|
+
graph.node("A2", async (ctx) => (await ctx.get("A")) + "!");
|
|
765
|
+
await graph.build(["AB", "A2"]);
|
|
766
|
+
await writeFile(p("b.txt"), "bb");
|
|
767
|
+
graph.invalidatePath(p("b.txt"));
|
|
768
|
+
const changed = await graph.refreshStale();
|
|
769
|
+
expect(changed).toEqual([fileNodeId(p("b.txt"))]);
|
|
770
|
+
expect([...graph.dependents(changed)].sort()).toEqual(["AB", "B"]);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
it("records why each node recomputed", async () => {
|
|
774
|
+
await writeFile(p("a.txt"), "a");
|
|
775
|
+
const graph = new BuildGraph();
|
|
776
|
+
graph.node("A", (ctx) => ctx.read(p("a.txt")));
|
|
777
|
+
graph.node("AA", async (ctx) => (await ctx.get("A")) + "!");
|
|
778
|
+
await graph.build(["AA"]);
|
|
779
|
+
await writeFile(p("a.txt"), "b");
|
|
780
|
+
graph.invalidatePath(p("a.txt"));
|
|
781
|
+
await graph.build(["AA"]);
|
|
782
|
+
expect(graph.reasons.get("A")).toBe(fileNodeId(p("a.txt")));
|
|
783
|
+
expect(graph.reasons.get("AA")).toBe("A");
|
|
784
|
+
});
|
|
785
|
+
});
|