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
@@ -0,0 +1,120 @@
1
+ import { z } from "zod";
2
+
3
+ import type { DocComponent, DocComponentContext } from "./types";
4
+
5
+ export interface InstallTabsProps {
6
+ dev?: boolean;
7
+ pkg: string;
8
+ }
9
+
10
+ const schema = z
11
+ .object({
12
+ dev: z.boolean().optional(),
13
+ pkg: z.string().min(1),
14
+ })
15
+ .strict() satisfies z.ZodType<InstallTabsProps>;
16
+
17
+ const escapeHtml = (value: string): string =>
18
+ value
19
+ .replaceAll("&", "&amp;")
20
+ .replaceAll("<", "&lt;")
21
+ .replaceAll(">", "&gt;")
22
+ .replaceAll('"', "&quot;")
23
+ .replaceAll("'", "&#39;");
24
+
25
+ type PackageManager = "npm" | "pnpm" | "bun" | "yarn";
26
+
27
+ const PACKAGE_MANAGERS: readonly PackageManager[] = [
28
+ "npm",
29
+ "pnpm",
30
+ "bun",
31
+ "yarn",
32
+ ] as const;
33
+
34
+ const buildInstallCommand = (options: {
35
+ dev: boolean;
36
+ pkg: string;
37
+ pm: PackageManager;
38
+ }): string => {
39
+ const devFlag = options.dev;
40
+ const { pkg } = options;
41
+
42
+ switch (options.pm) {
43
+ case "npm": {
44
+ return devFlag ? `npm i -D ${pkg}` : `npm i ${pkg}`;
45
+ }
46
+ case "pnpm": {
47
+ return devFlag ? `pnpm add -D ${pkg}` : `pnpm add ${pkg}`;
48
+ }
49
+ case "bun": {
50
+ return devFlag ? `bun add -d ${pkg}` : `bun add ${pkg}`;
51
+ }
52
+ case "yarn": {
53
+ return devFlag ? `yarn add -D ${pkg}` : `yarn add ${pkg}`;
54
+ }
55
+ default: {
56
+ const exhaustiveCheck: never = options.pm;
57
+ throw new Error(`Unknown package manager: ${exhaustiveCheck}`);
58
+ }
59
+ }
60
+ };
61
+
62
+ const buildTabSegmentHtml = (options: {
63
+ code: string;
64
+ defaultPm: PackageManager;
65
+ idPrefix: string;
66
+ pm: PackageManager;
67
+ }): string => {
68
+ const inputId = `${options.idPrefix}-${options.pm}`;
69
+ const checkedAttr = options.pm === options.defaultPm ? " checked" : "";
70
+
71
+ return [
72
+ `<input class="code-tabs__input" type="radio" name="${options.idPrefix}" id="${inputId}"${checkedAttr} />`,
73
+ `<label class="code-tabs__label" for="${inputId}" data-pm="${options.pm}">${options.pm}</label>`,
74
+ '<div class="code-tabs__panel">',
75
+ `<pre><code class="language-bash">${escapeHtml(options.code)}</code></pre>`,
76
+ "</div>",
77
+ ].join("");
78
+ };
79
+
80
+ const renderInstallTabsHtml = (
81
+ props: InstallTabsProps,
82
+ ctx: DocComponentContext
83
+ ): string => {
84
+ const dev = props.dev ?? false;
85
+ const pkg = props.pkg.trim();
86
+
87
+ const idPrefix = `install-tabs-${ctx.instanceId}`;
88
+
89
+ const defaultPm: PackageManager = "npm";
90
+
91
+ const segments = PACKAGE_MANAGERS.map((pm) => {
92
+ const code = buildInstallCommand({ dev, pkg, pm });
93
+ return buildTabSegmentHtml({ code, defaultPm, idPrefix, pm });
94
+ }).join("");
95
+
96
+ return [
97
+ '<div class="code-tabs" data-code-tabs="install" data-doc-component="InstallTabs">',
98
+ segments,
99
+ "</div>",
100
+ ].join("");
101
+ };
102
+
103
+ const renderInstallTabsMarkdown = (props: InstallTabsProps): string => {
104
+ const dev = props.dev ?? false;
105
+ const pkg = props.pkg.trim();
106
+
107
+ const blocks = PACKAGE_MANAGERS.map((pm) => {
108
+ const command = buildInstallCommand({ dev, pkg, pm });
109
+ return [`### ${pm}`, "", "```sh", command, "```", ""].join("\n");
110
+ });
111
+
112
+ return blocks.join("\n");
113
+ };
114
+
115
+ export const InstallTabs: DocComponent<InstallTabsProps> = {
116
+ name: "InstallTabs",
117
+ renderHtml: renderInstallTabsHtml,
118
+ renderMarkdown: (props) => renderInstallTabsMarkdown(props),
119
+ schema,
120
+ };
@@ -0,0 +1,12 @@
1
+ import type { DocComponent } from "./types";
2
+
3
+ import { InstallTabs } from "./install-tabs";
4
+
5
+ type AnyDocComponent = DocComponent<unknown>;
6
+
7
+ const COMPONENTS: Record<string, AnyDocComponent> = {
8
+ InstallTabs: InstallTabs as unknown as AnyDocComponent,
9
+ };
10
+
11
+ export const getDocComponent = (name: string): AnyDocComponent | undefined =>
12
+ COMPONENTS[name];
@@ -0,0 +1,21 @@
1
+ import type { ZodType } from "zod";
2
+
3
+ export interface DocComponentContext {
4
+ currentPath: string;
5
+ instanceId: string;
6
+ isDev: boolean;
7
+ slug: string;
8
+ }
9
+
10
+ export interface DocComponent<Props> {
11
+ name: string;
12
+ schema: ZodType<Props>;
13
+ renderHtml: (
14
+ props: Props,
15
+ ctx: DocComponentContext
16
+ ) => string | Promise<string>;
17
+ renderMarkdown: (
18
+ props: Props,
19
+ ctx: DocComponentContext
20
+ ) => string | Promise<string>;
21
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Frontmatter parsing utility
3
+ * Extracts YAML frontmatter from markdown files since Bun.markdown.html()
4
+ * doesn't handle it natively (renders it as HTML instead of stripping it).
5
+ */
6
+
7
+ export interface PageMeta {
8
+ title?: string;
9
+ description?: string;
10
+ icon?: string;
11
+ group?: string;
12
+ order?: number;
13
+ hidden?: boolean;
14
+ }
15
+
16
+ export interface ParsedMarkdown {
17
+ frontmatter: PageMeta;
18
+ content: string;
19
+ }
20
+
21
+ /**
22
+ * Parse YAML frontmatter from markdown content.
23
+ * Frontmatter must be at the very start of the file, delimited by `---`.
24
+ *
25
+ * @example
26
+ * ```markdown
27
+ * ---
28
+ * title: My Page
29
+ * icon: home
30
+ * group: main
31
+ * order: 1
32
+ * ---
33
+ * # Content here
34
+ * ```
35
+ */
36
+ const getFrontmatterBlock = (
37
+ trimmed: string
38
+ ): { yamlBlock: string; content: string } | null => {
39
+ if (!trimmed.startsWith("---")) {
40
+ return null;
41
+ }
42
+
43
+ const endIndex = trimmed.indexOf("---", 3);
44
+ if (endIndex === -1) {
45
+ return null;
46
+ }
47
+
48
+ return {
49
+ content: trimmed.slice(endIndex + 3).trimStart(),
50
+ yamlBlock: trimmed.slice(3, endIndex).trim(),
51
+ };
52
+ };
53
+
54
+ const parseYamlFrontmatter = (yamlBlock: string): PageMeta => {
55
+ try {
56
+ const parsed = Bun.YAML.parse(yamlBlock);
57
+ if (parsed && typeof parsed === "object") {
58
+ return parsed as PageMeta;
59
+ }
60
+ } catch (error) {
61
+ console.warn("Failed to parse frontmatter YAML:", error);
62
+ }
63
+
64
+ return {};
65
+ };
66
+
67
+ export const parseFrontmatter = (markdown: string): ParsedMarkdown => {
68
+ const trimmed = markdown.trimStart();
69
+ const block = getFrontmatterBlock(trimmed);
70
+
71
+ if (!block) {
72
+ return {
73
+ content: markdown,
74
+ frontmatter: {},
75
+ };
76
+ }
77
+
78
+ return {
79
+ content: block.content,
80
+ frontmatter: parseYamlFrontmatter(block.yamlBlock),
81
+ };
82
+ };
83
+
84
+ /**
85
+ * Extract title from markdown content (first h1 heading).
86
+ * Used as fallback when frontmatter doesn't specify a title.
87
+ */
88
+ export const extractTitleFromContent = (markdown: string): string | undefined =>
89
+ markdown.match(/^#\s+(.+)$/m)?.[1];
@@ -0,0 +1,78 @@
1
+ import { getProjectPaths } from "../project/paths";
2
+
3
+ const FALLBACK_ICON_NAME = "file";
4
+ const FALLBACK_ICON_SVG = `<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>`;
5
+
6
+ const iconFileCache = new Map<string, Promise<string | undefined>>();
7
+
8
+ const readSvgFile = async (path: string): Promise<string | undefined> => {
9
+ const file = Bun.file(path);
10
+ if (!(await file.exists())) {
11
+ return undefined;
12
+ }
13
+ return file.text();
14
+ };
15
+
16
+ const getCachedSvgFile = (path: string): Promise<string | undefined> => {
17
+ const cached = iconFileCache.get(path);
18
+ if (cached) {
19
+ return cached;
20
+ }
21
+
22
+ const pending = readSvgFile(path);
23
+ iconFileCache.set(path, pending);
24
+ return pending;
25
+ };
26
+
27
+ const getNamedIconPath = async (name: string): Promise<string> => {
28
+ const { iconsDir } = await getProjectPaths();
29
+ return `${iconsDir}/${name}.svg`;
30
+ };
31
+
32
+ const loadCustomIcon = async (
33
+ slug: string,
34
+ iconPath: string
35
+ ): Promise<string | undefined> => {
36
+ const { contentDir } = await getProjectPaths();
37
+ const resolvedPath = `${contentDir}/${slug}/${iconPath.slice(2)}`;
38
+ const svg = await getCachedSvgFile(resolvedPath);
39
+ if (!svg) {
40
+ console.warn(`Custom icon not found: ${resolvedPath}`);
41
+ }
42
+ return svg;
43
+ };
44
+
45
+ const getFallbackIcon = async (): Promise<string> => {
46
+ const svg = await getCachedSvgFile(
47
+ await getNamedIconPath(FALLBACK_ICON_NAME)
48
+ );
49
+ return svg ?? FALLBACK_ICON_SVG;
50
+ };
51
+
52
+ /**
53
+ * Resolve an icon specification to SVG content.
54
+ * - "./icon.svg" -> loads from content/<slug>/icon.svg
55
+ * - "home" -> loads from icons/home.svg
56
+ * - Falls back to icons/file.svg (or built-in fallback SVG)
57
+ */
58
+ export const resolveIconSvg = async (
59
+ slug: string,
60
+ icon: string | undefined
61
+ ): Promise<string> => {
62
+ if (!icon) {
63
+ return getFallbackIcon();
64
+ }
65
+
66
+ if (icon.startsWith("./")) {
67
+ const customSvg = await loadCustomIcon(slug, icon);
68
+ return customSvg ?? (await getFallbackIcon());
69
+ }
70
+
71
+ const namedSvg = await getCachedSvgFile(await getNamedIconPath(icon));
72
+ if (namedSvg) {
73
+ return namedSvg;
74
+ }
75
+
76
+ console.warn(`Icon "${icon}" not found in icons/ folder for ${slug}`);
77
+ return getFallbackIcon();
78
+ };
@@ -0,0 +1,93 @@
1
+ import type { SiteConfig } from "../site/config";
2
+
3
+ import { loadSiteConfig } from "../site/config";
4
+ import { parseFrontmatter } from "./frontmatter";
5
+ import { derivePageMetaFromParsed } from "./meta";
6
+ import {
7
+ getContentDir,
8
+ pageSlugFromContentSlug,
9
+ scanContentFiles,
10
+ slugFromContentFile,
11
+ } from "./paths";
12
+
13
+ interface LlmsPage {
14
+ description: string;
15
+ slug: string;
16
+ title: string;
17
+ }
18
+
19
+ const sortPages = (pages: LlmsPage[]): LlmsPage[] =>
20
+ pages.toSorted((a, b) => {
21
+ if (a.slug === "") {
22
+ return -1;
23
+ }
24
+ if (b.slug === "") {
25
+ return 1;
26
+ }
27
+ return a.title.localeCompare(b.title);
28
+ });
29
+
30
+ const buildLlmsPage = async (
31
+ file: string,
32
+ contentDir: string,
33
+ siteConfig: SiteConfig
34
+ ): Promise<LlmsPage | null> => {
35
+ const markdown = await Bun.file(`${contentDir}/${file}`).text();
36
+ const parsed = parseFrontmatter(markdown);
37
+
38
+ if (parsed.frontmatter.hidden) {
39
+ return null;
40
+ }
41
+
42
+ const slug = slugFromContentFile(file);
43
+ const meta = derivePageMetaFromParsed(parsed, {
44
+ fallbackTitle: slug,
45
+ siteDefaultDescription: siteConfig.description,
46
+ });
47
+
48
+ return {
49
+ description: meta.description,
50
+ slug: pageSlugFromContentSlug(slug),
51
+ title: meta.title,
52
+ };
53
+ };
54
+
55
+ const buildLlmsPages = async (siteConfig: SiteConfig): Promise<LlmsPage[]> => {
56
+ const pages: LlmsPage[] = [];
57
+ const contentDir = await getContentDir();
58
+
59
+ for await (const file of scanContentFiles()) {
60
+ const page = await buildLlmsPage(file, contentDir, siteConfig);
61
+ if (page) {
62
+ pages.push(page);
63
+ }
64
+ }
65
+
66
+ return pages;
67
+ };
68
+
69
+ const formatLlmsTxt = (siteConfig: SiteConfig, pages: LlmsPage[]): string => {
70
+ const lines = [
71
+ `# ${siteConfig.name}`,
72
+ "",
73
+ `> ${siteConfig.description}`,
74
+ "",
75
+ "## Pages",
76
+ "",
77
+ ];
78
+
79
+ for (const page of pages) {
80
+ const mdFile = page.slug === "" ? "index.md" : `${page.slug}.md`;
81
+ lines.push(`- [${page.title}](/${mdFile}): ${page.description}`);
82
+ }
83
+
84
+ return `${lines.join("\n")}\n`;
85
+ };
86
+
87
+ export const generateLlmsTxt = async (): Promise<string> => {
88
+ const siteConfig = await loadSiteConfig();
89
+ const pages = await buildLlmsPages(siteConfig);
90
+ const sortedPages = sortPages(pages);
91
+
92
+ return formatLlmsTxt(siteConfig, sortedPages);
93
+ };
@@ -0,0 +1,92 @@
1
+ import type { ParsedMarkdown } from "./frontmatter";
2
+
3
+ import { extractTitleFromContent } from "./frontmatter";
4
+
5
+ const stripInlineMarkdown = (text: string): string =>
6
+ text
7
+ // Images: ![alt](url) -> alt
8
+ .replaceAll(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
9
+ // Links: [text](url) -> text
10
+ .replaceAll(/\[([^\]]+)\]\([^)]+\)/g, "$1")
11
+ // Inline code: `code` -> code
12
+ .replaceAll(/`([^`]+)`/g, "$1")
13
+ // Emphasis markers
14
+ .replaceAll(/[*_~]+/g, "")
15
+ .trim();
16
+
17
+ const extractDescriptionFromContent = (content: string): string | undefined => {
18
+ const lines = content.split("\n");
19
+ const titleIndex = lines.findIndex((line) => /^#\s+/.test(line));
20
+
21
+ const startIndex = titleIndex === -1 ? 0 : titleIndex + 1;
22
+ const firstMeaningfulLine = lines
23
+ .slice(startIndex)
24
+ .find(
25
+ (line) => line.trim().length > 0 && !line.trimStart().startsWith("#")
26
+ );
27
+
28
+ const cleaned = firstMeaningfulLine
29
+ ? stripInlineMarkdown(firstMeaningfulLine)
30
+ : undefined;
31
+
32
+ return cleaned && cleaned.length > 0 ? cleaned : undefined;
33
+ };
34
+
35
+ export interface DeriveTitleOptions {
36
+ fallbackTitle: string;
37
+ titleOverride?: string;
38
+ }
39
+
40
+ export const deriveTitleFromParsed = (
41
+ parsed: ParsedMarkdown,
42
+ options: DeriveTitleOptions
43
+ ): string => {
44
+ const override = options.titleOverride?.trim();
45
+ if (override) {
46
+ return override;
47
+ }
48
+
49
+ return (
50
+ parsed.frontmatter.title ??
51
+ extractTitleFromContent(parsed.content) ??
52
+ options.fallbackTitle
53
+ );
54
+ };
55
+
56
+ export interface DeriveDescriptionOptions {
57
+ siteDefaultDescription: string;
58
+ }
59
+
60
+ export const deriveDescriptionFromParsed = (
61
+ parsed: ParsedMarkdown,
62
+ options: DeriveDescriptionOptions
63
+ ): string => {
64
+ const frontmatterDescription = parsed.frontmatter.description?.trim();
65
+ if (frontmatterDescription) {
66
+ return frontmatterDescription;
67
+ }
68
+
69
+ const extracted = extractDescriptionFromContent(parsed.content);
70
+ if (extracted) {
71
+ return extracted;
72
+ }
73
+
74
+ const fallback = options.siteDefaultDescription.trim();
75
+ return fallback.length > 0 ? fallback : "No description available.";
76
+ };
77
+
78
+ export interface DerivePageMetaOptions
79
+ extends DeriveTitleOptions, DeriveDescriptionOptions {}
80
+
81
+ export const derivePageMetaFromParsed = (
82
+ parsed: ParsedMarkdown,
83
+ options: DerivePageMetaOptions
84
+ ): { title: string; description: string } => ({
85
+ description: deriveDescriptionFromParsed(parsed, {
86
+ siteDefaultDescription: options.siteDefaultDescription,
87
+ }),
88
+ title: deriveTitleFromParsed(parsed, {
89
+ fallbackTitle: options.fallbackTitle,
90
+ titleOverride: options.titleOverride,
91
+ }),
92
+ });
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Navigation discovery utility
3
+ * Scans content folders and builds navigation structure from frontmatter.
4
+ */
5
+
6
+ import type { GroupConfig } from "../site/config";
7
+ import type { PageMeta } from "./frontmatter";
8
+
9
+ import { loadSiteConfig } from "../site/config";
10
+ import { parseFrontmatter, extractTitleFromContent } from "./frontmatter";
11
+ import { resolveIconSvg } from "./icons";
12
+ import { getContentDir, scanContentFiles, slugFromContentFile } from "./paths";
13
+
14
+ export interface NavItem {
15
+ title: string;
16
+ href: string;
17
+ // SVG content for the icon
18
+ iconSvg: string;
19
+ order: number;
20
+ }
21
+
22
+ export interface NavGroup {
23
+ id: string;
24
+ label: string;
25
+ order: number;
26
+ items: NavItem[];
27
+ }
28
+
29
+ const buildGroupsMap = (groups: GroupConfig[]): Map<string, NavGroup> => {
30
+ const groupsMap = new Map<string, NavGroup>();
31
+ for (const group of groups) {
32
+ groupsMap.set(group.id, {
33
+ id: group.id,
34
+ items: [],
35
+ label: group.label,
36
+ order: group.order,
37
+ });
38
+ }
39
+ return groupsMap;
40
+ };
41
+
42
+ const createDefaultGroup = (): NavGroup => ({
43
+ id: "_default",
44
+ items: [],
45
+ label: "Pages",
46
+ order: 999,
47
+ });
48
+
49
+ const createGroupFromId = (groupId: string): NavGroup => ({
50
+ id: groupId,
51
+ items: [],
52
+ label: groupId.charAt(0).toUpperCase() + groupId.slice(1),
53
+ order: 100,
54
+ });
55
+
56
+ const buildNavItem = async (
57
+ slug: string,
58
+ frontmatter: PageMeta,
59
+ content: string
60
+ ): Promise<NavItem> => {
61
+ const title = frontmatter.title ?? extractTitleFromContent(content) ?? slug;
62
+ const href = slug === "index" ? "/" : `/${slug}/`;
63
+ const iconSvg = await resolveIconSvg(slug, frontmatter.icon);
64
+
65
+ return {
66
+ href,
67
+ iconSvg,
68
+ order: frontmatter.order ?? 100,
69
+ title,
70
+ };
71
+ };
72
+
73
+ const addNavItemToGroups = (
74
+ groupsMap: Map<string, NavGroup>,
75
+ defaultGroup: NavGroup,
76
+ navItem: NavItem,
77
+ groupId: string | undefined
78
+ ): void => {
79
+ if (!groupId) {
80
+ defaultGroup.items.push(navItem);
81
+ return;
82
+ }
83
+
84
+ const existingGroup = groupsMap.get(groupId);
85
+ if (existingGroup) {
86
+ existingGroup.items.push(navItem);
87
+ return;
88
+ }
89
+
90
+ const createdGroup = createGroupFromId(groupId);
91
+ createdGroup.items.push(navItem);
92
+ groupsMap.set(groupId, createdGroup);
93
+ };
94
+
95
+ const addFileToNavigation = async (
96
+ file: string,
97
+ groupsMap: Map<string, NavGroup>,
98
+ defaultGroup: NavGroup,
99
+ contentDir: string
100
+ ): Promise<void> => {
101
+ const slug = slugFromContentFile(file);
102
+ const markdown = await Bun.file(`${contentDir}/${file}`).text();
103
+ const { frontmatter, content } = parseFrontmatter(markdown);
104
+
105
+ if (frontmatter.hidden) {
106
+ return;
107
+ }
108
+
109
+ const navItem = await buildNavItem(slug, frontmatter, content);
110
+ addNavItemToGroups(groupsMap, defaultGroup, navItem, frontmatter.group);
111
+ };
112
+
113
+ const finalizeGroups = (
114
+ groupsMap: Map<string, NavGroup>,
115
+ defaultGroup: NavGroup
116
+ ): NavGroup[] => {
117
+ if (defaultGroup.items.length > 0) {
118
+ groupsMap.set(defaultGroup.id, defaultGroup);
119
+ }
120
+
121
+ const groups = [...groupsMap.values()]
122
+ .filter((group) => group.items.length > 0)
123
+ .toSorted((a, b) => a.order - b.order);
124
+
125
+ for (const group of groups) {
126
+ group.items.sort((a, b) => a.order - b.order);
127
+ }
128
+
129
+ return groups;
130
+ };
131
+
132
+ /**
133
+ * Discover all pages and build navigation structure.
134
+ * Reads frontmatter from each content file to determine:
135
+ * - Title (falls back to h1)
136
+ * - Icon (loaded from icons/ folder or custom path)
137
+ * - Group (for sidebar sections)
138
+ * - Order (sort order within group)
139
+ */
140
+ export const discoverNavigation = async (): Promise<NavGroup[]> => {
141
+ const siteConfig = await loadSiteConfig();
142
+ const contentDir = await getContentDir();
143
+
144
+ // Map of group ID -> NavGroup
145
+ const groupsMap = buildGroupsMap(siteConfig.groups ?? []);
146
+ const defaultGroup = createDefaultGroup();
147
+
148
+ // Scan all content files
149
+ for await (const file of scanContentFiles()) {
150
+ await addFileToNavigation(file, groupsMap, defaultGroup, contentDir);
151
+ }
152
+
153
+ return finalizeGroups(groupsMap, defaultGroup);
154
+ };
@@ -0,0 +1,34 @@
1
+ import { getProjectPaths } from "../project/paths";
2
+
3
+ const flatContentGlob = new Bun.Glob("*.md");
4
+
5
+ export const getContentDir = async (): Promise<string> => {
6
+ const paths = await getProjectPaths();
7
+ return paths.contentDir;
8
+ };
9
+
10
+ export const getMarkdownFilePath = async (slug: string): Promise<string> =>
11
+ `${await getContentDir()}/${slug}.md`;
12
+
13
+ export const slugFromContentFile = (file: string): string => {
14
+ if (file.endsWith(".md")) {
15
+ return file.slice(0, -".md".length);
16
+ }
17
+
18
+ return file;
19
+ };
20
+
21
+ export const scanContentFiles =
22
+ async function* scanContentFiles(): AsyncGenerator<string> {
23
+ const { contentDir } = await getProjectPaths();
24
+ // `content/<slug>.md`
25
+ for await (const file of flatContentGlob.scan(contentDir)) {
26
+ yield file;
27
+ }
28
+ };
29
+
30
+ export const pageSlugFromContentSlug = (slug: string): string =>
31
+ slug === "index" ? "" : slug;
32
+
33
+ export const pagePathFromContentSlug = (slug: string): string =>
34
+ slug === "index" ? "/" : `/${slug}/`;
@@ -0,0 +1,10 @@
1
+ import { getMarkdownFilePath } from "./paths";
2
+
3
+ export const getMarkdownFile = async (slug: string): Promise<string | null> => {
4
+ const filePath = await getMarkdownFilePath(slug);
5
+ const file = Bun.file(filePath);
6
+ if (await file.exists()) {
7
+ return file.text();
8
+ }
9
+ return null;
10
+ };