@praxisjs/ssg 0.1.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/dist/__tests__/loader-hook.test.d.ts +2 -0
  4. package/dist/__tests__/loader-hook.test.d.ts.map +1 -0
  5. package/dist/__tests__/loader-hook.test.js +66 -0
  6. package/dist/__tests__/loader-hook.test.js.map +1 -0
  7. package/dist/__tests__/plugin.test.d.ts +2 -0
  8. package/dist/__tests__/plugin.test.d.ts.map +1 -0
  9. package/dist/__tests__/plugin.test.js +128 -0
  10. package/dist/__tests__/plugin.test.js.map +1 -0
  11. package/dist/__tests__/prerender.test.d.ts +2 -0
  12. package/dist/__tests__/prerender.test.d.ts.map +1 -0
  13. package/dist/__tests__/prerender.test.js +211 -0
  14. package/dist/__tests__/prerender.test.js.map +1 -0
  15. package/dist/__tests__/register-fallback.test.d.ts +2 -0
  16. package/dist/__tests__/register-fallback.test.d.ts.map +1 -0
  17. package/dist/__tests__/register-fallback.test.js +20 -0
  18. package/dist/__tests__/register-fallback.test.js.map +1 -0
  19. package/dist/__tests__/resolve-sync.test.d.ts +2 -0
  20. package/dist/__tests__/resolve-sync.test.d.ts.map +1 -0
  21. package/dist/__tests__/resolve-sync.test.js +67 -0
  22. package/dist/__tests__/resolve-sync.test.js.map +1 -0
  23. package/dist/__tests__/routes.test.d.ts +2 -0
  24. package/dist/__tests__/routes.test.d.ts.map +1 -0
  25. package/dist/__tests__/routes.test.js +154 -0
  26. package/dist/__tests__/routes.test.js.map +1 -0
  27. package/dist/index.d.ts +59 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +156 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/loader-hook.d.ts +5 -0
  32. package/dist/loader-hook.d.ts.map +1 -0
  33. package/dist/loader-hook.js +34 -0
  34. package/dist/loader-hook.js.map +1 -0
  35. package/dist/prerender.d.ts +38 -0
  36. package/dist/prerender.d.ts.map +1 -0
  37. package/dist/prerender.js +116 -0
  38. package/dist/prerender.js.map +1 -0
  39. package/dist/routes.d.ts +32 -0
  40. package/dist/routes.d.ts.map +1 -0
  41. package/dist/routes.js +59 -0
  42. package/dist/routes.js.map +1 -0
  43. package/package.json +38 -0
  44. package/src/__tests__/loader-hook.test.ts +76 -0
  45. package/src/__tests__/plugin.test.ts +161 -0
  46. package/src/__tests__/prerender.test.ts +181 -0
  47. package/src/__tests__/register-fallback.test.ts +23 -0
  48. package/src/__tests__/resolve-sync.test.ts +91 -0
  49. package/src/__tests__/routes.test.ts +175 -0
  50. package/src/index.ts +233 -0
  51. package/src/loader-hook.ts +43 -0
  52. package/src/prerender.ts +168 -0
  53. package/src/routes.ts +83 -0
  54. package/tsconfig.json +11 -0
