idcmd 0.0.1 → 0.0.3

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 +53 -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 +60 -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 +93 -0
  39. package/src/content/meta.ts +92 -0
  40. package/src/content/navigation.ts +154 -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 +339 -0
  46. package/src/render/markdown.ts +14 -0
  47. package/src/render/page-renderer.ts +320 -0
  48. package/src/render/right-rail.tsx +249 -0
  49. package/src/render/toc.ts +66 -0
  50. package/src/search/api.ts +75 -0
  51. package/src/search/contract.ts +44 -0
  52. package/src/search/index.ts +264 -0
  53. package/src/search/page.tsx +96 -0
  54. package/src/search/server-page.ts +97 -0
  55. package/src/seo/files.ts +124 -0
  56. package/src/seo/server.ts +102 -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 +212 -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
package/src/server.ts ADDED
@@ -0,0 +1,234 @@
1
+ import type { Server } from "bun";
2
+
3
+ import { expandMarkdownForAgent } from "./content/components/expand";
4
+ import { generateLlmsTxt } from "./content/llms";
5
+ import { getMarkdownFile } from "./content/store";
6
+ import { getProjectPaths } from "./project/paths";
7
+ import { renderMarkdownPage } from "./render/page-renderer";
8
+ import { handleSearchRequest } from "./search/api";
9
+ import { handleSearchPageRequest } from "./search/server-page";
10
+ import { handleRobotsTxt, handleSitemapXml } from "./seo/server";
11
+ import {
12
+ createHtmlCacheHeaders,
13
+ createStaticCacheHeaders,
14
+ } from "./server/headers";
15
+ import { createLiveReload } from "./server/live-reload";
16
+ import { serveStaticFile } from "./server/static";
17
+ import { handleUserRouteRequest } from "./server/user-routes";
18
+ import {
19
+ getRedirectForCanonicalHtmlPath,
20
+ isFileLikePathname,
21
+ toCanonicalHtmlPathname,
22
+ } from "./site/url-policy";
23
+
24
+ type ServerInstance = Server<undefined>;
25
+
26
+ const project = await getProjectPaths();
27
+ const PUBLIC_DIR = project.publicDir;
28
+ const DIST_DIR = project.distDir;
29
+ const isDev = process.env.NODE_ENV !== "production";
30
+ const LIVE_RELOAD_POLL_MS = 250;
31
+ const MIN_SEARCH_QUERY_LENGTH = 2;
32
+ const MAX_SEARCH_RESULTS = 50;
33
+
34
+ const cacheHeaders = createHtmlCacheHeaders(isDev);
35
+ const staticCacheHeaders = createStaticCacheHeaders(isDev);
36
+
37
+ const withQueryString = (pathname: string, search: string): string =>
38
+ search ? `${pathname}${search}` : pathname;
39
+
40
+ const createRedirectResponse = (pathname: string, url: URL): Response =>
41
+ new Response(null, {
42
+ headers: {
43
+ Location: withQueryString(pathname, url.search),
44
+ ...(isDev ? { "Cache-Control": "no-cache" } : {}),
45
+ },
46
+ status: 308,
47
+ });
48
+
49
+ const liveReload = createLiveReload({
50
+ isDev,
51
+ pollMs: LIVE_RELOAD_POLL_MS,
52
+ websocketPath: `${project.assetPrefix}/live-reload`,
53
+ });
54
+
55
+ if (isDev) {
56
+ try {
57
+ await liveReload.startWatcher();
58
+ } catch (error) {
59
+ console.warn("[live-reload] Watcher failed", error);
60
+ }
61
+ }
62
+
63
+ const handleLlmsTxt = async (path: string): Promise<Response | undefined> => {
64
+ if (path !== "/llms.txt") {
65
+ return undefined;
66
+ }
67
+
68
+ const llmsTxt = await generateLlmsTxt();
69
+ return new Response(llmsTxt, {
70
+ headers: {
71
+ "Content-Type": "text/plain; charset=utf-8",
72
+ ...staticCacheHeaders,
73
+ },
74
+ });
75
+ };
76
+
77
+ const handleMarkdownRequest = async (
78
+ path: string
79
+ ): Promise<Response | undefined> => {
80
+ if (!path.endsWith(".md")) {
81
+ return undefined;
82
+ }
83
+
84
+ const isRaw = path.endsWith(".raw.md");
85
+ const slug = isRaw ? path.slice(1, -".raw.md".length) : path.slice(1, -3);
86
+ const markdown = await getMarkdownFile(slug);
87
+
88
+ if (!markdown) {
89
+ return new Response("Not Found", {
90
+ headers: { "Content-Type": "text/plain" },
91
+ status: 404,
92
+ });
93
+ }
94
+
95
+ const expanded = isRaw
96
+ ? markdown
97
+ : await expandMarkdownForAgent(markdown, {
98
+ currentPath: slug === "index" ? "/" : `/${slug}/`,
99
+ instanceId: `${slug}:md`,
100
+ isDev,
101
+ slug,
102
+ });
103
+
104
+ return new Response(expanded, {
105
+ headers: {
106
+ "Content-Type": "text/markdown; charset=utf-8",
107
+ ...staticCacheHeaders,
108
+ },
109
+ status: 200,
110
+ });
111
+ };
112
+
113
+ const createNotFoundResponse = async (url: URL): Promise<Response> => {
114
+ const notFoundMarkdown = await getMarkdownFile("404");
115
+ if (notFoundMarkdown) {
116
+ const canonicalPathname = toCanonicalHtmlPathname(url.pathname);
117
+ const html = await renderMarkdownPage(notFoundMarkdown, {
118
+ currentPath: canonicalPathname,
119
+ isDev,
120
+ requestOrigin: url.origin,
121
+ searchQuery: url.searchParams.get("q") ?? undefined,
122
+ });
123
+ return new Response(html, {
124
+ headers: cacheHeaders,
125
+ status: 404,
126
+ });
127
+ }
128
+
129
+ return new Response("Not Found", {
130
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
131
+ status: 404,
132
+ });
133
+ };
134
+
135
+ const handlePageRequest = async (url: URL): Promise<Response> => {
136
+ const canonicalPathname = toCanonicalHtmlPathname(url.pathname);
137
+ const slug =
138
+ canonicalPathname === "/" ? "index" : canonicalPathname.slice(1, -1);
139
+ const markdown = await getMarkdownFile(slug);
140
+
141
+ if (!markdown) {
142
+ return createNotFoundResponse(url);
143
+ }
144
+
145
+ const html = await renderMarkdownPage(markdown, {
146
+ currentPath: canonicalPathname,
147
+ isDev,
148
+ requestOrigin: url.origin,
149
+ });
150
+
151
+ return new Response(html, { headers: cacheHeaders, status: 200 });
152
+ };
153
+
154
+ const maybeHandleCanonicalRedirect = (url: URL): Response | undefined => {
155
+ const { pathname } = url;
156
+
157
+ if (
158
+ pathname === "/__live-reload" ||
159
+ pathname === `${project.assetPrefix}/live-reload` ||
160
+ pathname.startsWith("/api/")
161
+ ) {
162
+ return undefined;
163
+ }
164
+
165
+ if (isFileLikePathname(pathname)) {
166
+ return undefined;
167
+ }
168
+
169
+ const redirectPathname = getRedirectForCanonicalHtmlPath(pathname);
170
+ if (!redirectPathname) {
171
+ return undefined;
172
+ }
173
+
174
+ return createRedirectResponse(redirectPathname, url);
175
+ };
176
+
177
+ const handleRequest = async (
178
+ req: Request,
179
+ server: ServerInstance
180
+ ): Promise<Response | undefined> => {
181
+ const url = new URL(req.url);
182
+ const path = url.pathname;
183
+
184
+ const liveReloadUpgrade = liveReload.maybeHandleUpgrade(req, server, path);
185
+ if (liveReloadUpgrade === "handled") {
186
+ return undefined;
187
+ }
188
+
189
+ const seoEnv = { distDir: DIST_DIR, isDev, staticCacheHeaders };
190
+ const searchPageEnv = {
191
+ cacheHeaders,
192
+ isDev,
193
+ maxResults: MAX_SEARCH_RESULTS,
194
+ minQueryLength: MIN_SEARCH_QUERY_LENGTH,
195
+ };
196
+ const staticEnv = {
197
+ distDir: DIST_DIR,
198
+ isDev,
199
+ publicDir: PUBLIC_DIR,
200
+ staticCacheHeaders,
201
+ };
202
+ const userRoutesEnv = { isDev, routesDir: project.routesDir };
203
+
204
+ return (
205
+ liveReloadUpgrade ??
206
+ (await handleLlmsTxt(path)) ??
207
+ (await handleRobotsTxt(url, seoEnv)) ??
208
+ (await handleSitemapXml(url, seoEnv)) ??
209
+ (await handleSearchRequest(url)) ??
210
+ (await handleUserRouteRequest(url, req, userRoutesEnv)) ??
211
+ (await serveStaticFile(path, staticEnv)) ??
212
+ (await handleMarkdownRequest(path)) ??
213
+ (await (maybeHandleCanonicalRedirect(url) ??
214
+ handleSearchPageRequest(url, searchPageEnv))) ??
215
+ (await handlePageRequest(url))
216
+ );
217
+ };
218
+
219
+ const server = Bun.serve({
220
+ fetch(req, serverInstance) {
221
+ return handleRequest(req, serverInstance);
222
+ },
223
+
224
+ port: process.env.PORT || 4000,
225
+
226
+ websocket: liveReload.websocket,
227
+ });
228
+
229
+ console.log(`Server running at http://localhost:${server.port}`);
230
+ if (isDev) {
231
+ console.log(
232
+ "Live reload enabled - editing .md files will refresh the browser"
233
+ );
234
+ }
@@ -0,0 +1,244 @@
1
+ import { ZodError, z } from "zod";
2
+
3
+ export const SearchScopeSchema = z.enum([
4
+ "full",
5
+ "title",
6
+ "title_and_description",
7
+ ]);
8
+ export type SearchScope = z.infer<typeof SearchScopeSchema>;
9
+
10
+ export const RightRailVisibleFromSchema = z.enum([
11
+ "xl",
12
+ "lg",
13
+ "md",
14
+ "always",
15
+ "never",
16
+ ]);
17
+ export type RightRailVisibleFrom = z.infer<typeof RightRailVisibleFromSchema>;
18
+
19
+ export const ScrollSpyUpdateHashSchema = z.enum(["never", "replace", "push"]);
20
+ export type ScrollSpyUpdateHash = z.infer<typeof ScrollSpyUpdateHashSchema>;
21
+
22
+ export const TocLevelSchema = z.union([
23
+ z.literal(1),
24
+ z.literal(2),
25
+ z.literal(3),
26
+ z.literal(4),
27
+ z.literal(5),
28
+ z.literal(6),
29
+ ]);
30
+ export type TocLevel = z.infer<typeof TocLevelSchema>;
31
+
32
+ export const RightRailScrollSpyConfigSchema = z
33
+ .object({
34
+ centerActiveItem: z.boolean().optional(),
35
+ enabled: z.boolean().optional(),
36
+ updateHash: ScrollSpyUpdateHashSchema.optional(),
37
+ })
38
+ .strict();
39
+ export type RightRailScrollSpyConfig = z.infer<
40
+ typeof RightRailScrollSpyConfigSchema
41
+ >;
42
+
43
+ export const RightRailConfigSchema = z
44
+ .object({
45
+ enabled: z.boolean().optional(),
46
+ placement: z.enum(["content", "viewport"]).optional(),
47
+ scrollSpy: RightRailScrollSpyConfigSchema.optional(),
48
+ smoothScroll: z.boolean().optional(),
49
+ tocLevels: z.array(TocLevelSchema).optional(),
50
+ visibleFrom: RightRailVisibleFromSchema.optional(),
51
+ })
52
+ .strict();
53
+ export type RightRailConfig = z.infer<typeof RightRailConfigSchema>;
54
+
55
+ export const GroupConfigSchema = z
56
+ .object({
57
+ id: z.string().min(1),
58
+ label: z.string().min(1),
59
+ order: z.number().int(),
60
+ })
61
+ .strict();
62
+ export type GroupConfig = z.infer<typeof GroupConfigSchema>;
63
+
64
+ export const SiteConfigSchema = z
65
+ .object({
66
+ baseUrl: z.string().url().optional(),
67
+ description: z.string(),
68
+ groups: z.array(GroupConfigSchema).optional(),
69
+ name: z.string().min(1),
70
+ rightRail: RightRailConfigSchema.optional(),
71
+ search: z
72
+ .object({
73
+ scope: SearchScopeSchema.optional(),
74
+ })
75
+ .strict()
76
+ .optional(),
77
+ })
78
+ .strict();
79
+ export type SiteConfig = z.infer<typeof SiteConfigSchema>;
80
+
81
+ export interface ResolvedRightRailConfig {
82
+ enabled: boolean;
83
+ visibleFrom: RightRailVisibleFrom;
84
+ placement: "content" | "viewport";
85
+ tocLevels: readonly TocLevel[];
86
+ smoothScroll: boolean;
87
+ scrollSpy: {
88
+ enabled: boolean;
89
+ centerActiveItem: boolean;
90
+ updateHash: ScrollSpyUpdateHash;
91
+ };
92
+ }
93
+
94
+ const LEGACY_SITE_CONFIG_PATH = "site.jsonc";
95
+ const NEW_SITE_CONFIG_PATH = "site/site.jsonc";
96
+ const LOCALHOST_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]);
97
+
98
+ const DEFAULT_RIGHT_RAIL_CONFIG: ResolvedRightRailConfig = {
99
+ enabled: true,
100
+ placement: "content",
101
+ scrollSpy: {
102
+ centerActiveItem: true,
103
+ enabled: true,
104
+ updateHash: "never",
105
+ },
106
+ smoothScroll: false,
107
+ tocLevels: [2, 3],
108
+ visibleFrom: "xl",
109
+ } as const;
110
+
111
+ export const resolveRightRailConfig = (
112
+ config?: RightRailConfig
113
+ ): ResolvedRightRailConfig => ({
114
+ enabled: config?.enabled ?? DEFAULT_RIGHT_RAIL_CONFIG.enabled,
115
+ placement: config?.placement ?? DEFAULT_RIGHT_RAIL_CONFIG.placement,
116
+ scrollSpy: {
117
+ centerActiveItem:
118
+ config?.scrollSpy?.centerActiveItem ??
119
+ DEFAULT_RIGHT_RAIL_CONFIG.scrollSpy.centerActiveItem,
120
+ enabled:
121
+ config?.scrollSpy?.enabled ?? DEFAULT_RIGHT_RAIL_CONFIG.scrollSpy.enabled,
122
+ updateHash:
123
+ config?.scrollSpy?.updateHash ??
124
+ DEFAULT_RIGHT_RAIL_CONFIG.scrollSpy.updateHash,
125
+ },
126
+ smoothScroll: config?.smoothScroll ?? DEFAULT_RIGHT_RAIL_CONFIG.smoothScroll,
127
+ tocLevels: [...(config?.tocLevels ?? DEFAULT_RIGHT_RAIL_CONFIG.tocLevels)],
128
+ visibleFrom: config?.visibleFrom ?? DEFAULT_RIGHT_RAIL_CONFIG.visibleFrom,
129
+ });
130
+
131
+ const isLocalhostBaseUrl = (baseUrl: string): boolean => {
132
+ try {
133
+ const url = new URL(baseUrl);
134
+ return LOCALHOST_HOSTNAMES.has(url.hostname);
135
+ } catch {
136
+ return false;
137
+ }
138
+ };
139
+
140
+ const normalizeBaseUrl = (baseUrl: string): string | undefined => {
141
+ try {
142
+ const url = new URL(baseUrl);
143
+ // Canonicalize to origin (strip path/query/hash).
144
+ return url.origin;
145
+ } catch {
146
+ return undefined;
147
+ }
148
+ };
149
+
150
+ const resolveBaseUrlFromEnv = (): string | undefined => {
151
+ const explicit = process.env.SITE_BASE_URL;
152
+ if (explicit) {
153
+ return normalizeBaseUrl(explicit);
154
+ }
155
+
156
+ // Vercel provides hostnames without protocol.
157
+ const vercelUrl =
158
+ process.env.VERCEL_URL ??
159
+ process.env.VERCEL_PROJECT_PRODUCTION_URL ??
160
+ process.env.VERCEL_BRANCH_URL;
161
+
162
+ if (vercelUrl) {
163
+ return normalizeBaseUrl(`https://${vercelUrl}`);
164
+ }
165
+
166
+ return undefined;
167
+ };
168
+
169
+ const resolveBaseUrl = (baseUrl: string | undefined): string | undefined => {
170
+ const normalizedConfigUrl = baseUrl ? normalizeBaseUrl(baseUrl) : undefined;
171
+ const envUrl = resolveBaseUrlFromEnv();
172
+
173
+ // If config is set to localhost (common for dev), prefer env-derived URL when available.
174
+ if (normalizedConfigUrl && !isLocalhostBaseUrl(normalizedConfigUrl)) {
175
+ return normalizedConfigUrl;
176
+ }
177
+
178
+ return envUrl ?? normalizedConfigUrl;
179
+ };
180
+
181
+ const formatZodIssuePath = (path: readonly (string | number)[]): string =>
182
+ path.length === 0 ? "(root)" : path.join(".");
183
+
184
+ const formatSiteConfigZodError = (
185
+ configPath: string,
186
+ error: ZodError
187
+ ): string => {
188
+ const lines = error.issues.map(
189
+ (issue) => `${formatZodIssuePath(issue.path)}: ${issue.message}`
190
+ );
191
+ return `${configPath} validation failed:\n${lines.join("\n")}`;
192
+ };
193
+
194
+ const DEFAULT_SITE_CONFIG: SiteConfig = { description: "", name: "idcmd" };
195
+
196
+ const parseSiteConfigJsonc = (configPath: string, text: string): unknown => {
197
+ try {
198
+ return Bun.JSONC.parse(text) as unknown;
199
+ } catch (error) {
200
+ const message = error instanceof Error ? error.message : String(error);
201
+ throw new Error(`Failed to parse ${configPath} as JSONC: ${message}`, {
202
+ cause: error,
203
+ });
204
+ }
205
+ };
206
+
207
+ const parseSiteConfigUnknown = (
208
+ configPath: string,
209
+ raw: unknown
210
+ ): SiteConfig => {
211
+ try {
212
+ return SiteConfigSchema.parse(raw);
213
+ } catch (error) {
214
+ if (error instanceof ZodError) {
215
+ throw new TypeError(formatSiteConfigZodError(configPath, error), {
216
+ cause: error,
217
+ });
218
+ }
219
+ throw error;
220
+ }
221
+ };
222
+
223
+ const resolveSiteConfigPath = async (): Promise<string> => {
224
+ if (await Bun.file(NEW_SITE_CONFIG_PATH).exists()) {
225
+ return NEW_SITE_CONFIG_PATH;
226
+ }
227
+ return LEGACY_SITE_CONFIG_PATH;
228
+ };
229
+
230
+ export const loadSiteConfig = async (): Promise<SiteConfig> => {
231
+ const configPath = await resolveSiteConfigPath();
232
+ const file = Bun.file(configPath);
233
+ if (!(await file.exists())) {
234
+ return DEFAULT_SITE_CONFIG;
235
+ }
236
+
237
+ const text = await file.text();
238
+ const raw = parseSiteConfigJsonc(configPath, text);
239
+ const parsed = parseSiteConfigUnknown(configPath, raw);
240
+ return { ...parsed, baseUrl: resolveBaseUrl(parsed.baseUrl) };
241
+ };
242
+
243
+ export const getSearchScope = (siteConfig: SiteConfig): SearchScope =>
244
+ siteConfig.search?.scope ?? "full";
@@ -0,0 +1,60 @@
1
+ const ensureLeadingSlash = (pathname: string): string =>
2
+ pathname.startsWith("/") ? pathname : `/${pathname}`;
3
+
4
+ const collapseSlashes = (pathname: string): string =>
5
+ pathname.replaceAll(/\/{2,}/g, "/");
6
+
7
+ const trimTrailingSlash = (pathname: string): string =>
8
+ pathname !== "/" && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
9
+
10
+ const lastSegment = (pathname: string): string => {
11
+ const trimmed = trimTrailingSlash(pathname);
12
+ const index = trimmed.lastIndexOf("/");
13
+ return index === -1 ? trimmed : trimmed.slice(index + 1);
14
+ };
15
+
16
+ export const isFileLikePathname = (pathname: string): boolean => {
17
+ const normalized = collapseSlashes(ensureLeadingSlash(pathname));
18
+ const segment = lastSegment(normalized);
19
+
20
+ // Treat any last-path segment with an extension as file-like.
21
+ // Examples: /styles.css, /robots.txt, /index.md
22
+ const dotIndex = segment.lastIndexOf(".");
23
+ return dotIndex > 0 && dotIndex < segment.length - 1;
24
+ };
25
+
26
+ const stripIndexSegment = (pathname: string): string => {
27
+ if (pathname === "/index") {
28
+ return "/";
29
+ }
30
+ if (pathname.startsWith("/index/")) {
31
+ return `/${pathname.slice("/index/".length)}`;
32
+ }
33
+ return pathname;
34
+ };
35
+
36
+ export const toCanonicalHtmlPathname = (pathname: string): string => {
37
+ const normalized = collapseSlashes(ensureLeadingSlash(pathname));
38
+ if (isFileLikePathname(normalized)) {
39
+ return normalized;
40
+ }
41
+
42
+ const withoutIndex = stripIndexSegment(trimTrailingSlash(normalized));
43
+ if (withoutIndex === "/") {
44
+ return "/";
45
+ }
46
+
47
+ return withoutIndex.endsWith("/") ? withoutIndex : `${withoutIndex}/`;
48
+ };
49
+
50
+ export const getRedirectForCanonicalHtmlPath = (
51
+ pathname: string
52
+ ): string | null => {
53
+ const normalized = collapseSlashes(ensureLeadingSlash(pathname));
54
+ if (isFileLikePathname(normalized)) {
55
+ return null;
56
+ }
57
+
58
+ const canonical = toCanonicalHtmlPathname(normalized);
59
+ return canonical === normalized ? null : canonical;
60
+ };
@@ -0,0 +1,46 @@
1
+ export const resolveAbsoluteUrl = (
2
+ baseUrl: string | undefined,
3
+ pathname: string
4
+ ): string | undefined => {
5
+ if (!baseUrl) {
6
+ return undefined;
7
+ }
8
+
9
+ // `new URL()` handles joining and normalization.
10
+ return new URL(pathname, baseUrl).toString();
11
+ };
12
+
13
+ export interface CanonicalUrlContext {
14
+ /**
15
+ * If true, prefer the request origin for canonicals so localhost doesn't emit
16
+ * production URLs during development.
17
+ */
18
+ isDev: boolean;
19
+ /**
20
+ * Origin of the current request (ex: "http://localhost:4000").
21
+ * Optional so build-time callers can omit it.
22
+ */
23
+ requestOrigin?: string;
24
+ /**
25
+ * Configured canonical base URL (typically `siteConfig.baseUrl`).
26
+ */
27
+ configuredBaseUrl?: string;
28
+ }
29
+
30
+ export const resolveCanonicalBaseUrl = (
31
+ context: CanonicalUrlContext
32
+ ): string | undefined => {
33
+ const { configuredBaseUrl, isDev, requestOrigin } = context;
34
+
35
+ if (isDev) {
36
+ return requestOrigin;
37
+ }
38
+
39
+ return configuredBaseUrl ?? requestOrigin;
40
+ };
41
+
42
+ export const resolveCanonicalUrl = (
43
+ context: CanonicalUrlContext,
44
+ pathname: string
45
+ ): string | undefined =>
46
+ resolveAbsoluteUrl(resolveCanonicalBaseUrl(context), pathname);
@@ -0,0 +1,26 @@
1
+ # **IDCMD_SITE_NAME**
2
+
3
+ Everything you edit lives in `site/`.
4
+
5
+ ## Quickstart
6
+
7
+ ```bash
8
+ bun install
9
+ bun run dev
10
+ ```
11
+
12
+ ## Layout
13
+
14
+ - `site/content/` markdown pages (`index.md` -> `/`, `about.md` -> `/about/`)
15
+ - `site/styles/tailwind.css` Tailwind entrypoint (compiled to `site/public/styles.css`)
16
+ - `site/public/` static assets
17
+ - `site/server/routes/` file-based server routes (dev/server-host only)
18
+ - `site/site.jsonc` site configuration
19
+
20
+ ## Deploy (Vercel static)
21
+
22
+ ```bash
23
+ bun run build
24
+ ```
25
+
26
+ This produces a static `dist/` directory for Vercel.
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "__IDCMD_PACKAGE_NAME__",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "idcmd dev --port __IDCMD_DEV_PORT__",
7
+ "build": "idcmd build",
8
+ "preview": "idcmd preview",
9
+ "deploy": "idcmd deploy",
10
+ "check": "ultracite check && bun run typecheck && bun run test",
11
+ "test": "bun test",
12
+ "typecheck": "tsc --noEmit -p tsconfig.json",
13
+ "fix": "ultracite fix"
14
+ },
15
+ "dependencies": {
16
+ "idcmd": "__IDCMD_IDCMD_VERSION__"
17
+ },
18
+ "devDependencies": {
19
+ "@tailwindcss/cli": "^4.1.18",
20
+ "@types/bun": "latest",
21
+ "@typescript/native-preview": "latest",
22
+ "lefthook": "^2.1.0",
23
+ "oxfmt": "^0.28.0",
24
+ "oxlint": "^1.43.0",
25
+ "tailwindcss": "^4.1.18",
26
+ "typescript": "^5",
27
+ "ultracite": "7.1.4"
28
+ }
29
+ }
@@ -0,0 +1,2 @@
1
+ export { renderLayout } from "idcmd/client";
2
+ export type { LayoutProps } from "idcmd/client";
@@ -0,0 +1 @@
1
+ export { RightRail } from "idcmd/client";
@@ -0,0 +1 @@
1
+ export { renderSearchPageContent } from "idcmd/client";
@@ -0,0 +1,8 @@
1
+ ---
2
+ title: Not Found
3
+ hidden: true
4
+ ---
5
+
6
+ # Not Found
7
+
8
+ The page you are looking for does not exist.
@@ -0,0 +1,10 @@
1
+ ---
2
+ title: About
3
+ group: main
4
+ order: 2
5
+ icon: info
6
+ ---
7
+
8
+ # About
9
+
10
+ This site is generated by `idcmd`.
@@ -0,0 +1,10 @@
1
+ ---
2
+ title: Home
3
+ group: main
4
+ order: 1
5
+ icon: home
6
+ ---
7
+
8
+ # **IDCMD_SITE_NAME**
9
+
10
+ Edit markdown in `site/content/`.
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>