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.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -2
  3. package/package.json +52 -6
  4. package/public/_idcmd/live-reload.js +18 -0
  5. package/public/_idcmd/llm-menu.js +153 -0
  6. package/public/_idcmd/nav-prefetch.js +30 -0
  7. package/public/_idcmd/right-rail-scrollspy.js +262 -0
  8. package/public/anthropic-black.svg +16 -0
  9. package/public/anthropic-white.svg +16 -0
  10. package/public/favicon.svg +13 -0
  11. package/public/live-reload.js +18 -0
  12. package/public/llm-menu.js +153 -0
  13. package/public/openai-black.svg +15 -0
  14. package/public/openai-white.svg +15 -0
  15. package/public/right-rail-scrollspy.js +262 -0
  16. package/src/build.ts +230 -0
  17. package/src/cli/args.ts +101 -0
  18. package/src/cli/commands/build.ts +43 -0
  19. package/src/cli/commands/deploy.ts +82 -0
  20. package/src/cli/commands/dev.ts +79 -0
  21. package/src/cli/commands/init.ts +211 -0
  22. package/src/cli/commands/preview.ts +57 -0
  23. package/src/cli/fs.ts +47 -0
  24. package/src/cli/main.ts +120 -0
  25. package/src/cli/normalize.ts +26 -0
  26. package/src/cli/path.ts +30 -0
  27. package/src/cli/prompt.ts +74 -0
  28. package/src/cli/run.ts +17 -0
  29. package/src/cli/version.ts +12 -0
  30. package/src/cli.ts +6 -0
  31. package/src/client/index.ts +7 -0
  32. package/src/content/components/expand.ts +351 -0
  33. package/src/content/components/install-tabs.ts +120 -0
  34. package/src/content/components/registry.ts +12 -0
  35. package/src/content/components/types.ts +21 -0
  36. package/src/content/frontmatter.ts +89 -0
  37. package/src/content/icons.ts +78 -0
  38. package/src/content/llms.ts +94 -0
  39. package/src/content/meta.ts +92 -0
  40. package/src/content/navigation.ts +156 -0
  41. package/src/content/paths.ts +34 -0
  42. package/src/content/store.ts +10 -0
  43. package/src/project/paths.ts +86 -0
  44. package/src/render/layout-loader.ts +46 -0
  45. package/src/render/layout.tsx +340 -0
  46. package/src/render/markdown.ts +14 -0
  47. package/src/render/page-renderer.ts +321 -0
  48. package/src/render/right-rail.tsx +250 -0
  49. package/src/render/toc.ts +66 -0
  50. package/src/search/api.ts +76 -0
  51. package/src/search/contract.ts +44 -0
  52. package/src/search/index.ts +265 -0
  53. package/src/search/page.tsx +96 -0
  54. package/src/search/server-page.ts +99 -0
  55. package/src/seo/files.ts +124 -0
  56. package/src/seo/server.ts +103 -0
  57. package/src/server/headers.ts +10 -0
  58. package/src/server/live-reload.ts +121 -0
  59. package/src/server/static.ts +59 -0
  60. package/src/server/user-routes.ts +209 -0
  61. package/src/server.ts +234 -0
  62. package/src/site/config.ts +244 -0
  63. package/src/site/url-policy.ts +60 -0
  64. package/src/site/urls.ts +46 -0
  65. package/templates/default/README.md +26 -0
  66. package/templates/default/package.json +29 -0
  67. package/templates/default/site/client/layout.tsx +2 -0
  68. package/templates/default/site/client/right-rail.tsx +1 -0
  69. package/templates/default/site/client/search-page.tsx +1 -0
  70. package/templates/default/site/content/404.md +8 -0
  71. package/templates/default/site/content/about.md +10 -0
  72. package/templates/default/site/content/index.md +10 -0
  73. package/templates/default/site/icons/file.svg +1 -0
  74. package/templates/default/site/icons/home.svg +1 -0
  75. package/templates/default/site/icons/info.svg +1 -0
  76. package/templates/default/site/public/_idcmd/live-reload.js +18 -0
  77. package/templates/default/site/public/_idcmd/llm-menu.js +153 -0
  78. package/templates/default/site/public/_idcmd/nav-prefetch.js +30 -0
  79. package/templates/default/site/public/_idcmd/right-rail-scrollspy.js +262 -0
  80. package/templates/default/site/public/anthropic-white.svg +16 -0
  81. package/templates/default/site/public/favicon.svg +13 -0
  82. package/templates/default/site/public/openai-white.svg +15 -0
  83. package/templates/default/site/server/routes/api/hello.ts +2 -0
  84. package/templates/default/site/server/server.ts +4 -0
  85. package/templates/default/site/site.jsonc +21 -0
  86. package/templates/default/site/styles/tailwind.css +452 -0
  87. package/templates/default/tsconfig.json +23 -0
  88. package/templates/default/vercel.json +7 -0
  89. 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
+ });