mintiljs 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.
- package/README.md +192 -0
- package/auth/index.ts +1 -0
- package/i18n/index.ts +1 -0
- package/index.ts +1 -0
- package/package.json +68 -0
- package/src/auth/index.ts +5 -0
- package/src/auth/jwt.ts +101 -0
- package/src/auth/middleware.ts +150 -0
- package/src/auth/session.ts +46 -0
- package/src/auth/store.ts +39 -0
- package/src/auth/types.ts +55 -0
- package/src/cli/cli.ts +142 -0
- package/src/cli/generate.ts +142 -0
- package/src/core/client.ts +157 -0
- package/src/core/config.ts +16 -0
- package/src/core/css.ts +72 -0
- package/src/core/islands.ts +90 -0
- package/src/core/logger.ts +37 -0
- package/src/core/routes.ts +247 -0
- package/src/core/runtime.ts +131 -0
- package/src/core/watcher.ts +43 -0
- package/src/i18n/index.ts +57 -0
- package/src/i18n/loader.ts +125 -0
- package/src/i18n/translate.ts +89 -0
- package/src/i18n/types.ts +10 -0
- package/src/index.ts +10 -0
- package/src/render/island.tsx +78 -0
- package/src/render/ssr.tsx +198 -0
- package/src/render/stream.tsx +144 -0
- package/src/router/api.ts +111 -0
- package/src/router/index.ts +9 -0
- package/src/router/middleware.ts +16 -0
- package/src/router/pages.ts +97 -0
- package/src/router/shared.ts +3 -0
- package/src/types/api.ts +106 -0
- package/src/types/config.ts +35 -0
- package/src/types/index.ts +4 -0
- package/src/types/middleware.ts +21 -0
- package/src/types/page.ts +51 -0
- package/src/types/plugin.ts +29 -0
- package/src/utils/fs.ts +46 -0
- package/src/utils/logger.ts +21 -0
- package/src/utils/network.ts +14 -0
- package/templates/default/api/hello.ts +9 -0
- package/templates/default/components/card.tsx +10 -0
- package/templates/default/components/layouts/base.tsx +26 -0
- package/templates/default/lib/greeting.ts +3 -0
- package/templates/default/mintil.config.ts +10 -0
- package/templates/default/package.json +17 -0
- package/templates/default/pages/(*).tsx +11 -0
- package/templates/default/pages/about.tsx +13 -0
- package/templates/default/pages/blog/(:slug).tsx +12 -0
- package/templates/default/pages/counter.tsx +36 -0
- package/templates/default/pages/index.tsx +14 -0
- package/templates/default/public/readme.txt +1 -0
- package/templates/default/styles.css +1 -0
- package/templates/default/tsconfig.json +21 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { createMintilApp, start, resetStarted } from "./core/runtime.ts";
|
|
2
|
+
export { resolveConfig } from "./core/config.ts";
|
|
3
|
+
export { Island, registerIsland } from "./render/island.tsx";
|
|
4
|
+
export type { IslandDef } from "./render/island.tsx";
|
|
5
|
+
|
|
6
|
+
export type { MintilConfig, ResolvedConfig, MintilMode } from "./types/config.ts";
|
|
7
|
+
export type { PageProps, PageComponent, GetServerSideProps, ServerSidePropsContext } from "./types/page.ts";
|
|
8
|
+
export type { ApiRequest, ApiResponse, ApiHandler, ApiMethodHandler, MintilRequest, MintilResponse } from "./types/api.ts";
|
|
9
|
+
export type { MintilMiddleware } from "./types/middleware.ts";
|
|
10
|
+
export type { MintilPlugin } from "./types/plugin.ts";
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
/** Registered island component definition. */
|
|
4
|
+
export interface IslandDef {
|
|
5
|
+
/** The React component to render. */
|
|
6
|
+
component: React.ComponentType<any>;
|
|
7
|
+
/** Absolute path to the source file (used to build the client bundle). */
|
|
8
|
+
file: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const islandRegistry = new Map<string, IslandDef>();
|
|
12
|
+
|
|
13
|
+
let usedIslands: string[] = [];
|
|
14
|
+
const islandPropsMap = new Map<string, any>();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Register an island component so it can be used via `<Island name="..." />`.
|
|
18
|
+
* Called automatically during startup for files inside the project's `islands/` directory.
|
|
19
|
+
*/
|
|
20
|
+
export function registerIsland(name: string, def: IslandDef) {
|
|
21
|
+
islandRegistry.set(name, def);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Retrieve a registered island definition by name. */
|
|
25
|
+
export function getIslandDef(name: string): IslandDef | undefined {
|
|
26
|
+
return islandRegistry.get(name);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Clear the per-request island usage tracker. Called before each SSR render. */
|
|
30
|
+
export function resetIslands() {
|
|
31
|
+
usedIslands = [];
|
|
32
|
+
islandPropsMap.clear();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Returns the list of island names used during the current render. */
|
|
36
|
+
export function getUsedIslands(): string[] {
|
|
37
|
+
return usedIslands;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Returns the props passed to a given island during the current render. */
|
|
41
|
+
export function getIslandProps(name: string): any {
|
|
42
|
+
return islandPropsMap.get(name);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Island component — renders a registered island on the server and marks it
|
|
47
|
+
* for client hydration.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```tsx
|
|
51
|
+
* import { Island } from "mintiljs";
|
|
52
|
+
*
|
|
53
|
+
* export default function Page() {
|
|
54
|
+
* return <Island name="Counter" props={{ initial: 0 }} />;
|
|
55
|
+
* }
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export function Island({
|
|
59
|
+
name,
|
|
60
|
+
props = {},
|
|
61
|
+
}: {
|
|
62
|
+
name: string;
|
|
63
|
+
props?: any;
|
|
64
|
+
}) {
|
|
65
|
+
const def = islandRegistry.get(name);
|
|
66
|
+
if (!def) {
|
|
67
|
+
console.error(`[mintil] Island "${name}" not registered`);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
if (!usedIslands.includes(name)) usedIslands.push(name);
|
|
71
|
+
islandPropsMap.set(name, props);
|
|
72
|
+
|
|
73
|
+
return React.createElement(
|
|
74
|
+
"div",
|
|
75
|
+
{ "data-island": name, "data-props": JSON.stringify(props) },
|
|
76
|
+
React.createElement(def.component, props),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { renderToString } from "react-dom/server";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathExists } from "../utils/fs.ts";
|
|
5
|
+
import { processCss } from "../core/css.ts";
|
|
6
|
+
import type { PageComponent } from "../types/page.ts";
|
|
7
|
+
|
|
8
|
+
export interface RenderOptions {
|
|
9
|
+
root: string;
|
|
10
|
+
route: string;
|
|
11
|
+
params: Record<string, string>;
|
|
12
|
+
searchParams: URLSearchParams;
|
|
13
|
+
mode?: "development" | "production";
|
|
14
|
+
nonce?: string;
|
|
15
|
+
layout?: PageComponent;
|
|
16
|
+
clientBundle?: string;
|
|
17
|
+
frameworkUrl?: string;
|
|
18
|
+
extraProps?: Record<string, any>;
|
|
19
|
+
islandScripts?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function ErrorFallback({ error }: { error: Error }) {
|
|
23
|
+
return React.createElement(
|
|
24
|
+
"div",
|
|
25
|
+
{
|
|
26
|
+
style: {
|
|
27
|
+
padding: "2rem",
|
|
28
|
+
fontFamily: "system-ui, sans-serif",
|
|
29
|
+
maxWidth: "640px",
|
|
30
|
+
margin: "4rem auto",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
React.createElement("h1", { style: { color: "#b91c1c" } }, "SSR Error"),
|
|
34
|
+
React.createElement(
|
|
35
|
+
"p",
|
|
36
|
+
{ style: { color: "#6b7280" } },
|
|
37
|
+
"An error occurred while rendering this page on the server.",
|
|
38
|
+
),
|
|
39
|
+
React.createElement(
|
|
40
|
+
"pre",
|
|
41
|
+
{
|
|
42
|
+
style: {
|
|
43
|
+
background: "#f3f4f6",
|
|
44
|
+
padding: "1rem",
|
|
45
|
+
borderRadius: "6px",
|
|
46
|
+
overflow: "auto",
|
|
47
|
+
fontSize: "0.875rem",
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
error.stack || error.message || String(error),
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildPageTree(
|
|
56
|
+
Page: PageComponent,
|
|
57
|
+
opts: RenderOptions,
|
|
58
|
+
): React.ReactElement {
|
|
59
|
+
const pageProps = { params: opts.params, searchParams: opts.searchParams, ...opts.extraProps };
|
|
60
|
+
const pageElement = React.createElement(
|
|
61
|
+
"div",
|
|
62
|
+
{ id: "__mintil-root" },
|
|
63
|
+
React.createElement(Page, pageProps),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
if (opts.layout) {
|
|
67
|
+
return React.createElement(opts.layout, { children: pageElement });
|
|
68
|
+
}
|
|
69
|
+
return pageElement;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildHtmlShell(
|
|
73
|
+
content: string,
|
|
74
|
+
extra: string,
|
|
75
|
+
css: string,
|
|
76
|
+
route: string,
|
|
77
|
+
): string {
|
|
78
|
+
const title = route === "/" ? "Mintil App" : `Mintil — ${route.slice(1)}`;
|
|
79
|
+
|
|
80
|
+
if (content.trim().toLowerCase().startsWith("<!doctype") || content.includes("<html")) {
|
|
81
|
+
return extra ? content.replace(/<\/body>/i, `${extra}</body>`) : content;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return `<!DOCTYPE html>
|
|
85
|
+
<html lang="en">
|
|
86
|
+
<head>
|
|
87
|
+
<meta charset="UTF-8" />
|
|
88
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
89
|
+
<title>${title}</title>
|
|
90
|
+
<style>${css}</style>
|
|
91
|
+
</head>
|
|
92
|
+
<body>
|
|
93
|
+
${content}
|
|
94
|
+
${extra}
|
|
95
|
+
</body>
|
|
96
|
+
</html>`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function loadLayout(root: string, nonce?: string): Promise<React.FC<any> | null> {
|
|
100
|
+
const layoutPath = path.join(root, "components", "layouts", "base.tsx");
|
|
101
|
+
if (await pathExists(layoutPath)) {
|
|
102
|
+
try {
|
|
103
|
+
const mod: any = await import(nonce ? `${layoutPath}?t=${nonce}` : layoutPath);
|
|
104
|
+
return mod.default ?? mod.Base ?? null;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildExtraScripts(
|
|
113
|
+
opts: RenderOptions,
|
|
114
|
+
pageProps: Record<string, any>,
|
|
115
|
+
): string {
|
|
116
|
+
const debugScript =
|
|
117
|
+
opts.mode === "development"
|
|
118
|
+
? `<script>console.log("[mintil] development mode");</script>`
|
|
119
|
+
: "";
|
|
120
|
+
|
|
121
|
+
const needsImportmap = !!(opts.clientBundle || opts.islandScripts?.length);
|
|
122
|
+
|
|
123
|
+
let extra = debugScript;
|
|
124
|
+
|
|
125
|
+
if (needsImportmap) {
|
|
126
|
+
const fw = opts.frameworkUrl ?? `https://esm.sh/react@${React.version}`;
|
|
127
|
+
const importMap = {
|
|
128
|
+
imports: {
|
|
129
|
+
react: fw,
|
|
130
|
+
"react-dom": fw,
|
|
131
|
+
"react-dom/client": fw,
|
|
132
|
+
"react/jsx-runtime": fw,
|
|
133
|
+
"react/jsx-dev-runtime": fw,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
extra += `\n<script type="importmap">${JSON.stringify(importMap)}</script>`;
|
|
137
|
+
|
|
138
|
+
if (opts.islandScripts?.length) {
|
|
139
|
+
extra += "\n" + opts.islandScripts.join("\n");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (opts.clientBundle) {
|
|
143
|
+
extra += `\n<script id="__PAGE_PROPS__" type="application/json">${JSON.stringify(pageProps)}</script>`;
|
|
144
|
+
extra += `\n<script type="module" src="${opts.clientBundle}"></script>`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return extra.trim();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Render a page to an HTML string using synchronous SSR.
|
|
153
|
+
* Falls back to an error page if rendering throws.
|
|
154
|
+
*/
|
|
155
|
+
export async function renderPage(
|
|
156
|
+
Page: PageComponent,
|
|
157
|
+
opts: RenderOptions,
|
|
158
|
+
): Promise<string> {
|
|
159
|
+
const pageProps = { params: opts.params, searchParams: opts.searchParams, ...opts.extraProps };
|
|
160
|
+
|
|
161
|
+
// Resolve layout if not provided
|
|
162
|
+
const layout = opts.layout ?? (await loadLayout(opts.root, opts.nonce));
|
|
163
|
+
|
|
164
|
+
// Build element tree
|
|
165
|
+
const pageElement = layout
|
|
166
|
+
? React.createElement(layout, { children: buildInnerPage(Page, pageProps) })
|
|
167
|
+
: buildInnerPage(Page, pageProps);
|
|
168
|
+
|
|
169
|
+
const css = await processCss(opts.root, opts.mode);
|
|
170
|
+
|
|
171
|
+
// SSR with error boundary
|
|
172
|
+
let content: string;
|
|
173
|
+
try {
|
|
174
|
+
content = renderToString(pageElement);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
177
|
+
console.error(`[mintil] SSR error for ${opts.route}:`, error.message);
|
|
178
|
+
|
|
179
|
+
// Render error fallback
|
|
180
|
+
const fallback = renderToString(
|
|
181
|
+
layout
|
|
182
|
+
? React.createElement(layout, { children: ErrorFallback({ error }) })
|
|
183
|
+
: ErrorFallback({ error }),
|
|
184
|
+
);
|
|
185
|
+
content = fallback;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const extra = buildExtraScripts(opts, pageProps);
|
|
189
|
+
return buildHtmlShell(content, extra, css, opts.route);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function buildInnerPage(Page: PageComponent, pageProps: Record<string, any>) {
|
|
193
|
+
return React.createElement(
|
|
194
|
+
"div",
|
|
195
|
+
{ id: "__mintil-root" },
|
|
196
|
+
React.createElement(Page, pageProps),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { renderToReadableStream } from "react-dom/server";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathExists } from "../utils/fs.ts";
|
|
5
|
+
import { processCss } from "../core/css.ts";
|
|
6
|
+
import type { PageComponent } from "../types/page.ts";
|
|
7
|
+
import type { RenderOptions } from "./ssr.tsx";
|
|
8
|
+
|
|
9
|
+
const encoder = new TextEncoder();
|
|
10
|
+
|
|
11
|
+
function buildHeadChunk(css: string, route: string): string {
|
|
12
|
+
const title = route === "/" ? "Mintil App" : `Mintil — ${route.slice(1)}`;
|
|
13
|
+
return `<!DOCTYPE html>
|
|
14
|
+
<html lang="en">
|
|
15
|
+
<head>
|
|
16
|
+
<meta charset="UTF-8" />
|
|
17
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
18
|
+
<title>${title}</title>
|
|
19
|
+
<style>${css}</style>
|
|
20
|
+
</head>
|
|
21
|
+
<body>`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function buildTailChunk(extra: string): string {
|
|
25
|
+
return `${extra}
|
|
26
|
+
</body>
|
|
27
|
+
</html>`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Render a page using React 19's streaming SSR (`renderToReadableStream`).
|
|
32
|
+
*
|
|
33
|
+
* Falls back to the synchronous renderer if:
|
|
34
|
+
* - The page uses `useClient` (client bundle) — needs full HTML for hydration
|
|
35
|
+
* - An error occurs during stream creation
|
|
36
|
+
*
|
|
37
|
+
* @returns A `Response` with the streamed HTML, or `null` to signal fallback.
|
|
38
|
+
*/
|
|
39
|
+
export async function tryStreamPage(
|
|
40
|
+
Page: PageComponent,
|
|
41
|
+
opts: RenderOptions,
|
|
42
|
+
): Promise<Response | null> {
|
|
43
|
+
// Skip streaming for useClient pages — they need exact HTML for hydration
|
|
44
|
+
if (opts.clientBundle) return null;
|
|
45
|
+
|
|
46
|
+
const pageProps = { params: opts.params, searchParams: opts.searchParams, ...opts.extraProps };
|
|
47
|
+
|
|
48
|
+
let Layout: React.FC<any> | null = opts.layout ?? null;
|
|
49
|
+
if (!Layout) {
|
|
50
|
+
const layoutPath = path.join(opts.root, "components", "layouts", "base.tsx");
|
|
51
|
+
if (await pathExists(layoutPath)) {
|
|
52
|
+
try {
|
|
53
|
+
const mod: any = await import(layoutPath);
|
|
54
|
+
Layout = mod.default ?? mod.Base ?? null;
|
|
55
|
+
} catch {
|
|
56
|
+
// ignore
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const css = await processCss(opts.root, opts.mode);
|
|
62
|
+
|
|
63
|
+
// Build the React element tree
|
|
64
|
+
const pageElement = React.createElement(
|
|
65
|
+
"div",
|
|
66
|
+
{ id: "__mintil-root" },
|
|
67
|
+
React.createElement(Page, pageProps),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const element = Layout
|
|
71
|
+
? React.createElement(Layout, { children: pageElement })
|
|
72
|
+
: pageElement;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const stream = await renderToReadableStream(element, {
|
|
76
|
+
bootstrapModules: opts.islandScripts?.length
|
|
77
|
+
? opts.islandScripts.map((s) => {
|
|
78
|
+
const match = s.match(/src="([^"]+)"/);
|
|
79
|
+
return match ? match[1] : s;
|
|
80
|
+
})
|
|
81
|
+
: undefined,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const head = buildHeadChunk(css, opts.route);
|
|
85
|
+
|
|
86
|
+
const debugScript =
|
|
87
|
+
opts.mode === "development"
|
|
88
|
+
? `<script>console.log("[mintil] development mode");</script>`
|
|
89
|
+
: "";
|
|
90
|
+
|
|
91
|
+
const needsImportmap = !!(opts.islandScripts?.length);
|
|
92
|
+
let extraScripts = debugScript;
|
|
93
|
+
|
|
94
|
+
if (needsImportmap) {
|
|
95
|
+
const fw = opts.frameworkUrl ?? `https://esm.sh/react@${React.version}`;
|
|
96
|
+
const importMap = {
|
|
97
|
+
imports: {
|
|
98
|
+
react: fw,
|
|
99
|
+
"react-dom": fw,
|
|
100
|
+
"react-dom/client": fw,
|
|
101
|
+
"react/jsx-runtime": fw,
|
|
102
|
+
"react/jsx-dev-runtime": fw,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
extraScripts += `\n<script type="importmap">${JSON.stringify(importMap)}</script>`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const tail = buildTailChunk(extraScripts.trim());
|
|
109
|
+
|
|
110
|
+
// Combine head + react stream + tail into a single readable stream
|
|
111
|
+
let headWritten = false;
|
|
112
|
+
let tailWritten = false;
|
|
113
|
+
|
|
114
|
+
const combined = new ReadableStream({
|
|
115
|
+
async pull(controller) {
|
|
116
|
+
if (!headWritten) {
|
|
117
|
+
controller.enqueue(encoder.encode(head));
|
|
118
|
+
headWritten = true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const reader = stream.getReader();
|
|
122
|
+
while (true) {
|
|
123
|
+
const { done, value } = await reader.read();
|
|
124
|
+
if (done) break;
|
|
125
|
+
if (value) controller.enqueue(value);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!tailWritten) {
|
|
129
|
+
controller.enqueue(encoder.encode(tail));
|
|
130
|
+
tailWritten = true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
controller.close();
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return new Response(combined, {
|
|
138
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
139
|
+
});
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.error(`[mintil] stream SSR error for ${opts.route}:`, err);
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import path from "node:path"; // used in walkFiles via fileToRoutePath but path.basename explicit
|
|
2
|
+
import { pathExists, walkFiles, fileToRoutePath } from "../utils/fs.ts";
|
|
3
|
+
import { withNonce } from "./shared.ts";
|
|
4
|
+
import type { ApiHandler, ApiMethodHandler } from "../types/api.ts";
|
|
5
|
+
import type { MintilMiddleware } from "../types/middleware.ts";
|
|
6
|
+
|
|
7
|
+
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] as const;
|
|
8
|
+
|
|
9
|
+
export interface ApiHandlerEntry {
|
|
10
|
+
method: string;
|
|
11
|
+
handler: ApiHandler;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ApiRoute {
|
|
15
|
+
file: string;
|
|
16
|
+
route: string;
|
|
17
|
+
handlers: ApiHandlerEntry[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ApiMiddleware {
|
|
21
|
+
file: string;
|
|
22
|
+
route: string;
|
|
23
|
+
middleware: MintilMiddleware;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function collectApiRoutes(root: string, nonce?: string): Promise<ApiRoute[]> {
|
|
27
|
+
const apiDir = path.join(root, "api");
|
|
28
|
+
if (!(await pathExists(apiDir))) return [];
|
|
29
|
+
|
|
30
|
+
const routes: ApiRoute[] = [];
|
|
31
|
+
for await (const file of walkFiles(apiDir)) {
|
|
32
|
+
const ext = path.extname(file);
|
|
33
|
+
if (!isTsLike(ext) && !isJsLike(ext)) continue;
|
|
34
|
+
|
|
35
|
+
// Skip middleware files – they are not API routes.
|
|
36
|
+
const nameWithoutExt = path.basename(file, ext);
|
|
37
|
+
if (nameWithoutExt === "middleware") continue;
|
|
38
|
+
|
|
39
|
+
const route = fileToRoutePath(file, root);
|
|
40
|
+
const mod = await import(withNonce(file, nonce));
|
|
41
|
+
const handlers: ApiHandlerEntry[] = [];
|
|
42
|
+
|
|
43
|
+
for (const method of HTTP_METHODS) {
|
|
44
|
+
const fn: ApiMethodHandler | undefined = mod[method];
|
|
45
|
+
if (typeof fn !== "function") continue;
|
|
46
|
+
handlers.push({ method, handler: wrapMethodHandler(fn) });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (handlers.length === 0) {
|
|
50
|
+
const fallback = mod.default ?? mod.handler ?? null;
|
|
51
|
+
if (typeof fallback === "function") {
|
|
52
|
+
handlers.push({ method: "ALL", handler: fallback });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (handlers.length > 0) {
|
|
57
|
+
routes.push({ file, route, handlers });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
routes.sort((a, b) => sortRoute(a.route, b.route));
|
|
62
|
+
return routes;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function collectApiMiddlewares(root: string, nonce?: string): Promise<ApiMiddleware[]> {
|
|
66
|
+
const apiDir = path.join(root, "api");
|
|
67
|
+
if (!(await pathExists(apiDir))) return [];
|
|
68
|
+
|
|
69
|
+
const middlewares: ApiMiddleware[] = [];
|
|
70
|
+
for await (const file of walkFiles(apiDir)) {
|
|
71
|
+
const basename = path.basename(file).replace(path.extname(file), "");
|
|
72
|
+
if (basename !== "middleware") continue;
|
|
73
|
+
// The middleware's route is the directory it lives in, not including the filename.
|
|
74
|
+
const dir = path.dirname(file);
|
|
75
|
+
const route = fileToRoutePath(dir, root);
|
|
76
|
+
const mod: any = await import(withNonce(file, nonce));
|
|
77
|
+
const mw = mod.default ?? null;
|
|
78
|
+
if (typeof mw !== "function") continue;
|
|
79
|
+
middlewares.push({ file, route, middleware: mw });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Most specific (deepest) middleware first.
|
|
83
|
+
middlewares.sort((a, b) => b.route.length - a.route.length);
|
|
84
|
+
return middlewares;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function wrapMethodHandler(fn: ApiMethodHandler): ApiHandler {
|
|
88
|
+
return async (c) => {
|
|
89
|
+
const result = await fn(c.req, c);
|
|
90
|
+
if (result instanceof Response) return result;
|
|
91
|
+
if (result !== undefined) return c.json(result);
|
|
92
|
+
return c.body(null);
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isJsLike(ext: string) {
|
|
97
|
+
return ext === ".ts" || ext === ".js";
|
|
98
|
+
}
|
|
99
|
+
function isTsLike(ext: string) {
|
|
100
|
+
return ext === ".ts" || ext === ".tsx" || ext === ".js" || ext === ".jsx";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function routeSpecificity(route: string): number {
|
|
104
|
+
if (route.includes("*")) return -1000;
|
|
105
|
+
if (route.includes(":")) return -100;
|
|
106
|
+
return route.split("/").length;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sortRoute(a: string, b: string): number {
|
|
110
|
+
return routeSpecificity(b) - routeSpecificity(a);
|
|
111
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { collectPageRoutes, collectPageLayouts } from "./pages.ts";
|
|
2
|
+
export type { PageRoute, PageLayout } from "./pages.ts";
|
|
3
|
+
|
|
4
|
+
export { collectApiRoutes, collectApiMiddlewares } from "./api.ts";
|
|
5
|
+
export type { ApiRoute, ApiMiddleware, ApiHandlerEntry } from "./api.ts";
|
|
6
|
+
|
|
7
|
+
export { loadRootMiddleware } from "./middleware.ts";
|
|
8
|
+
|
|
9
|
+
export { withNonce } from "./shared.ts";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { pathExists } from "../utils/fs.ts";
|
|
3
|
+
import { withNonce } from "./shared.ts";
|
|
4
|
+
import type { MintilMiddleware } from "../types/middleware.ts";
|
|
5
|
+
|
|
6
|
+
export async function loadRootMiddleware(root: string, nonce?: string): Promise<MintilMiddleware | null> {
|
|
7
|
+
const candidates = ["middleware.ts", "middleware.tsx", "middleware.js", "middleware.jsx"];
|
|
8
|
+
for (const file of candidates) {
|
|
9
|
+
const full = path.join(root, file);
|
|
10
|
+
if (!(await pathExists(full))) continue;
|
|
11
|
+
const mod: any = await import(withNonce(full, nonce));
|
|
12
|
+
const middleware = mod.default ?? null;
|
|
13
|
+
if (typeof middleware === "function") return middleware;
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { pathExists, walkFiles, fileToRoutePath } from "../utils/fs.ts";
|
|
3
|
+
import { withNonce } from "./shared.ts";
|
|
4
|
+
import type { Context } from "hono";
|
|
5
|
+
import type { PageComponent, PageProps, GetServerSideProps } from "../types/page.ts";
|
|
6
|
+
|
|
7
|
+
export interface PageRoute {
|
|
8
|
+
file: string;
|
|
9
|
+
route: string;
|
|
10
|
+
mod: any;
|
|
11
|
+
component: PageComponent;
|
|
12
|
+
useClient: boolean;
|
|
13
|
+
getServerSideProps?: GetServerSideProps;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PageLayout {
|
|
17
|
+
file: string;
|
|
18
|
+
scope: string;
|
|
19
|
+
component: PageComponent;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function collectPageRoutes(root: string, nonce?: string): Promise<PageRoute[]> {
|
|
23
|
+
const pagesDir = path.join(root, "pages");
|
|
24
|
+
if (!(await pathExists(pagesDir))) return [];
|
|
25
|
+
|
|
26
|
+
const routes: PageRoute[] = [];
|
|
27
|
+
for await (const file of walkFiles(pagesDir)) {
|
|
28
|
+
const ext = path.extname(file);
|
|
29
|
+
if (!isJsxLike(ext) && !isJsLike(ext)) continue;
|
|
30
|
+
|
|
31
|
+
const basename = path.basename(file, ext);
|
|
32
|
+
if (basename === "layout") continue;
|
|
33
|
+
|
|
34
|
+
const route = fileToRoutePath(file, pagesDir);
|
|
35
|
+
const mod = await import(withNonce(file, nonce));
|
|
36
|
+
const component = mod.default ?? mod.Page ?? null;
|
|
37
|
+
if (typeof component !== "function") continue;
|
|
38
|
+
const useClient = mod.useClient === true;
|
|
39
|
+
const getServerSideProps = typeof mod.getServerSideProps === "function" ? mod.getServerSideProps.bind(null) : undefined;
|
|
40
|
+
routes.push({ file, route, mod, component, useClient, getServerSideProps });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
routes.sort((a, b) => sortRoute(a.route, b.route));
|
|
44
|
+
return routes;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function collectPageLayouts(root: string, nonce?: string): Promise<PageLayout[]> {
|
|
48
|
+
const pagesDir = path.join(root, "pages");
|
|
49
|
+
if (!(await pathExists(pagesDir))) return [];
|
|
50
|
+
|
|
51
|
+
const layouts: PageLayout[] = [];
|
|
52
|
+
for await (const file of walkFiles(pagesDir)) {
|
|
53
|
+
const ext = path.extname(file);
|
|
54
|
+
if (!isJsxLike(ext) && !isJsLike(ext)) continue;
|
|
55
|
+
|
|
56
|
+
const basename = path.basename(file, ext);
|
|
57
|
+
if (basename !== "layout") continue;
|
|
58
|
+
|
|
59
|
+
const scopeRoute = fileToRoutePath(file, pagesDir);
|
|
60
|
+
const scope = scopeRoute.replace(/\/layout$/i, "") || "/";
|
|
61
|
+
const mod = await import(withNonce(file, nonce));
|
|
62
|
+
const component = mod.default ?? mod.Layout ?? null;
|
|
63
|
+
if (typeof component !== "function") continue;
|
|
64
|
+
layouts.push({ file, scope, component });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
layouts.sort((a, b) => b.scope.length - a.scope.length);
|
|
68
|
+
return layouts;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isJsxLike(ext: string) {
|
|
72
|
+
return ext === ".tsx" || ext === ".jsx";
|
|
73
|
+
}
|
|
74
|
+
function isJsLike(ext: string) {
|
|
75
|
+
return ext === ".ts" || ext === ".js";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function routeSpecificity(route: string): number {
|
|
79
|
+
if (route.includes("*")) return -1000;
|
|
80
|
+
if (route.includes(":")) return -100;
|
|
81
|
+
return route.split("/").length;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sortRoute(a: string, b: string): number {
|
|
85
|
+
return routeSpecificity(b) - routeSpecificity(a);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function pagePropsFromContext(c: Context): PageProps {
|
|
89
|
+
const params: Record<string, string> = {};
|
|
90
|
+
for (const [key, value] of Object.entries(c.req.param())) {
|
|
91
|
+
params[key] = String(value ?? "");
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
params,
|
|
95
|
+
searchParams: new URL(c.req.url).searchParams,
|
|
96
|
+
};
|
|
97
|
+
}
|