@praxisjs/ssg 0.1.2 → 0.1.4
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 +42 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +39 -15
- package/dist/index.js.map +1 -1
- package/dist/loader-hook.d.ts.map +1 -1
- package/dist/loader-hook.js +3 -13
- package/dist/loader-hook.js.map +1 -1
- package/dist/prerender.d.ts +10 -7
- package/dist/prerender.d.ts.map +1 -1
- package/dist/prerender.js +80 -63
- package/dist/prerender.js.map +1 -1
- package/package.json +16 -8
- package/src/index.ts +42 -16
- package/src/loader-hook.ts +3 -13
- package/src/prerender.ts +95 -80
- package/dist/__tests__/loader-hook.test.d.ts +0 -2
- package/dist/__tests__/loader-hook.test.d.ts.map +0 -1
- package/dist/__tests__/loader-hook.test.js +0 -66
- package/dist/__tests__/loader-hook.test.js.map +0 -1
- package/dist/__tests__/plugin.test.d.ts +0 -2
- package/dist/__tests__/plugin.test.d.ts.map +0 -1
- package/dist/__tests__/plugin.test.js +0 -128
- package/dist/__tests__/plugin.test.js.map +0 -1
- package/dist/__tests__/prerender.test.d.ts +0 -2
- package/dist/__tests__/prerender.test.d.ts.map +0 -1
- package/dist/__tests__/prerender.test.js +0 -211
- package/dist/__tests__/prerender.test.js.map +0 -1
- package/dist/__tests__/register-fallback.test.d.ts +0 -2
- package/dist/__tests__/register-fallback.test.d.ts.map +0 -1
- package/dist/__tests__/register-fallback.test.js +0 -20
- package/dist/__tests__/register-fallback.test.js.map +0 -1
- package/dist/__tests__/resolve-sync.test.d.ts +0 -2
- package/dist/__tests__/resolve-sync.test.d.ts.map +0 -1
- package/dist/__tests__/resolve-sync.test.js +0 -67
- package/dist/__tests__/resolve-sync.test.js.map +0 -1
- package/dist/__tests__/routes.test.d.ts +0 -2
- package/dist/__tests__/routes.test.d.ts.map +0 -1
- package/dist/__tests__/routes.test.js +0 -154
- package/dist/__tests__/routes.test.js.map +0 -1
- package/src/__tests__/loader-hook.test.ts +0 -76
- package/src/__tests__/plugin.test.ts +0 -161
- package/src/__tests__/prerender.test.ts +0 -181
- package/src/__tests__/register-fallback.test.ts +0 -23
- package/src/__tests__/resolve-sync.test.ts +0 -91
- package/src/__tests__/routes.test.ts +0 -175
- package/tsconfig.json +0 -11
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
import { mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
-
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
|
|
5
|
-
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
6
|
-
|
|
7
|
-
import type { ResolvedConfig } from "vite";
|
|
8
|
-
|
|
9
|
-
import { prerender } from "../prerender";
|
|
10
|
-
import { ssgPlugin } from "../index";
|
|
11
|
-
|
|
12
|
-
vi.mock("../prerender", () => ({
|
|
13
|
-
prerender: vi.fn(),
|
|
14
|
-
// Pass-through stand-in — the real DOM-global install/restore mechanics
|
|
15
|
-
// are covered in prerender.test.ts; here we only need loadEntry() to call
|
|
16
|
-
// through to whatever function it wraps.
|
|
17
|
-
withDomGlobals: vi.fn((_html: string, _url: string, fn: () => unknown) => fn()),
|
|
18
|
-
}));
|
|
19
|
-
|
|
20
|
-
const ssrLoadModule = vi.fn();
|
|
21
|
-
const close = vi.fn();
|
|
22
|
-
const createServer = vi.fn(async (_options: unknown) => ({ ssrLoadModule, close }));
|
|
23
|
-
|
|
24
|
-
vi.mock("vite", () => ({
|
|
25
|
-
createServer: (options: unknown) => createServer(options),
|
|
26
|
-
}));
|
|
27
|
-
|
|
28
|
-
// registerLoaderHook()'s fallback path (module.register with a file URL)
|
|
29
|
-
// assumes a built dist/ layout — loader-hook.js physically next to index.js
|
|
30
|
-
// — which doesn't exist under vitest's on-the-fly source transform. The
|
|
31
|
-
// mechanism itself is covered separately by running the actual built dist/
|
|
32
|
-
// output through Node directly (see the plan's manual verification step);
|
|
33
|
-
// here we only need to confirm ssgPlugin() triggers registration.
|
|
34
|
-
const { registerHooks, register } = vi.hoisted(() => ({
|
|
35
|
-
registerHooks: vi.fn(),
|
|
36
|
-
register: vi.fn(),
|
|
37
|
-
}));
|
|
38
|
-
vi.mock("node:module", async (importOriginal) => {
|
|
39
|
-
const actual = await importOriginal<typeof import("node:module")>();
|
|
40
|
-
return { ...actual, registerHooks, register };
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
const RootComponent = class {} as never;
|
|
44
|
-
const routes = [{ path: "/", component: RootComponent }];
|
|
45
|
-
|
|
46
|
-
function fakeResolvedConfig(root: string, outDir = "dist"): ResolvedConfig {
|
|
47
|
-
return {
|
|
48
|
-
root,
|
|
49
|
-
build: { outDir },
|
|
50
|
-
configFile: `${root}/vite.config.ts`,
|
|
51
|
-
} as unknown as ResolvedConfig;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
describe("ssgPlugin()", () => {
|
|
55
|
-
let dir: string;
|
|
56
|
-
|
|
57
|
-
afterEach(async () => {
|
|
58
|
-
vi.clearAllMocks();
|
|
59
|
-
if (dir) await rm(dir, { recursive: true, force: true });
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("loads the root component and routes from a single SSR module load, prerenders, and writes files", async () => {
|
|
63
|
-
dir = await mkdtemp(join(tmpdir(), "praxisjs-ssg-"));
|
|
64
|
-
await mkdir(join(dir, "dist"), { recursive: true });
|
|
65
|
-
await writeFile(join(dir, "dist", "index.html"), "<html><body><div id=\"app\"></div></body></html>");
|
|
66
|
-
|
|
67
|
-
ssrLoadModule.mockResolvedValue({ default: RootComponent, routes });
|
|
68
|
-
vi.mocked(prerender).mockResolvedValue([
|
|
69
|
-
{ path: "/", file: "index.html", html: "<html>home</html>" },
|
|
70
|
-
{ path: "/about", file: "about/index.html", html: "<html>about</html>" },
|
|
71
|
-
]);
|
|
72
|
-
|
|
73
|
-
const plugin = ssgPlugin({ root: "./src/app.tsx" });
|
|
74
|
-
|
|
75
|
-
// ssgPlugin() must register the extensionless-import retry hook
|
|
76
|
-
// synchronously, before closeBundle ever dynamically imports
|
|
77
|
-
// ./prerender (which is what actually needs it) — see index.ts.
|
|
78
|
-
expect(registerHooks.mock.calls.length + register.mock.calls.length).toBeGreaterThan(0);
|
|
79
|
-
|
|
80
|
-
const configResolved = plugin.configResolved as (c: ResolvedConfig) => void | Promise<void>;
|
|
81
|
-
await configResolved(fakeResolvedConfig(dir));
|
|
82
|
-
const closeBundle = plugin.closeBundle as () => Promise<void>;
|
|
83
|
-
await closeBundle();
|
|
84
|
-
|
|
85
|
-
expect(createServer).toHaveBeenCalledWith(
|
|
86
|
-
expect.objectContaining({
|
|
87
|
-
root: dir,
|
|
88
|
-
configFile: `${dir}/vite.config.ts`,
|
|
89
|
-
server: { middlewareMode: true, watch: null },
|
|
90
|
-
appType: "custom",
|
|
91
|
-
ssr: { external: true },
|
|
92
|
-
optimizeDeps: { noDiscovery: true, include: [] },
|
|
93
|
-
}),
|
|
94
|
-
);
|
|
95
|
-
// A single ssrLoadModule call reads both the default export and the
|
|
96
|
-
// named `routes` export — vite.config.ts never imports app source itself.
|
|
97
|
-
expect(ssrLoadModule).toHaveBeenCalledOnce();
|
|
98
|
-
expect(ssrLoadModule).toHaveBeenCalledWith("./src/app.tsx");
|
|
99
|
-
expect(close).toHaveBeenCalledOnce();
|
|
100
|
-
|
|
101
|
-
expect(prerender).toHaveBeenCalledWith(
|
|
102
|
-
expect.objectContaining({
|
|
103
|
-
root: RootComponent,
|
|
104
|
-
routes,
|
|
105
|
-
template: "<html><body><div id=\"app\"></div></body></html>",
|
|
106
|
-
}),
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
expect(await readFile(join(dir, "dist", "index.html"), "utf-8")).toBe("<html>home</html>");
|
|
110
|
-
expect(await readFile(join(dir, "dist", "about", "index.html"), "utf-8")).toBe("<html>about</html>");
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("throws a clear error when the root module has no default export", async () => {
|
|
114
|
-
dir = await mkdtemp(join(tmpdir(), "praxisjs-ssg-"));
|
|
115
|
-
await mkdir(join(dir, "dist"), { recursive: true });
|
|
116
|
-
await writeFile(join(dir, "dist", "index.html"), "<html><body><div id=\"app\"></div></body></html>");
|
|
117
|
-
|
|
118
|
-
ssrLoadModule.mockResolvedValue({ routes });
|
|
119
|
-
|
|
120
|
-
const plugin = ssgPlugin({ root: "./src/app.tsx" });
|
|
121
|
-
const configResolved = plugin.configResolved as (c: ResolvedConfig) => void | Promise<void>;
|
|
122
|
-
await configResolved(fakeResolvedConfig(dir));
|
|
123
|
-
const closeBundle = plugin.closeBundle as () => Promise<void>;
|
|
124
|
-
|
|
125
|
-
await expect(closeBundle()).rejects.toThrow(/no default export/);
|
|
126
|
-
expect(close).toHaveBeenCalledOnce(); // server is closed even on failure
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it("throws a clear error when the root module has no named routes export", async () => {
|
|
130
|
-
dir = await mkdtemp(join(tmpdir(), "praxisjs-ssg-"));
|
|
131
|
-
await mkdir(join(dir, "dist"), { recursive: true });
|
|
132
|
-
await writeFile(join(dir, "dist", "index.html"), "<html><body><div id=\"app\"></div></body></html>");
|
|
133
|
-
|
|
134
|
-
ssrLoadModule.mockResolvedValue({ default: RootComponent });
|
|
135
|
-
|
|
136
|
-
const plugin = ssgPlugin({ root: "./src/app.tsx" });
|
|
137
|
-
const configResolved = plugin.configResolved as (c: ResolvedConfig) => void | Promise<void>;
|
|
138
|
-
await configResolved(fakeResolvedConfig(dir));
|
|
139
|
-
const closeBundle = plugin.closeBundle as () => Promise<void>;
|
|
140
|
-
|
|
141
|
-
await expect(closeBundle()).rejects.toThrow(/no named "routes" export/);
|
|
142
|
-
expect(close).toHaveBeenCalledOnce();
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it("passes hydrate: false through to prerender()", async () => {
|
|
146
|
-
dir = await mkdtemp(join(tmpdir(), "praxisjs-ssg-"));
|
|
147
|
-
await mkdir(join(dir, "dist"), { recursive: true });
|
|
148
|
-
await writeFile(join(dir, "dist", "index.html"), "<html><body><div id=\"app\"></div></body></html>");
|
|
149
|
-
|
|
150
|
-
ssrLoadModule.mockResolvedValue({ default: RootComponent, routes });
|
|
151
|
-
vi.mocked(prerender).mockResolvedValue([{ path: "/", file: "index.html", html: "<html></html>" }]);
|
|
152
|
-
|
|
153
|
-
const plugin = ssgPlugin({ root: "./src/app.tsx", hydrate: false });
|
|
154
|
-
const configResolved = plugin.configResolved as (c: ResolvedConfig) => void | Promise<void>;
|
|
155
|
-
await configResolved(fakeResolvedConfig(dir));
|
|
156
|
-
const closeBundle = plugin.closeBundle as () => Promise<void>;
|
|
157
|
-
await closeBundle();
|
|
158
|
-
|
|
159
|
-
expect(prerender).toHaveBeenCalledWith(expect.objectContaining({ hydrate: false }));
|
|
160
|
-
});
|
|
161
|
-
});
|
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { describe, it, expect } from "vitest";
|
|
3
|
-
|
|
4
|
-
import { StatefulComponent } from "@praxisjs/core";
|
|
5
|
-
import { resource } from "@praxisjs/core/internal";
|
|
6
|
-
import { Head } from "@praxisjs/head";
|
|
7
|
-
import type { RouteDefinition, RouteLocation } from "@praxisjs/router";
|
|
8
|
-
import { Location } from "@praxisjs/router";
|
|
9
|
-
import { getCurrentScope, mountComponent, mountElement, render } from "@praxisjs/runtime";
|
|
10
|
-
|
|
11
|
-
import { prerender, withDomGlobals } from "../prerender";
|
|
12
|
-
|
|
13
|
-
const TEMPLATE =
|
|
14
|
-
'<!doctype html><html><head><title>App</title></head><body><div id="app"></div></body></html>';
|
|
15
|
-
|
|
16
|
-
@Head((self) => ({ title: `Page: ${(self as Page).location().path}` }))
|
|
17
|
-
class Page extends StatefulComponent {
|
|
18
|
-
static __isComponent = true as const;
|
|
19
|
-
static __isStateless = false;
|
|
20
|
-
@Location() location!: RouteLocation;
|
|
21
|
-
|
|
22
|
-
render() {
|
|
23
|
-
return mountElement("main", {
|
|
24
|
-
id: "root",
|
|
25
|
-
children: [
|
|
26
|
-
mountElement("h1", { children: `Path: ${this.location().path}` }, getCurrentScope()),
|
|
27
|
-
mountElement("button", { id: "btn", onClick: () => {}, children: "Click" }, getCurrentScope()),
|
|
28
|
-
],
|
|
29
|
-
}, getCurrentScope());
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
class AsyncPage extends StatefulComponent {
|
|
34
|
-
static __isComponent = true as const;
|
|
35
|
-
static __isStateless = false;
|
|
36
|
-
data = resource(() => Promise.resolve("loaded content"));
|
|
37
|
-
|
|
38
|
-
render() {
|
|
39
|
-
return mountElement("p", { id: "data", children: () => this.data.data() ?? "pending" }, getCurrentScope());
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
describe("prerender()", () => {
|
|
44
|
-
it("renders each static route to its own page with the hydration marker", async () => {
|
|
45
|
-
const routes: RouteDefinition[] = [
|
|
46
|
-
{ path: "/", component: Page as never },
|
|
47
|
-
{ path: "/about", component: Page as never },
|
|
48
|
-
];
|
|
49
|
-
|
|
50
|
-
const pages = await prerender({ root: Page as never, routes, template: TEMPLATE });
|
|
51
|
-
|
|
52
|
-
expect(pages.map((p) => p.path).sort()).toEqual(["/", "/about"]);
|
|
53
|
-
const home = pages.find((p) => p.path === "/")!;
|
|
54
|
-
expect(home.file).toBe("index.html");
|
|
55
|
-
expect(home.html).toContain("Path: /");
|
|
56
|
-
expect(home.html).toContain('data-praxis-ssg="1"');
|
|
57
|
-
|
|
58
|
-
const about = pages.find((p) => p.path === "/about")!;
|
|
59
|
-
expect(about.file).toBe("about/index.html");
|
|
60
|
-
expect(about.html).toContain("Path: /about");
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("omits the hydration marker when hydrate: false", async () => {
|
|
64
|
-
const routes: RouteDefinition[] = [{ path: "/", component: Page as never }];
|
|
65
|
-
const pages = await prerender({ root: Page as never, routes, template: TEMPLATE, hydrate: false });
|
|
66
|
-
expect(pages[0].html).not.toContain("data-praxis-ssg");
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("expands a dynamic route via getStaticPaths", async () => {
|
|
70
|
-
const routes: RouteDefinition[] = [{ path: "/blog/:slug", component: Page as never }];
|
|
71
|
-
const pages = await prerender({
|
|
72
|
-
root: Page as never,
|
|
73
|
-
routes,
|
|
74
|
-
template: TEMPLATE,
|
|
75
|
-
getStaticPaths: async () => ["/blog/hello", "/blog/world"],
|
|
76
|
-
});
|
|
77
|
-
expect(pages.map((p) => p.file).sort()).toEqual(["blog/hello/index.html", "blog/world/index.html"]);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("waits for resource() data to settle before serializing", async () => {
|
|
81
|
-
const routes: RouteDefinition[] = [{ path: "/", component: AsyncPage as never }];
|
|
82
|
-
const pages = await prerender({ root: AsyncPage as never, routes, template: TEMPLATE });
|
|
83
|
-
expect(pages[0].html).toContain("loaded content");
|
|
84
|
-
expect(pages[0].html).not.toContain("pending");
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it("sets per-route <title> via @Head and does not leak it across routes", async () => {
|
|
88
|
-
const routes: RouteDefinition[] = [
|
|
89
|
-
{ path: "/", component: Page as never },
|
|
90
|
-
{ path: "/about", component: Page as never },
|
|
91
|
-
];
|
|
92
|
-
const pages = await prerender({ root: Page as never, routes, template: TEMPLATE });
|
|
93
|
-
const home = pages.find((p) => p.path === "/")!;
|
|
94
|
-
const about = pages.find((p) => p.path === "/about")!;
|
|
95
|
-
expect(home.html).toContain("<title>Page: /</title>");
|
|
96
|
-
expect(about.html).toContain("<title>Page: /about</title>");
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("does not leak globalThis.document/window back to the caller's environment", async () => {
|
|
100
|
-
const outer = document;
|
|
101
|
-
const routes: RouteDefinition[] = [{ path: "/", component: Page as never }];
|
|
102
|
-
await prerender({ root: Page as never, routes, template: TEMPLATE });
|
|
103
|
-
expect(globalThis.document).toBe(outer);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("throws a clear error when the container selector is not found in the template", async () => {
|
|
107
|
-
const routes: RouteDefinition[] = [{ path: "/", component: Page as never }];
|
|
108
|
-
await expect(
|
|
109
|
-
prerender({ root: Page as never, routes, template: "<html><body></body></html>" }),
|
|
110
|
-
).rejects.toThrow(/No element matching/);
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
describe("withDomGlobals", () => {
|
|
115
|
-
it("deletes a patched global on restore when it had no prior descriptor, and restores others that did", async () => {
|
|
116
|
-
const priorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "Comment");
|
|
117
|
-
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- simulating a Node environment where this global never existed
|
|
118
|
-
delete (globalThis as Record<string, unknown>).Comment;
|
|
119
|
-
|
|
120
|
-
try {
|
|
121
|
-
let sawCommentDuring: unknown;
|
|
122
|
-
await withDomGlobals("<!doctype html><html><body></body></html>", "http://localhost/", async () => {
|
|
123
|
-
sawCommentDuring = globalThis.Comment;
|
|
124
|
-
// document existed before this call (vitest's jsdom environment) — restoreGlobals
|
|
125
|
-
// must put its original descriptor back, not delete it.
|
|
126
|
-
expect(globalThis.document).toBeDefined();
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
expect(sawCommentDuring).toBeDefined();
|
|
130
|
-
expect(Object.getOwnPropertyDescriptor(globalThis, "Comment")).toBeUndefined();
|
|
131
|
-
expect(globalThis.document).toBeDefined();
|
|
132
|
-
} finally {
|
|
133
|
-
if (priorDescriptor) Object.defineProperty(globalThis, "Comment", priorDescriptor);
|
|
134
|
-
}
|
|
135
|
-
});
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
describe("prerender() -> render() round trip", () => {
|
|
139
|
-
it("produces HTML the client can hydrate without recreating the adopted nodes", async () => {
|
|
140
|
-
const routes: RouteDefinition[] = [{ path: "/", component: Page as never }];
|
|
141
|
-
const pages = await prerender({ root: Page as never, routes, template: TEMPLATE });
|
|
142
|
-
const html = pages[0].html;
|
|
143
|
-
|
|
144
|
-
const parsed = new DOMParser().parseFromString(html, "text/html");
|
|
145
|
-
const appHTML = parsed.querySelector("#app")?.innerHTML ?? "";
|
|
146
|
-
|
|
147
|
-
const container = document.createElement("div");
|
|
148
|
-
container.innerHTML = appHTML;
|
|
149
|
-
container.setAttribute("data-praxis-ssg", "1");
|
|
150
|
-
document.body.appendChild(container);
|
|
151
|
-
|
|
152
|
-
const h1Before = container.querySelector("h1");
|
|
153
|
-
const btnBefore = container.querySelector("#btn");
|
|
154
|
-
expect(h1Before?.textContent).toBe("Path: /");
|
|
155
|
-
|
|
156
|
-
let clicks = 0;
|
|
157
|
-
class ClientPage extends StatefulComponent {
|
|
158
|
-
static __isComponent = true as const;
|
|
159
|
-
static __isStateless = false;
|
|
160
|
-
render() {
|
|
161
|
-
return mountElement("main", {
|
|
162
|
-
id: "root",
|
|
163
|
-
children: [
|
|
164
|
-
mountElement("h1", { children: "Path: /" }, getCurrentScope()),
|
|
165
|
-
mountElement("button", { id: "btn", onClick: () => { clicks++; }, children: "Click" }, getCurrentScope()),
|
|
166
|
-
],
|
|
167
|
-
}, getCurrentScope());
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
render(() => mountComponent(ClientPage as never, {}, getCurrentScope()), container);
|
|
172
|
-
|
|
173
|
-
expect(container.querySelector("h1")).toBe(h1Before);
|
|
174
|
-
expect(container.querySelector("#btn")).toBe(btnBefore);
|
|
175
|
-
|
|
176
|
-
container.querySelector("#btn")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
177
|
-
expect(clicks).toBe(1);
|
|
178
|
-
|
|
179
|
-
document.body.removeChild(container);
|
|
180
|
-
});
|
|
181
|
-
});
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
// Simulates Node < 22.15, which has no module.registerHooks — registerLoaderHook()
|
|
4
|
-
// must fall back to the older, off-thread module.register() API instead.
|
|
5
|
-
const { register } = vi.hoisted(() => ({
|
|
6
|
-
register: vi.fn(),
|
|
7
|
-
}));
|
|
8
|
-
|
|
9
|
-
vi.mock("node:module", async (importOriginal) => {
|
|
10
|
-
const actual = await importOriginal<typeof import("node:module")>();
|
|
11
|
-
return { ...actual, registerHooks: undefined, register };
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
describe("registerLoaderHook() fallback (no module.registerHooks)", () => {
|
|
15
|
-
it("registers the standalone loader-hook module via module.register()", async () => {
|
|
16
|
-
const { registerLoaderHook } = await import("../index");
|
|
17
|
-
registerLoaderHook();
|
|
18
|
-
|
|
19
|
-
expect(register).toHaveBeenCalledOnce();
|
|
20
|
-
const [specifier] = register.mock.calls[0] as [URL];
|
|
21
|
-
expect(specifier.href).toContain("loader-hook.js");
|
|
22
|
-
});
|
|
23
|
-
});
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
import type { ResolveFnOutput, ResolveHookContext } from "node:module";
|
|
4
|
-
|
|
5
|
-
import { registerLoaderHook } from "../index";
|
|
6
|
-
|
|
7
|
-
const { registerHooks } = vi.hoisted(() => ({
|
|
8
|
-
registerHooks: vi.fn(),
|
|
9
|
-
}));
|
|
10
|
-
|
|
11
|
-
vi.mock("node:module", async (importOriginal) => {
|
|
12
|
-
const actual = await importOriginal<typeof import("node:module")>();
|
|
13
|
-
return { ...actual, registerHooks, register: vi.fn() };
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
const CONTEXT = {} as ResolveHookContext;
|
|
17
|
-
|
|
18
|
-
function notFound(): never {
|
|
19
|
-
const err = new Error("not found") as Error & { code?: string };
|
|
20
|
-
err.code = "ERR_MODULE_NOT_FOUND";
|
|
21
|
-
throw err;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function ok(url: string): ResolveFnOutput {
|
|
25
|
-
return { url, shortCircuit: true } as ResolveFnOutput;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
describe("resolveSync (registered via module.registerHooks())", () => {
|
|
29
|
-
registerLoaderHook();
|
|
30
|
-
const resolveSync = registerHooks.mock.calls[0][0].resolve as (
|
|
31
|
-
specifier: string,
|
|
32
|
-
context: ResolveHookContext,
|
|
33
|
-
nextResolve: (specifier: string, context?: Partial<ResolveHookContext>) => ResolveFnOutput,
|
|
34
|
-
) => ResolveFnOutput;
|
|
35
|
-
|
|
36
|
-
it("registers exactly once via module.registerHooks()", () => {
|
|
37
|
-
expect(registerHooks).toHaveBeenCalledOnce();
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("returns nextResolve's result directly when it succeeds", () => {
|
|
41
|
-
const nextResolve = vi.fn(() => ok("file:///real.js"));
|
|
42
|
-
expect(resolveSync("./real", CONTEXT, nextResolve)).toEqual(ok("file:///real.js"));
|
|
43
|
-
expect(nextResolve).toHaveBeenCalledOnce();
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("rethrows without retrying for a bare (non-relative) specifier", () => {
|
|
47
|
-
const nextResolve = vi.fn(() => notFound());
|
|
48
|
-
expect(() => resolveSync("some-package", CONTEXT, nextResolve)).toThrow("not found");
|
|
49
|
-
expect(nextResolve).toHaveBeenCalledOnce();
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("rethrows without retrying when the error has no code", () => {
|
|
53
|
-
const nextResolve = vi.fn(() => {
|
|
54
|
-
throw new Error("weird failure");
|
|
55
|
-
});
|
|
56
|
-
expect(() => resolveSync("./router", CONTEXT, nextResolve)).toThrow("weird failure");
|
|
57
|
-
expect(nextResolve).toHaveBeenCalledOnce();
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("rethrows without retrying for a non-retryable error code", () => {
|
|
61
|
-
const nextResolve = vi.fn(() => {
|
|
62
|
-
const err = new Error("permission denied") as Error & { code?: string };
|
|
63
|
-
err.code = "EACCES";
|
|
64
|
-
throw err;
|
|
65
|
-
});
|
|
66
|
-
expect(() => resolveSync("./router", CONTEXT, nextResolve)).toThrow("permission denied");
|
|
67
|
-
expect(nextResolve).toHaveBeenCalledOnce();
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("retries with a .js suffix and returns it when that succeeds", () => {
|
|
71
|
-
const nextResolve = vi.fn((specifier: string) =>
|
|
72
|
-
specifier === "./router.js" ? ok("file:///router.js") : notFound(),
|
|
73
|
-
);
|
|
74
|
-
expect(resolveSync("./router", CONTEXT, nextResolve)).toEqual(ok("file:///router.js"));
|
|
75
|
-
expect(nextResolve).toHaveBeenCalledTimes(2);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it("falls back to /index.js when the .js candidate also fails", () => {
|
|
79
|
-
const nextResolve = vi.fn((specifier: string) =>
|
|
80
|
-
specifier === "./router/index.js" ? ok("file:///router/index.js") : notFound(),
|
|
81
|
-
);
|
|
82
|
-
expect(resolveSync("./router", CONTEXT, nextResolve)).toEqual(ok("file:///router/index.js"));
|
|
83
|
-
expect(nextResolve).toHaveBeenCalledTimes(3);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("rethrows the original error when both retry candidates fail", () => {
|
|
87
|
-
const nextResolve = vi.fn(() => notFound());
|
|
88
|
-
expect(() => resolveSync("../router", CONTEXT, nextResolve)).toThrow("not found");
|
|
89
|
-
expect(nextResolve).toHaveBeenCalledTimes(3);
|
|
90
|
-
});
|
|
91
|
-
});
|
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
|
|
3
|
-
import type { RouteDefinition } from "@praxisjs/router";
|
|
4
|
-
|
|
5
|
-
import { flattenRoutes, isStaticPath, pathToOutputFile, resolvePaths } from "../routes";
|
|
6
|
-
|
|
7
|
-
const Noop = class {} as never;
|
|
8
|
-
|
|
9
|
-
describe("flattenRoutes", () => {
|
|
10
|
-
it("flattens a flat route list unchanged", () => {
|
|
11
|
-
const routes: RouteDefinition[] = [
|
|
12
|
-
{ path: "/", component: Noop },
|
|
13
|
-
{ path: "/about", component: Noop },
|
|
14
|
-
];
|
|
15
|
-
expect(flattenRoutes(routes).map((r) => r.fullPath)).toEqual(["/", "/about"]);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it("prefixes nested children with the parent path", () => {
|
|
19
|
-
const routes: RouteDefinition[] = [
|
|
20
|
-
{
|
|
21
|
-
path: "/blog",
|
|
22
|
-
component: Noop,
|
|
23
|
-
children: [
|
|
24
|
-
{ path: "/", component: Noop },
|
|
25
|
-
{ path: "/:slug", component: Noop },
|
|
26
|
-
],
|
|
27
|
-
},
|
|
28
|
-
];
|
|
29
|
-
expect(flattenRoutes(routes).map((r) => r.fullPath)).toEqual(["/blog", "/blog/", "/blog/:slug"]);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("treats a root parent's prefix as empty, matching RouterInstance.addRoute", () => {
|
|
33
|
-
const routes: RouteDefinition[] = [
|
|
34
|
-
{
|
|
35
|
-
path: "/",
|
|
36
|
-
component: Noop,
|
|
37
|
-
children: [{ path: "/dashboard", component: Noop }],
|
|
38
|
-
},
|
|
39
|
-
];
|
|
40
|
-
expect(flattenRoutes(routes).map((r) => r.fullPath)).toEqual(["/", "/dashboard"]);
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
describe("isStaticPath", () => {
|
|
45
|
-
it("treats plain paths as static", () => {
|
|
46
|
-
expect(isStaticPath("/about")).toBe(true);
|
|
47
|
-
expect(isStaticPath("/")).toBe(true);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("treats :param segments as dynamic", () => {
|
|
51
|
-
expect(isStaticPath("/blog/:slug")).toBe(false);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("treats optional :param? segments as dynamic", () => {
|
|
55
|
-
expect(isStaticPath("/users/:id?")).toBe(false);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("treats ** catch-alls as dynamic", () => {
|
|
59
|
-
expect(isStaticPath("/docs/**")).toBe(false);
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe("resolvePaths", () => {
|
|
64
|
-
it("returns static paths as-is", async () => {
|
|
65
|
-
const routes: RouteDefinition[] = [
|
|
66
|
-
{ path: "/", component: Noop },
|
|
67
|
-
{ path: "/about", component: Noop },
|
|
68
|
-
];
|
|
69
|
-
expect(await resolvePaths({ routes })).toEqual(["/", "/about"]);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("expands a dynamic route via getStaticPaths", async () => {
|
|
73
|
-
const routes: RouteDefinition[] = [{ path: "/blog/:slug", component: Noop }];
|
|
74
|
-
const paths = await resolvePaths({
|
|
75
|
-
routes,
|
|
76
|
-
getStaticPaths: async () => ["/blog/hello", "/blog/world"],
|
|
77
|
-
});
|
|
78
|
-
expect(paths).toEqual(["/blog/hello", "/blog/world"]);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("skips dynamic routes with no getStaticPaths", async () => {
|
|
82
|
-
const routes: RouteDefinition[] = [
|
|
83
|
-
{ path: "/", component: Noop },
|
|
84
|
-
{ path: "/blog/:slug", component: Noop },
|
|
85
|
-
];
|
|
86
|
-
expect(await resolvePaths({ routes })).toEqual(["/"]);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("passes the route and its full path pattern to getStaticPaths", async () => {
|
|
90
|
-
const target: RouteDefinition = { path: "/blog/:slug", component: Noop };
|
|
91
|
-
const routes: RouteDefinition[] = [target];
|
|
92
|
-
let seen: [RouteDefinition, string] | undefined;
|
|
93
|
-
await resolvePaths({
|
|
94
|
-
routes,
|
|
95
|
-
getStaticPaths: async (route, fullPath) => {
|
|
96
|
-
seen = [route, fullPath];
|
|
97
|
-
return [];
|
|
98
|
-
},
|
|
99
|
-
});
|
|
100
|
-
expect(seen).toEqual([target, "/blog/:slug"]);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it("de-duplicates paths across routes", async () => {
|
|
104
|
-
const routes: RouteDefinition[] = [
|
|
105
|
-
{ path: "/blog/:slug", component: Noop },
|
|
106
|
-
{ path: "/other/:slug", component: Noop },
|
|
107
|
-
];
|
|
108
|
-
const paths = await resolvePaths({
|
|
109
|
-
routes,
|
|
110
|
-
getStaticPaths: async () => ["/shared"],
|
|
111
|
-
});
|
|
112
|
-
expect(paths).toEqual(["/shared"]);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("expands a dynamic route via its own colocated getStaticPaths, with no global fallback needed", async () => {
|
|
116
|
-
const routes: RouteDefinition[] = [
|
|
117
|
-
{ path: "/blog/:slug", component: Noop, getStaticPaths: async () => ["/blog/hello", "/blog/world"] } as RouteDefinition,
|
|
118
|
-
];
|
|
119
|
-
expect(await resolvePaths({ routes })).toEqual(["/blog/hello", "/blog/world"]);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("passes the route's own full path to its colocated getStaticPaths", async () => {
|
|
123
|
-
let seen: string | undefined;
|
|
124
|
-
const routes: RouteDefinition[] = [
|
|
125
|
-
{
|
|
126
|
-
path: "/blog/:slug",
|
|
127
|
-
component: Noop,
|
|
128
|
-
getStaticPaths: async (fullPath: string) => {
|
|
129
|
-
seen = fullPath;
|
|
130
|
-
return [];
|
|
131
|
-
},
|
|
132
|
-
} as RouteDefinition,
|
|
133
|
-
];
|
|
134
|
-
await resolvePaths({ routes });
|
|
135
|
-
expect(seen).toBe("/blog/:slug");
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("prefers a route's own getStaticPaths over the global fallback", async () => {
|
|
139
|
-
const routes: RouteDefinition[] = [
|
|
140
|
-
{ path: "/blog/:slug", component: Noop, getStaticPaths: async () => ["/blog/own"] } as RouteDefinition,
|
|
141
|
-
];
|
|
142
|
-
const paths = await resolvePaths({
|
|
143
|
-
routes,
|
|
144
|
-
getStaticPaths: async () => ["/blog/global"],
|
|
145
|
-
});
|
|
146
|
-
expect(paths).toEqual(["/blog/own"]);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it("falls back to the global getStaticPaths for routes with no colocated one, alongside routes that have their own", async () => {
|
|
150
|
-
const routes: RouteDefinition[] = [
|
|
151
|
-
{ path: "/blog/:slug", component: Noop, getStaticPaths: async () => ["/blog/own"] } as RouteDefinition,
|
|
152
|
-
{ path: "/other/:slug", component: Noop },
|
|
153
|
-
];
|
|
154
|
-
const paths = await resolvePaths({
|
|
155
|
-
routes,
|
|
156
|
-
getStaticPaths: async (_route, fullPath) => (fullPath === "/other/:slug" ? ["/other/global"] : []),
|
|
157
|
-
});
|
|
158
|
-
expect(paths).toEqual(["/blog/own", "/other/global"]);
|
|
159
|
-
});
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
describe("pathToOutputFile", () => {
|
|
163
|
-
it("maps the root path to index.html", () => {
|
|
164
|
-
expect(pathToOutputFile("/")).toBe("index.html");
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it("maps a nested path to <path>/index.html", () => {
|
|
168
|
-
expect(pathToOutputFile("/about")).toBe("about/index.html");
|
|
169
|
-
expect(pathToOutputFile("/blog/hello-world")).toBe("blog/hello-world/index.html");
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it("normalizes surrounding slashes", () => {
|
|
173
|
-
expect(pathToOutputFile("/about/")).toBe("about/index.html");
|
|
174
|
-
});
|
|
175
|
-
});
|