idcmd 0.0.1 → 0.0.2
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/LICENSE +21 -0
- package/README.md +96 -2
- package/package.json +52 -6
- package/public/_idcmd/live-reload.js +18 -0
- package/public/_idcmd/llm-menu.js +153 -0
- package/public/_idcmd/nav-prefetch.js +30 -0
- package/public/_idcmd/right-rail-scrollspy.js +262 -0
- package/public/anthropic-black.svg +16 -0
- package/public/anthropic-white.svg +16 -0
- package/public/favicon.svg +13 -0
- package/public/live-reload.js +18 -0
- package/public/llm-menu.js +153 -0
- package/public/openai-black.svg +15 -0
- package/public/openai-white.svg +15 -0
- package/public/right-rail-scrollspy.js +262 -0
- package/src/build.ts +230 -0
- package/src/cli/args.ts +101 -0
- package/src/cli/commands/build.ts +43 -0
- package/src/cli/commands/deploy.ts +82 -0
- package/src/cli/commands/dev.ts +79 -0
- package/src/cli/commands/init.ts +211 -0
- package/src/cli/commands/preview.ts +57 -0
- package/src/cli/fs.ts +47 -0
- package/src/cli/main.ts +120 -0
- package/src/cli/normalize.ts +26 -0
- package/src/cli/path.ts +30 -0
- package/src/cli/prompt.ts +74 -0
- package/src/cli/run.ts +17 -0
- package/src/cli/version.ts +12 -0
- package/src/cli.ts +6 -0
- package/src/client/index.ts +7 -0
- package/src/content/components/expand.ts +351 -0
- package/src/content/components/install-tabs.ts +120 -0
- package/src/content/components/registry.ts +12 -0
- package/src/content/components/types.ts +21 -0
- package/src/content/frontmatter.ts +89 -0
- package/src/content/icons.ts +78 -0
- package/src/content/llms.ts +94 -0
- package/src/content/meta.ts +92 -0
- package/src/content/navigation.ts +156 -0
- package/src/content/paths.ts +34 -0
- package/src/content/store.ts +10 -0
- package/src/project/paths.ts +86 -0
- package/src/render/layout-loader.ts +46 -0
- package/src/render/layout.tsx +340 -0
- package/src/render/markdown.ts +14 -0
- package/src/render/page-renderer.ts +321 -0
- package/src/render/right-rail.tsx +250 -0
- package/src/render/toc.ts +66 -0
- package/src/search/api.ts +76 -0
- package/src/search/contract.ts +44 -0
- package/src/search/index.ts +265 -0
- package/src/search/page.tsx +96 -0
- package/src/search/server-page.ts +99 -0
- package/src/seo/files.ts +124 -0
- package/src/seo/server.ts +103 -0
- package/src/server/headers.ts +10 -0
- package/src/server/live-reload.ts +121 -0
- package/src/server/static.ts +59 -0
- package/src/server/user-routes.ts +209 -0
- package/src/server.ts +234 -0
- package/src/site/config.ts +244 -0
- package/src/site/url-policy.ts +60 -0
- package/src/site/urls.ts +46 -0
- package/templates/default/README.md +26 -0
- package/templates/default/package.json +29 -0
- package/templates/default/site/client/layout.tsx +2 -0
- package/templates/default/site/client/right-rail.tsx +1 -0
- package/templates/default/site/client/search-page.tsx +1 -0
- package/templates/default/site/content/404.md +8 -0
- package/templates/default/site/content/about.md +10 -0
- package/templates/default/site/content/index.md +10 -0
- package/templates/default/site/icons/file.svg +1 -0
- package/templates/default/site/icons/home.svg +1 -0
- package/templates/default/site/icons/info.svg +1 -0
- package/templates/default/site/public/_idcmd/live-reload.js +18 -0
- package/templates/default/site/public/_idcmd/llm-menu.js +153 -0
- package/templates/default/site/public/_idcmd/nav-prefetch.js +30 -0
- package/templates/default/site/public/_idcmd/right-rail-scrollspy.js +262 -0
- package/templates/default/site/public/anthropic-white.svg +16 -0
- package/templates/default/site/public/favicon.svg +13 -0
- package/templates/default/site/public/openai-white.svg +15 -0
- package/templates/default/site/server/routes/api/hello.ts +2 -0
- package/templates/default/site/server/server.ts +4 -0
- package/templates/default/site/site.jsonc +21 -0
- package/templates/default/site/styles/tailwind.css +452 -0
- package/templates/default/tsconfig.json +23 -0
- package/templates/default/vercel.json +7 -0
- package/index.js +0 -2
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { loadSiteConfig } from "@/site/config";
|
|
2
|
+
import { resolveCanonicalBaseUrl } from "@/site/urls";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
collectSitemapPagesFromContent,
|
|
6
|
+
generateRobotsTxt,
|
|
7
|
+
generateSitemapXml,
|
|
8
|
+
} from "./files";
|
|
9
|
+
|
|
10
|
+
export interface SeoHandlerEnv {
|
|
11
|
+
distDir: string;
|
|
12
|
+
isDev: boolean;
|
|
13
|
+
staticCacheHeaders: HeadersInit;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const tryServeDistFile = async (
|
|
17
|
+
filePath: string,
|
|
18
|
+
contentType: string,
|
|
19
|
+
env: SeoHandlerEnv
|
|
20
|
+
): Promise<Response | null> => {
|
|
21
|
+
if (env.isDev) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const file = Bun.file(filePath);
|
|
26
|
+
if (!(await file.exists())) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return new Response(file, {
|
|
31
|
+
headers: {
|
|
32
|
+
"Content-Type": contentType,
|
|
33
|
+
...env.staticCacheHeaders,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const handleRobotsTxt = async (
|
|
39
|
+
url: URL,
|
|
40
|
+
env: SeoHandlerEnv
|
|
41
|
+
): Promise<Response | undefined> => {
|
|
42
|
+
if (url.pathname !== "/robots.txt") {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const served = await tryServeDistFile(
|
|
47
|
+
`${env.distDir}/robots.txt`,
|
|
48
|
+
"text/plain; charset=utf-8",
|
|
49
|
+
env
|
|
50
|
+
);
|
|
51
|
+
if (served) {
|
|
52
|
+
return served;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const siteConfig = await loadSiteConfig();
|
|
56
|
+
const baseUrl =
|
|
57
|
+
resolveCanonicalBaseUrl({
|
|
58
|
+
configuredBaseUrl: siteConfig.baseUrl,
|
|
59
|
+
isDev: env.isDev,
|
|
60
|
+
requestOrigin: url.origin,
|
|
61
|
+
}) ?? url.origin;
|
|
62
|
+
|
|
63
|
+
return new Response(generateRobotsTxt(baseUrl), {
|
|
64
|
+
headers: {
|
|
65
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
66
|
+
...env.staticCacheHeaders,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const handleSitemapXml = async (
|
|
72
|
+
url: URL,
|
|
73
|
+
env: SeoHandlerEnv
|
|
74
|
+
): Promise<Response | undefined> => {
|
|
75
|
+
if (url.pathname !== "/sitemap.xml") {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const served = await tryServeDistFile(
|
|
80
|
+
`${env.distDir}/sitemap.xml`,
|
|
81
|
+
"application/xml; charset=utf-8",
|
|
82
|
+
env
|
|
83
|
+
);
|
|
84
|
+
if (served) {
|
|
85
|
+
return served;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const siteConfig = await loadSiteConfig();
|
|
89
|
+
const baseUrl =
|
|
90
|
+
resolveCanonicalBaseUrl({
|
|
91
|
+
configuredBaseUrl: siteConfig.baseUrl,
|
|
92
|
+
isDev: env.isDev,
|
|
93
|
+
requestOrigin: url.origin,
|
|
94
|
+
}) ?? url.origin;
|
|
95
|
+
const pages = await collectSitemapPagesFromContent();
|
|
96
|
+
|
|
97
|
+
return new Response(generateSitemapXml(pages, baseUrl), {
|
|
98
|
+
headers: {
|
|
99
|
+
"Content-Type": "application/xml; charset=utf-8",
|
|
100
|
+
...env.staticCacheHeaders,
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const createHtmlCacheHeaders = (isDev: boolean): HeadersInit => ({
|
|
2
|
+
"Cache-Control": isDev
|
|
3
|
+
? "no-cache"
|
|
4
|
+
: "s-maxage=60, stale-while-revalidate=3600",
|
|
5
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export const createStaticCacheHeaders = (isDev: boolean): HeadersInit => ({
|
|
9
|
+
"Cache-Control": isDev ? "no-cache" : "public, max-age=31536000, immutable",
|
|
10
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Server } from "bun";
|
|
2
|
+
|
|
3
|
+
import { getContentDir, scanContentFiles } from "@/content/paths";
|
|
4
|
+
|
|
5
|
+
interface LiveReloadClient {
|
|
6
|
+
close: () => void;
|
|
7
|
+
send: (msg: string) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type ServerInstance = Server<undefined>;
|
|
11
|
+
|
|
12
|
+
export interface LiveReloadEnv {
|
|
13
|
+
isDev: boolean;
|
|
14
|
+
pollMs: number;
|
|
15
|
+
websocketPath: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LiveReloadController {
|
|
19
|
+
maybeHandleUpgrade: (
|
|
20
|
+
req: Request,
|
|
21
|
+
server: ServerInstance,
|
|
22
|
+
pathname: string
|
|
23
|
+
) => "handled" | Response | undefined;
|
|
24
|
+
startWatcher: () => Promise<void>;
|
|
25
|
+
websocket: {
|
|
26
|
+
close: (ws: LiveReloadClient) => void;
|
|
27
|
+
message: () => void;
|
|
28
|
+
open: (ws: LiveReloadClient) => void;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const createLiveReload = (env: LiveReloadEnv): LiveReloadController => {
|
|
33
|
+
const clients = new Set<LiveReloadClient>();
|
|
34
|
+
|
|
35
|
+
const notify = (message: string): void => {
|
|
36
|
+
for (const client of clients) {
|
|
37
|
+
try {
|
|
38
|
+
client.send(message);
|
|
39
|
+
} catch {
|
|
40
|
+
clients.delete(client);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const getContentSnapshot = async (): Promise<string> => {
|
|
46
|
+
const contentDir = await getContentDir();
|
|
47
|
+
const entries: string[] = [];
|
|
48
|
+
for await (const file of scanContentFiles()) {
|
|
49
|
+
const filePath = `${contentDir}/${file}`;
|
|
50
|
+
const { lastModified } = Bun.file(filePath);
|
|
51
|
+
entries.push(`${file}:${lastModified}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return entries.toSorted().join("|");
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const startWatcher = async (): Promise<void> => {
|
|
58
|
+
if (!env.isDev) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log("Watching content/ for changes...");
|
|
63
|
+
let snapshot = await getContentSnapshot();
|
|
64
|
+
|
|
65
|
+
const poll = async (): Promise<void> => {
|
|
66
|
+
const nextSnapshot = await getContentSnapshot();
|
|
67
|
+
if (nextSnapshot !== snapshot) {
|
|
68
|
+
snapshot = nextSnapshot;
|
|
69
|
+
console.log("[live-reload] Content updated");
|
|
70
|
+
notify("reload");
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
setInterval(async () => {
|
|
75
|
+
try {
|
|
76
|
+
await poll();
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.warn("[live-reload] Polling error", error);
|
|
79
|
+
}
|
|
80
|
+
}, env.pollMs);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const maybeHandleUpgrade = (
|
|
84
|
+
req: Request,
|
|
85
|
+
server: ServerInstance,
|
|
86
|
+
pathname: string
|
|
87
|
+
): "handled" | Response | undefined => {
|
|
88
|
+
// Backward compatible: accept both legacy and new websocket paths.
|
|
89
|
+
if (
|
|
90
|
+
!env.isDev ||
|
|
91
|
+
(pathname !== env.websocketPath && pathname !== "/__live-reload")
|
|
92
|
+
) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const upgraded = server.upgrade(req);
|
|
97
|
+
if (upgraded) {
|
|
98
|
+
return "handled";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
maybeHandleUpgrade,
|
|
106
|
+
startWatcher,
|
|
107
|
+
websocket: {
|
|
108
|
+
close(ws) {
|
|
109
|
+
clients.delete(ws);
|
|
110
|
+
console.log("[live-reload] Client disconnected");
|
|
111
|
+
},
|
|
112
|
+
message() {
|
|
113
|
+
// No messages expected from client.
|
|
114
|
+
},
|
|
115
|
+
open(ws) {
|
|
116
|
+
clients.add(ws);
|
|
117
|
+
console.log("[live-reload] Client connected");
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const mimeTypes: Record<string, string> = {
|
|
2
|
+
".css": "text/css",
|
|
3
|
+
".gif": "image/gif",
|
|
4
|
+
".ico": "image/x-icon",
|
|
5
|
+
".jpeg": "image/jpeg",
|
|
6
|
+
".jpg": "image/jpeg",
|
|
7
|
+
".js": "application/javascript",
|
|
8
|
+
".json": "application/json",
|
|
9
|
+
".png": "image/png",
|
|
10
|
+
".svg": "image/svg+xml",
|
|
11
|
+
".woff": "font/woff",
|
|
12
|
+
".woff2": "font/woff2",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export interface ServeStaticEnv {
|
|
16
|
+
distDir: string;
|
|
17
|
+
isDev: boolean;
|
|
18
|
+
publicDir: string;
|
|
19
|
+
staticCacheHeaders: HeadersInit;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const tryServeFileFromRoot = async (
|
|
23
|
+
rootDir: string,
|
|
24
|
+
pathname: string,
|
|
25
|
+
env: ServeStaticEnv
|
|
26
|
+
): Promise<Response | null> => {
|
|
27
|
+
const filePath = `${rootDir}${pathname}`;
|
|
28
|
+
const file = Bun.file(filePath);
|
|
29
|
+
|
|
30
|
+
if (!(await file.exists())) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const ext = pathname.slice(pathname.lastIndexOf("."));
|
|
35
|
+
const contentType = mimeTypes[ext] || "application/octet-stream";
|
|
36
|
+
|
|
37
|
+
return new Response(file, {
|
|
38
|
+
headers: {
|
|
39
|
+
"Content-Type": contentType,
|
|
40
|
+
...env.staticCacheHeaders,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const serveStaticFile = async (
|
|
46
|
+
pathname: string,
|
|
47
|
+
env: ServeStaticEnv
|
|
48
|
+
): Promise<Response | null> => {
|
|
49
|
+
const roots = env.isDev ? [env.publicDir] : [env.distDir, env.publicDir];
|
|
50
|
+
|
|
51
|
+
for (const root of roots) {
|
|
52
|
+
const served = await tryServeFileFromRoot(root, pathname, env);
|
|
53
|
+
if (served) {
|
|
54
|
+
return served;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return null;
|
|
59
|
+
};
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { ProjectPaths } from "@/project/paths";
|
|
2
|
+
|
|
3
|
+
import { isFileLikePathname, toCanonicalHtmlPathname } from "@/site/url-policy";
|
|
4
|
+
|
|
5
|
+
export type RouteHandler = (req: Request) => Response | Promise<Response>;
|
|
6
|
+
|
|
7
|
+
export interface RouteModule {
|
|
8
|
+
DELETE?: RouteHandler;
|
|
9
|
+
GET?: RouteHandler;
|
|
10
|
+
HEAD?: RouteHandler;
|
|
11
|
+
OPTIONS?: RouteHandler;
|
|
12
|
+
PATCH?: RouteHandler;
|
|
13
|
+
POST?: RouteHandler;
|
|
14
|
+
PUT?: RouteHandler;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UserRoutesEnv {
|
|
18
|
+
isDev: boolean;
|
|
19
|
+
routesDir: ProjectPaths["routesDir"];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface LoadedRoute {
|
|
23
|
+
handlers: RouteModule;
|
|
24
|
+
pathname: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SUPPORTED_EXTENSIONS = [".ts", ".js", ".mjs"] as const;
|
|
28
|
+
|
|
29
|
+
const isSupportedRouteFile = (relativePath: string): boolean =>
|
|
30
|
+
SUPPORTED_EXTENSIONS.some((ext) => relativePath.endsWith(ext));
|
|
31
|
+
|
|
32
|
+
const normalizeRelativePath = (relativePath: string): string =>
|
|
33
|
+
relativePath.replaceAll("\\", "/").replaceAll(/^\/+/g, "");
|
|
34
|
+
|
|
35
|
+
const stripExtension = (relativePath: string): string => {
|
|
36
|
+
for (const ext of SUPPORTED_EXTENSIONS) {
|
|
37
|
+
if (relativePath.endsWith(ext)) {
|
|
38
|
+
return relativePath.slice(0, -ext.length);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return relativePath;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const stripTrailingIndex = (value: string): string => {
|
|
45
|
+
if (value === "index") {
|
|
46
|
+
return "";
|
|
47
|
+
}
|
|
48
|
+
if (value.endsWith("/index")) {
|
|
49
|
+
return value.slice(0, -"/index".length);
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const hasUnsupportedDynamicSegment = (pathname: string): boolean =>
|
|
55
|
+
pathname.includes("[") || pathname.includes("]") || pathname.includes(":");
|
|
56
|
+
|
|
57
|
+
const pathnameFromRouteRelativePath = (relativePath: string): string => {
|
|
58
|
+
const normalized = normalizeRelativePath(relativePath);
|
|
59
|
+
const withoutExt = stripExtension(normalized);
|
|
60
|
+
const withoutIndex = stripTrailingIndex(withoutExt);
|
|
61
|
+
if (!withoutIndex) {
|
|
62
|
+
return "/";
|
|
63
|
+
}
|
|
64
|
+
return `/${withoutIndex}`;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const routePathnameFromFile = (
|
|
68
|
+
filePath: string,
|
|
69
|
+
routesDir: string
|
|
70
|
+
): string => {
|
|
71
|
+
const normalizedDir = routesDir.replaceAll("\\", "/").replaceAll(/\/+$/g, "");
|
|
72
|
+
const normalizedFile = filePath.replaceAll("\\", "/");
|
|
73
|
+
const prefix = `${normalizedDir}/`;
|
|
74
|
+
const relative = normalizedFile.startsWith(prefix)
|
|
75
|
+
? normalizedFile.slice(prefix.length)
|
|
76
|
+
: normalizedFile;
|
|
77
|
+
return pathnameFromRouteRelativePath(relative);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const loadRouteModule = async (filePath: string): Promise<RouteModule> => {
|
|
81
|
+
const url = Bun.pathToFileURL(filePath);
|
|
82
|
+
const mod = (await import(url.toString())) as unknown;
|
|
83
|
+
return mod as RouteModule;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const scanRouteFiles = async (routesDir: string): Promise<string[]> => {
|
|
87
|
+
const routes: string[] = [];
|
|
88
|
+
const glob = new Bun.Glob("**/*");
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
for await (const relativePath of glob.scan(routesDir)) {
|
|
92
|
+
if (!isSupportedRouteFile(relativePath)) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
routes.push(relativePath);
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return routes;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const cacheByDir = new Map<string, LoadedRoute[]>();
|
|
105
|
+
|
|
106
|
+
const loadAllRoutes = async (env: UserRoutesEnv): Promise<LoadedRoute[]> => {
|
|
107
|
+
const cached = cacheByDir.get(env.routesDir);
|
|
108
|
+
if (cached) {
|
|
109
|
+
return cached;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const files = await scanRouteFiles(env.routesDir);
|
|
113
|
+
const loaded = await loadRoutesFromFiles(env.routesDir, files);
|
|
114
|
+
cacheByDir.set(env.routesDir, loaded);
|
|
115
|
+
return loaded;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const loadRoutesFromFiles = async (
|
|
119
|
+
routesDir: string,
|
|
120
|
+
files: readonly string[]
|
|
121
|
+
): Promise<LoadedRoute[]> => {
|
|
122
|
+
if (files.length === 0) {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const loaded: LoadedRoute[] = [];
|
|
127
|
+
for (const relativeFile of files) {
|
|
128
|
+
// eslint-disable-next-line no-await-in-loop
|
|
129
|
+
loaded.push(await loadOneRoute(routesDir, relativeFile));
|
|
130
|
+
}
|
|
131
|
+
return loaded;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const loadOneRoute = async (
|
|
135
|
+
routesDir: string,
|
|
136
|
+
relativeFile: string
|
|
137
|
+
): Promise<LoadedRoute> => {
|
|
138
|
+
const pathname = pathnameFromRouteRelativePath(relativeFile);
|
|
139
|
+
if (hasUnsupportedDynamicSegment(pathname)) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`Unsupported dynamic route segment in ${routesDir}/${relativeFile} (computed pathname: ${pathname}). V1 does not support [param] or :param routes.`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const handlers = await loadRouteModule(`${routesDir}/${relativeFile}`);
|
|
146
|
+
return { handlers, pathname };
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const handlerFor = (
|
|
150
|
+
handlers: RouteModule,
|
|
151
|
+
method: string
|
|
152
|
+
): RouteHandler | null => {
|
|
153
|
+
const value = (handlers as Record<string, unknown>)[method];
|
|
154
|
+
return typeof value === "function" ? (value as RouteHandler) : null;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const candidatePathnamesForRequest = (rawPathname: string): Set<string> => {
|
|
158
|
+
const canonical = isFileLikePathname(rawPathname)
|
|
159
|
+
? rawPathname
|
|
160
|
+
: toCanonicalHtmlPathname(rawPathname);
|
|
161
|
+
|
|
162
|
+
const trimmedRaw =
|
|
163
|
+
rawPathname.endsWith("/") && rawPathname !== "/"
|
|
164
|
+
? rawPathname.slice(0, -1)
|
|
165
|
+
: rawPathname;
|
|
166
|
+
const trimmedCanonical =
|
|
167
|
+
canonical.endsWith("/") && canonical !== "/"
|
|
168
|
+
? canonical.slice(0, -1)
|
|
169
|
+
: canonical;
|
|
170
|
+
|
|
171
|
+
return new Set([rawPathname, canonical, trimmedRaw, trimmedCanonical]);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export const handleUserRouteRequest = async (
|
|
175
|
+
url: URL,
|
|
176
|
+
req: Request,
|
|
177
|
+
env: UserRoutesEnv
|
|
178
|
+
): Promise<Response | undefined> => {
|
|
179
|
+
const match = await findMatchingRoute(url.pathname, env);
|
|
180
|
+
if (!match) {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const handler = handlerFor(match.handlers, req.method);
|
|
185
|
+
if (!handler) {
|
|
186
|
+
return createMethodNotAllowedResponse(match.handlers);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return handler(req);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const findMatchingRoute = async (
|
|
193
|
+
pathname: string,
|
|
194
|
+
env: UserRoutesEnv
|
|
195
|
+
): Promise<LoadedRoute | null> => {
|
|
196
|
+
const routes = await loadAllRoutes(env);
|
|
197
|
+
if (routes.length === 0) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const candidates = candidatePathnamesForRequest(pathname);
|
|
202
|
+
return routes.find((r) => candidates.has(r.pathname)) ?? null;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const createMethodNotAllowedResponse = (handlers: RouteModule): Response =>
|
|
206
|
+
new Response("Method Not Allowed", {
|
|
207
|
+
headers: { Allow: Object.keys(handlers).join(", ") },
|
|
208
|
+
status: 405,
|
|
209
|
+
});
|