@@ -0,0 +1,32 @@
1
+ import type { RouteComponent, RouteDefinition } from "@praxisjs/router";
2
+ /** Expands the one dynamic route it's declared on into concrete paths — colocated alternative to the global `getStaticPaths` export on `root`. Takes precedence over it for that route. Receives the route's own full path, so helpers like `@praxisjs/content`'s `collectionStaticPaths()` don't need it repeated. */
3
+ export type RouteStaticPaths = (fullPath: string) => Promise<string[]>;
4
+ /** Same shape `@Router([...])` itself accepts — a plain RouteDefinition (optionally with its own `getStaticPaths`) or a bare @Route-decorated class. */
5
+ export type RouteEntry = (RouteDefinition & {
6
+ getStaticPaths?: RouteStaticPaths;
7
+ }) | RouteComponent;
8
+ /** Normalizes top-level route entries the same way the `@Router` decorator does, before passing them to `createRouter`. */
9
+ export declare function normalizeRoutes(routes: RouteEntry[]): RouteDefinition[];
10
+ export interface FlattenedRoute {
11
+ route: RouteDefinition;
12
+ fullPath: string;
13
+ }
14
+ /** Mirrors RouterInstance's own path-prefixing in @praxisjs/router (router.ts's addRoute). */
15
+ export declare function flattenRoutes(routes: RouteDefinition[], prefix?: string): FlattenedRoute[];
16
+ export declare function isStaticPath(path: string): boolean;
17
+ export type GetStaticPaths = (route: RouteDefinition, fullPath: string) => Promise<string[]>;
18
+ export interface ResolvePathsConfig {
19
+ routes: RouteDefinition[];
20
+ getStaticPaths?: GetStaticPaths;
21
+ }
22
+ /**
23
+ * Resolves every concrete path to prerender: static routes are used as-is;
24
+ * dynamic routes (`:slug`, `**`) are expanded via the route's own
25
+ * `getStaticPaths` if it declared one, falling back to the global
26
+ * `getStaticPaths` — otherwise they're skipped (documented: nothing to
27
+ * enumerate without either).
28
+ */
29
+ export declare function resolvePaths(config: ResolvePathsConfig): Promise<string[]>;
30
+ /** `/` -> `index.html`; `/about` -> `about/index.html`; trailing-slash convention. */
31
+ export declare function pathToOutputFile(path: string): string;
32
+ //# sourceMappingURL=routes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAGxE,uTAAuT;AACvT,MAAM,MAAM,gBAAgB,GAAG,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;AAEvE,wJAAwJ;AACxJ,MAAM,MAAM,UAAU,GAAG,CAAC,eAAe,GAAG;IAAE,cAAc,CAAC,EAAE,gBAAgB,CAAA;CAAE,CAAC,GAAG,cAAc,CAAC;AAEpG,2HAA2H;AAC3H,wBAAgB,eAAe,CAAC,MAAM,EAAE,UAAU,EAAE,GAAG,eAAe,EAAE,CAEvE;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,eAAe,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,8FAA8F;AAC9F,wBAAgB,aAAa,CAAC,MAAM,EAAE,eAAe,EAAE,EAAE,MAAM,SAAK,GAAG,cAAc,EAAE,CAUtF;AAMD,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAElD;AAED,MAAM,MAAM,cAAc,GAAG,CAAC,KAAK,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;AAE7F,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,eAAe,EAAE,CAAC;IAC1B,cAAc,CAAC,EAAE,cAAc,CAAC;CACjC;AAED;;;;;;GAMG;AACH,wBAAsB,YAAY,CAAC,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAsBhF;AAED,sFAAsF;AACtF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAGrD"}
package/dist/routes.js ADDED
@@ -0,0 +1,59 @@
1
+ import { normalizeRoute } from "@praxisjs/router/internal";
2
+ /** Normalizes top-level route entries the same way the `@Router` decorator does, before passing them to `createRouter`. */
3
+ export function normalizeRoutes(routes) {
4
+ return routes.map(normalizeRoute);
5
+ }
6
+ /** Mirrors RouterInstance's own path-prefixing in @praxisjs/router (router.ts's addRoute). */
7
+ export function flattenRoutes(routes, prefix = "") {
8
+ const out = [];
9
+ for (const route of routes) {
10
+ const fullPath = prefix + route.path;
11
+ out.push({ route, fullPath });
12
+ if (route.children) {
13
+ out.push(...flattenRoutes(route.children, fullPath === "/" ? "" : fullPath));
14
+ }
15
+ }
16
+ return out;
17
+ }
18
+ // Matches the same segment shapes @praxisjs/router's compilePath() does (utils.ts):
19
+ // ":name", ":name?" (optional param), "**" (catch-all).
20
+ const DYNAMIC_SEGMENT = /:[^/?]+\??|\*\*/;
21
+ export function isStaticPath(path) {
22
+ return !DYNAMIC_SEGMENT.test(path);
23
+ }
24
+ /**
25
+ * Resolves every concrete path to prerender: static routes are used as-is;
26
+ * dynamic routes (`:slug`, `**`) are expanded via the route's own
27
+ * `getStaticPaths` if it declared one, falling back to the global
28
+ * `getStaticPaths` — otherwise they're skipped (documented: nothing to
29
+ * enumerate without either).
30
+ */
31
+ export async function resolvePaths(config) {
32
+ const flattened = flattenRoutes(config.routes);
33
+ const paths = new Set();
34
+ for (const { route, fullPath } of flattened) {
35
+ if (isStaticPath(fullPath)) {
36
+ paths.add(fullPath);
37
+ continue;
38
+ }
39
+ // normalizeRoute() passes plain route-literal objects through unchanged,
40
+ // so this survives even though RouteDefinition itself doesn't declare it.
41
+ const ownStaticPaths = route.getStaticPaths;
42
+ const expanded = ownStaticPaths
43
+ ? await ownStaticPaths(fullPath)
44
+ : config.getStaticPaths
45
+ ? await config.getStaticPaths(route, fullPath)
46
+ : undefined;
47
+ if (!expanded)
48
+ continue;
49
+ for (const p of expanded)
50
+ paths.add(p);
51
+ }
52
+ return Array.from(paths);
53
+ }
54
+ /** `/` -> `index.html`; `/about` -> `about/index.html`; trailing-slash convention. */
55
+ export function pathToOutputFile(path) {
56
+ const trimmed = path.replace(/^\/+|\/+$/g, "");
57
+ return trimmed === "" ? "index.html" : `${trimmed}/index.html`;
58
+ }
59
+ //# sourceMappingURL=routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAQ3D,2HAA2H;AAC3H,MAAM,UAAU,eAAe,CAAC,MAAoB;IAClD,OAAO,MAAM,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;AACpC,CAAC;AAOD,8FAA8F;AAC9F,MAAM,UAAU,aAAa,CAAC,MAAyB,EAAE,MAAM,GAAG,EAAE;IAClE,MAAM,GAAG,GAAqB,EAAE,CAAC;IACjC,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,MAAM,QAAQ,GAAG,MAAM,GAAG,KAAK,CAAC,IAAI,CAAC;QACrC,GAAG,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC,CAAC;QAC9B,IAAI,KAAK,CAAC,QAAQ,EAAE,CAAC;YACnB,GAAG,CAAC,IAAI,CAAC,GAAG,aAAa,CAAC,KAAK,CAAC,QAAQ,EAAE,QAAQ,KAAK,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC;QAC/E,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED,oFAAoF;AACpF,wDAAwD;AACxD,MAAM,eAAe,GAAG,iBAAiB,CAAC;AAE1C,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,OAAO,CAAC,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACrC,CAAC;AASD;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,MAA0B;IAC3D,MAAM,SAAS,GAAG,aAAa,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAC/C,MAAM,KAAK,GAAG,IAAI,GAAG,EAAU,CAAC;IAEhC,KAAK,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,SAAS,EAAE,CAAC;QAC5C,IAAI,YAAY,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC3B,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;YACpB,SAAS;QACX,CAAC;QACD,yEAAyE;QACzE,0EAA0E;QAC1E,MAAM,cAAc,GAAI,KAA+C,CAAC,cAAc,CAAC;QACvF,MAAM,QAAQ,GAAG,cAAc;YAC7B,CAAC,CAAC,MAAM,cAAc,CAAC,QAAQ,CAAC;YAChC,CAAC,CAAC,MAAM,CAAC,cAAc;gBACrB,CAAC,CAAC,MAAM,MAAM,CAAC,cAAc,CAAC,KAAK,EAAE,QAAQ,CAAC;gBAC9C,CAAC,CAAC,SAAS,CAAC;QAChB,IAAI,CAAC,QAAQ;YAAE,SAAS;QACxB,KAAK,MAAM,CAAC,IAAI,QAAQ;YAAE,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IACzC,CAAC;IAED,OAAO,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AAC3B,CAAC;AAED,sFAAsF;AACtF,MAAM,UAAU,gBAAgB,CAAC,IAAY;IAC3C,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC;IAC/C,OAAO,OAAO,KAAK,EAAE,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,GAAG,OAAO,aAAa,CAAC;AACjE,CAAC"}
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@praxisjs/ssg",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.js",
10
+ "types": "./dist/index.d.ts"
11
+ },
12
+ "./prerender": {
13
+ "import": "./dist/prerender.js",
14
+ "types": "./dist/prerender.d.ts"
15
+ }
16
+ },
17
+ "dependencies": {
18
+ "jsdom": "^29.0.1",
19
+ "@praxisjs/core": "2.1.0",
20
+ "@praxisjs/head": "0.3.0",
21
+ "@praxisjs/router": "2.2.0",
22
+ "@praxisjs/runtime": "0.6.0",
23
+ "@praxisjs/shared": "0.3.1"
24
+ },
25
+ "peerDependencies": {
26
+ "vite": ">=8.0.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/jsdom": "^28.0.3",
30
+ "@types/node": "^25.9.1",
31
+ "typescript": "^6.0.3",
32
+ "vite": "^8.1.4"
33
+ },
34
+ "scripts": {
35
+ "build": "tsc",
36
+ "dev": "tsc --watch"
37
+ }
38
+ }
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ import type { ResolveFnOutput, ResolveHookContext } from "node:module";
4
+
5
+ import { resolve } from "../loader-hook";
6
+
7
+ const CONTEXT = {} as ResolveHookContext;
8
+
9
+ function notFound(): never {
10
+ const err = new Error("not found") as Error & { code?: string };
11
+ err.code = "ERR_MODULE_NOT_FOUND";
12
+ throw err;
13
+ }
14
+
15
+ function ok(url: string): ResolveFnOutput {
16
+ return { url, shortCircuit: true } as ResolveFnOutput;
17
+ }
18
+
19
+ describe("loader-hook resolve()", () => {
20
+ it("returns nextResolve's result directly when it succeeds", async () => {
21
+ const nextResolve = vi.fn(async () => ok("file:///real.js"));
22
+ const result = await resolve("./real", CONTEXT, nextResolve);
23
+ expect(result).toEqual(ok("file:///real.js"));
24
+ expect(nextResolve).toHaveBeenCalledOnce();
25
+ });
26
+
27
+ it("rethrows without retrying for a bare (non-relative) specifier", async () => {
28
+ const nextResolve = vi.fn(async () => notFound());
29
+ await expect(resolve("some-package", CONTEXT, nextResolve)).rejects.toThrow("not found");
30
+ expect(nextResolve).toHaveBeenCalledOnce();
31
+ });
32
+
33
+ it("rethrows without retrying when the error has no code", async () => {
34
+ const nextResolve = vi.fn(async () => {
35
+ throw new Error("weird failure");
36
+ });
37
+ await expect(resolve("./router", CONTEXT, nextResolve)).rejects.toThrow("weird failure");
38
+ expect(nextResolve).toHaveBeenCalledOnce();
39
+ });
40
+
41
+ it("rethrows without retrying for a non-retryable error code", async () => {
42
+ const nextResolve = vi.fn(async () => {
43
+ const err = new Error("permission denied") as Error & { code?: string };
44
+ err.code = "EACCES";
45
+ throw err;
46
+ });
47
+ await expect(resolve("./router", CONTEXT, nextResolve)).rejects.toThrow("permission denied");
48
+ expect(nextResolve).toHaveBeenCalledOnce();
49
+ });
50
+
51
+ it("retries with a .js suffix and returns it when that succeeds", async () => {
52
+ const nextResolve = vi.fn(async (specifier: string) => {
53
+ if (specifier === "./router.js") return ok("file:///router.js");
54
+ return notFound();
55
+ });
56
+ const result = await resolve("./router", CONTEXT, nextResolve);
57
+ expect(result).toEqual(ok("file:///router.js"));
58
+ expect(nextResolve).toHaveBeenCalledTimes(2);
59
+ });
60
+
61
+ it("falls back to /index.js when the .js candidate also fails", async () => {
62
+ const nextResolve = vi.fn(async (specifier: string) => {
63
+ if (specifier === "./router/index.js") return ok("file:///router/index.js");
64
+ return notFound();
65
+ });
66
+ const result = await resolve("./router", CONTEXT, nextResolve);
67
+ expect(result).toEqual(ok("file:///router/index.js"));
68
+ expect(nextResolve).toHaveBeenCalledTimes(3);
69
+ });
70
+
71
+ it("rethrows the original error when both retry candidates fail", async () => {
72
+ const nextResolve = vi.fn(async () => notFound());
73
+ await expect(resolve("../router", CONTEXT, nextResolve)).rejects.toThrow("not found");
74
+ expect(nextResolve).toHaveBeenCalledTimes(3);
75
+ });
76
+ });
@@ -0,0 +1,161 @@
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
+ });
@@ -0,0 +1,181 @@
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
+ });
@@ -0,0 +1,23 @@
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
+ });