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.
- package/LICENSE +21 -0
- package/README.md +96 -2
- package/package.json +53 -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 +60 -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 +93 -0
- package/src/content/meta.ts +92 -0
- package/src/content/navigation.ts +154 -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 +339 -0
- package/src/render/markdown.ts +14 -0
- package/src/render/page-renderer.ts +320 -0
- package/src/render/right-rail.tsx +249 -0
- package/src/render/toc.ts +66 -0
- package/src/search/api.ts +75 -0
- package/src/search/contract.ts +44 -0
- package/src/search/index.ts +264 -0
- package/src/search/page.tsx +96 -0
- package/src/search/server-page.ts +97 -0
- package/src/seo/files.ts +124 -0
- package/src/seo/server.ts +102 -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 +212 -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,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("&", "&")
|
|
20
|
+
.replaceAll("<", "<")
|
|
21
|
+
.replaceAll(">", ">")
|
|
22
|
+
.replaceAll('"', """)
|
|
23
|
+
.replaceAll("'", "'");
|
|
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
|
|
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
|
+
};
|