@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,168 @@
1
+ import { JSDOM } from "jsdom";
2
+
3
+ import {
4
+ flushPendingResources,
5
+ isServerRenderPass,
6
+ setServerRenderPass,
7
+ } from "@praxisjs/core/internal";
8
+ import { resetHeadState } from "@praxisjs/head/internal";
9
+ import type { RouteDefinition, RouterOptions } from "@praxisjs/router";
10
+ import { createRouter } from "@praxisjs/router/internal";
11
+ import { getCurrentScope, mountComponent, render } from "@praxisjs/runtime";
12
+ import type { ComponentConstructor } from "@praxisjs/shared/internal";
13
+
14
+ import { normalizeRoutes, pathToOutputFile, resolvePaths, type GetStaticPaths, type RouteEntry } from "./routes";
15
+
16
+ // Re-exported here (this whole file, plus these, only resolves once the
17
+ // loader hook is registered — see register.ts) rather than from the main
18
+ // package entry, so importing `ssgPlugin` alone never needs router/core/head
19
+ // resolved statically. See index.ts.
20
+ export { flattenRoutes, isStaticPath, normalizeRoutes, pathToOutputFile, resolvePaths, type FlattenedRoute, type ResolvePathsConfig } from "./routes";
21
+
22
+ const SSG_MARKER = "data-praxis-ssg";
23
+
24
+ export interface PrerenderConfig {
25
+ /** Root component class — same one passed to `@Router([...])`/mounted by `main.tsx`. */
26
+ root: ComponentConstructor;
27
+ /** Same route table passed to `@Router([...])` — a mix of RouteDefinitions and bare @Route-decorated classes is fine, same as the decorator itself accepts. */
28
+ routes: RouteEntry[];
29
+ routerOptions?: RouterOptions;
30
+ /** Global fallback for expanding a dynamic route (`:slug`, `**`) into concrete paths — a route with its own `getStaticPaths` uses that instead. Dynamic routes with neither are skipped. */
31
+ getStaticPaths?: GetStaticPaths;
32
+ /** The built `index.html` content — reused as the template for every page. */
33
+ template: string;
34
+ /** CSS selector for the mount container inside `template`. Default: `#app`. */
35
+ containerSelector?: string;
36
+ /** Whether to mark output HTML for client-side hydration. Default: `true`. When `false`, the client falls back to a full remount instead of reconciling. */
37
+ hydrate?: boolean;
38
+ }
39
+
40
+ export interface PrerenderedPage {
41
+ path: string;
42
+ /** Output file path relative to the SSG out dir, e.g. `about/index.html`. */
43
+ file: string;
44
+ html: string;
45
+ }
46
+
47
+ export async function prerender(config: PrerenderConfig): Promise<PrerenderedPage[]> {
48
+ const { getStaticPaths } = config;
49
+ const routes = normalizeRoutes(config.routes);
50
+ const paths = await resolvePaths({ routes, getStaticPaths });
51
+
52
+ const wasServerPass = isServerRenderPass();
53
+ setServerRenderPass(true);
54
+ try {
55
+ const pages: PrerenderedPage[] = [];
56
+ for (const path of paths) {
57
+ pages.push(await renderPage(path, { ...config, routes }));
58
+ }
59
+ return pages;
60
+ } finally {
61
+ setServerRenderPass(wasServerPass);
62
+ }
63
+ }
64
+
65
+ const PATCHED_GLOBALS = ["window", "document", "navigator", "history", "location", "Node", "Comment", "Text", "Element", "HTMLElement"] as const;
66
+
67
+ type GlobalSnapshot = Partial<Record<(typeof PATCHED_GLOBALS)[number], PropertyDescriptor>>;
68
+
69
+ // Modern Node already defines some of these itself (globalThis.navigator is a
70
+ // getter-only built-in since Node 21) — plain assignment throws on those, so
71
+ // every property is installed *and* restored via defineProperty, capturing
72
+ // the original descriptor (not just its value) to put back exactly what was
73
+ // there, getter and all, rather than flattening it into a plain value.
74
+ function installDomGlobals(dom: JSDOM): GlobalSnapshot {
75
+ const g = globalThis as unknown as Record<string, unknown>;
76
+ const snapshot: GlobalSnapshot = {};
77
+ for (const key of PATCHED_GLOBALS) {
78
+ const descriptor = Object.getOwnPropertyDescriptor(globalThis, key);
79
+ if (descriptor) snapshot[key] = descriptor;
80
+ }
81
+
82
+ const { window } = dom;
83
+ const values: Record<(typeof PATCHED_GLOBALS)[number], unknown> = {
84
+ window,
85
+ document: window.document,
86
+ navigator: window.navigator,
87
+ history: window.history,
88
+ location: window.location,
89
+ Node: window.Node,
90
+ Comment: window.Comment,
91
+ Text: window.Text,
92
+ Element: window.Element,
93
+ HTMLElement: window.HTMLElement,
94
+ };
95
+ for (const key of PATCHED_GLOBALS) {
96
+ Object.defineProperty(g, key, { value: values[key], writable: true, configurable: true, enumerable: true });
97
+ }
98
+
99
+ return snapshot;
100
+ }
101
+
102
+ function restoreGlobals(snapshot: GlobalSnapshot): void {
103
+ const g = globalThis as unknown as Record<string, unknown>;
104
+ for (const key of PATCHED_GLOBALS) {
105
+ const descriptor = snapshot[key];
106
+ if (descriptor) {
107
+ Object.defineProperty(g, key, descriptor);
108
+ } else {
109
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- removing a global that genuinely didn't exist before installDomGlobals() added it
110
+ delete g[key];
111
+ }
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Runs `fn` with a fresh JSDOM's `window`/`document`/etc installed as globals
117
+ * for its duration, then restores whatever was there before. Exported so
118
+ * `index.ts`'s `loadEntry()` can use it too: `ssrLoadModule()`-ing the app's
119
+ * root module runs `@Router([...])`'s class decorator, which calls
120
+ * `createRouter()` immediately (at module-evaluation time) — that needs a
121
+ * `window` in place just as much as the real per-route render below does,
122
+ * even though it's not rendering anything itself yet.
123
+ */
124
+ export async function withDomGlobals<T>(html: string, url: string, fn: (dom: JSDOM) => Promise<T>): Promise<T> {
125
+ const dom = new JSDOM(html, { url });
126
+ const snapshot = installDomGlobals(dom);
127
+ try {
128
+ return await fn(dom);
129
+ } finally {
130
+ restoreGlobals(snapshot);
131
+ }
132
+ }
133
+
134
+ async function renderPage(
135
+ path: string,
136
+ config: Omit<PrerenderConfig, "routes"> & { routes: RouteDefinition[] },
137
+ ): Promise<PrerenderedPage> {
138
+ const { root, routes, routerOptions, template, hydrate = true } = config;
139
+ const containerSelector = config.containerSelector ?? "#app";
140
+
141
+ return withDomGlobals(template, `http://localhost${path}`, async (dom) => {
142
+ resetHeadState();
143
+
144
+ const container = dom.window.document.querySelector(containerSelector);
145
+ if (!container) {
146
+ throw new Error(`[@praxisjs/ssg] No element matching "${containerSelector}" found in the HTML template.`);
147
+ }
148
+
149
+ createRouter(routes, routerOptions);
150
+
151
+ const dispose = render(
152
+ () => mountComponent(root, {}, getCurrentScope()),
153
+ container as HTMLElement,
154
+ );
155
+
156
+ await flushPendingResources();
157
+ await Promise.resolve();
158
+
159
+ // Marked *after* rendering — the server always mounts fresh (create-mode);
160
+ // the marker only tells the client to hydrate instead of remounting.
161
+ if (hydrate) container.setAttribute(SSG_MARKER, "1");
162
+
163
+ const html = dom.serialize();
164
+ dispose();
165
+
166
+ return { path, file: pathToOutputFile(path), html };
167
+ });
168
+ }
package/src/routes.ts ADDED
@@ -0,0 +1,83 @@
1
+ import type { RouteComponent, RouteDefinition } from "@praxisjs/router";
2
+ import { normalizeRoute } from "@praxisjs/router/internal";
3
+
4
+ /** 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. */
5
+ export type RouteStaticPaths = (fullPath: string) => Promise<string[]>;
6
+
7
+ /** Same shape `@Router([...])` itself accepts — a plain RouteDefinition (optionally with its own `getStaticPaths`) or a bare @Route-decorated class. */
8
+ export type RouteEntry = (RouteDefinition & { getStaticPaths?: RouteStaticPaths }) | RouteComponent;
9
+
10
+ /** Normalizes top-level route entries the same way the `@Router` decorator does, before passing them to `createRouter`. */
11
+ export function normalizeRoutes(routes: RouteEntry[]): RouteDefinition[] {
12
+ return routes.map(normalizeRoute);
13
+ }
14
+
15
+ export interface FlattenedRoute {
16
+ route: RouteDefinition;
17
+ fullPath: string;
18
+ }
19
+
20
+ /** Mirrors RouterInstance's own path-prefixing in @praxisjs/router (router.ts's addRoute). */
21
+ export function flattenRoutes(routes: RouteDefinition[], prefix = ""): FlattenedRoute[] {
22
+ const out: FlattenedRoute[] = [];
23
+ for (const route of routes) {
24
+ const fullPath = prefix + route.path;
25
+ out.push({ route, fullPath });
26
+ if (route.children) {
27
+ out.push(...flattenRoutes(route.children, fullPath === "/" ? "" : fullPath));
28
+ }
29
+ }
30
+ return out;
31
+ }
32
+
33
+ // Matches the same segment shapes @praxisjs/router's compilePath() does (utils.ts):
34
+ // ":name", ":name?" (optional param), "**" (catch-all).
35
+ const DYNAMIC_SEGMENT = /:[^/?]+\??|\*\*/;
36
+
37
+ export function isStaticPath(path: string): boolean {
38
+ return !DYNAMIC_SEGMENT.test(path);
39
+ }
40
+
41
+ export type GetStaticPaths = (route: RouteDefinition, fullPath: string) => Promise<string[]>;
42
+
43
+ export interface ResolvePathsConfig {
44
+ routes: RouteDefinition[];
45
+ getStaticPaths?: GetStaticPaths;
46
+ }
47
+
48
+ /**
49
+ * Resolves every concrete path to prerender: static routes are used as-is;
50
+ * dynamic routes (`:slug`, `**`) are expanded via the route's own
51
+ * `getStaticPaths` if it declared one, falling back to the global
52
+ * `getStaticPaths` — otherwise they're skipped (documented: nothing to
53
+ * enumerate without either).
54
+ */
55
+ export async function resolvePaths(config: ResolvePathsConfig): Promise<string[]> {
56
+ const flattened = flattenRoutes(config.routes);
57
+ const paths = new Set<string>();
58
+
59
+ for (const { route, fullPath } of flattened) {
60
+ if (isStaticPath(fullPath)) {
61
+ paths.add(fullPath);
62
+ continue;
63
+ }
64
+ // normalizeRoute() passes plain route-literal objects through unchanged,
65
+ // so this survives even though RouteDefinition itself doesn't declare it.
66
+ const ownStaticPaths = (route as { getStaticPaths?: RouteStaticPaths }).getStaticPaths;
67
+ const expanded = ownStaticPaths
68
+ ? await ownStaticPaths(fullPath)
69
+ : config.getStaticPaths
70
+ ? await config.getStaticPaths(route, fullPath)
71
+ : undefined;
72
+ if (!expanded) continue;
73
+ for (const p of expanded) paths.add(p);
74
+ }
75
+
76
+ return Array.from(paths);
77
+ }
78
+
79
+ /** `/` -> `index.html`; `/about` -> `about/index.html`; trailing-slash convention. */
80
+ export function pathToOutputFile(path: string): string {
81
+ const trimmed = path.replace(/^\/+|\/+$/g, "");
82
+ return trimmed === "" ? "index.html" : `${trimmed}/index.html`;
83
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "module": "ESNext",
7
+ "moduleResolution": "bundler",
8
+ "types": ["node"]
9
+ },
10
+ "include": ["src"]
11
+ }