@tayacrystals/lore 0.1.2 → 1.0.0
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/README.md +7 -0
- package/package.json +48 -36
- package/src/build.ts +148 -0
- package/src/config.ts +26 -0
- package/src/dev.ts +112 -0
- package/src/files.ts +193 -0
- package/src/i18n.ts +49 -0
- package/src/icons.ts +59 -0
- package/src/index.ts +28 -0
- package/src/mdx.ts +281 -0
- package/src/parse.ts +53 -0
- package/src/routing.ts +46 -0
- package/src/serve.ts +72 -0
- package/src/template.ts +747 -0
- package/src/types.ts +51 -0
- package/src/version.ts +33 -0
- package/components/docs/Breadcrumbs.astro +0 -41
- package/components/docs/PrevNext.astro +0 -50
- package/components/docs/Sidebar.astro +0 -28
- package/components/docs/SidebarGroup.astro +0 -55
- package/components/docs/SidebarItem.astro +0 -26
- package/components/docs/TableOfContents.astro +0 -82
- package/components/global/SearchModal.astro +0 -159
- package/components/mdx/Accordion.astro +0 -20
- package/components/mdx/Callout.astro +0 -53
- package/components/mdx/Card.astro +0 -26
- package/components/mdx/CardGrid.astro +0 -16
- package/components/mdx/CodeTabs.astro +0 -129
- package/components/mdx/FileTree.astro +0 -117
- package/components/mdx/Step.astro +0 -18
- package/components/mdx/Steps.astro +0 -6
- package/components/mdx/Tab.astro +0 -11
- package/components/mdx/Tabs.astro +0 -73
- package/components.ts +0 -11
- package/config.ts +0 -42
- package/index.ts +0 -2
- package/integration.ts +0 -68
- package/layouts/DocsLayout.astro +0 -277
- package/loaders.ts +0 -5
- package/routes/docs.astro +0 -201
- package/schema.ts +0 -13
- package/styles/global.css +0 -78
- package/styles/prose.css +0 -148
- package/utils/navigation.ts +0 -32
- package/utils/rehype-file-tree.ts +0 -229
- package/utils/sidebar.ts +0 -97
- package/utils/toc.ts +0 -28
- package/virtual.d.ts +0 -9
- package/vite-plugin.ts +0 -28
package/src/icons.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import * as lucide from "lucide";
|
|
2
|
+
|
|
3
|
+
type IconData = [string, Record<string, string>][];
|
|
4
|
+
|
|
5
|
+
function iconDataToSvg(data: IconData): string {
|
|
6
|
+
const children = data
|
|
7
|
+
.map(([tag, attrs]) => {
|
|
8
|
+
const attrStr = Object.entries(attrs)
|
|
9
|
+
.map(([k, v]) => `${k}="${v}"`)
|
|
10
|
+
.join(" ");
|
|
11
|
+
return `<${tag} ${attrStr}/>`;
|
|
12
|
+
})
|
|
13
|
+
.join("");
|
|
14
|
+
return `<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">${children}</svg>`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** kebab-case → PascalCase, e.g. "message-circle" → "MessageCircle" */
|
|
18
|
+
function toPascalCase(name: string): string {
|
|
19
|
+
return name
|
|
20
|
+
.split("-")
|
|
21
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
22
|
+
.join("");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Render a named Lucide icon (kebab-case) to an SVG string, or null if not found. */
|
|
26
|
+
export function lucideIcon(name: string): string | null {
|
|
27
|
+
const key = toPascalCase(name);
|
|
28
|
+
const data = (lucide as Record<string, unknown>)[key];
|
|
29
|
+
if (!Array.isArray(data)) return null;
|
|
30
|
+
return iconDataToSvg(data as IconData);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const fallback = () => lucideIcon("external-link") ?? "";
|
|
34
|
+
|
|
35
|
+
// Brand icons not in Lucide
|
|
36
|
+
const DISCORD_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/></svg>`;
|
|
37
|
+
|
|
38
|
+
/** Pick an icon SVG for a URL based on its domain. */
|
|
39
|
+
export function serviceIcon(url: string): string {
|
|
40
|
+
if (url.includes("github.com")) return lucideIcon("github") ?? fallback();
|
|
41
|
+
if (url.includes("npmjs.com")) return lucideIcon("package") ?? fallback();
|
|
42
|
+
if (url.includes("twitter.com") || url.includes("x.com")) return lucideIcon("twitter") ?? fallback();
|
|
43
|
+
if (url.includes("discord.com") || url.includes("discord.gg")) return DISCORD_SVG;
|
|
44
|
+
if (url.includes("slack.com")) return lucideIcon("slack") ?? fallback();
|
|
45
|
+
if (url.includes("youtube.com") || url.includes("youtu.be")) return lucideIcon("youtube") ?? fallback();
|
|
46
|
+
if (url.includes("linkedin.com")) return lucideIcon("linkedin") ?? fallback();
|
|
47
|
+
return fallback();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Get icon SVG for a link config entry. */
|
|
51
|
+
export function linkIcon(link: string | { url: string; icon: string }): string {
|
|
52
|
+
if (typeof link === "string") return serviceIcon(link);
|
|
53
|
+
return lucideIcon(link.icon) ?? serviceIcon(link.url);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Get the href for a link config entry. */
|
|
57
|
+
export function linkHref(link: string | { url: string; icon: string }): string {
|
|
58
|
+
return typeof link === "string" ? link : link.url;
|
|
59
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { build } from "./build.ts";
|
|
3
|
+
import { dev } from "./dev.ts";
|
|
4
|
+
import { serve } from "./serve.ts";
|
|
5
|
+
|
|
6
|
+
// Parse: lore [build|dev|serve] [dir]
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
|
|
9
|
+
let command: "build" | "dev" | "serve" = "build";
|
|
10
|
+
let dirArg: string | undefined;
|
|
11
|
+
|
|
12
|
+
if (args[0] === "dev" || args[0] === "build" || args[0] === "serve") {
|
|
13
|
+
command = args[0] as "build" | "dev" | "serve";
|
|
14
|
+
dirArg = args[1];
|
|
15
|
+
} else {
|
|
16
|
+
dirArg = args[0];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const docsDir = dirArg ? path.resolve(dirArg) : process.cwd();
|
|
20
|
+
const outDir = path.join(process.cwd(), "build");
|
|
21
|
+
|
|
22
|
+
if (command === "dev") {
|
|
23
|
+
await dev(docsDir, outDir);
|
|
24
|
+
} else if (command === "serve") {
|
|
25
|
+
await serve(docsDir, outDir);
|
|
26
|
+
} else {
|
|
27
|
+
await build(docsDir, outDir);
|
|
28
|
+
}
|
package/src/mdx.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { unified } from "unified";
|
|
2
|
+
import remarkParse from "remark-parse";
|
|
3
|
+
import remarkGfm from "remark-gfm";
|
|
4
|
+
import remarkRehype from "remark-rehype";
|
|
5
|
+
import rehypeSlug from "rehype-slug";
|
|
6
|
+
import rehypeAutolinkHeadings from "rehype-autolink-headings";
|
|
7
|
+
import rehypeStringify from "rehype-stringify";
|
|
8
|
+
import { codeToHtml } from "shiki";
|
|
9
|
+
import { lucideIcon } from "./icons.ts";
|
|
10
|
+
import { buildUrl } from "./routing.ts";
|
|
11
|
+
|
|
12
|
+
function esc(s: string): string {
|
|
13
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function addLinkPrefix(html: string, locale?: string, version?: string): string {
|
|
17
|
+
if (!locale && !version) return html;
|
|
18
|
+
|
|
19
|
+
const prefix = buildUrl("", { locale, version });
|
|
20
|
+
|
|
21
|
+
return html.replace(
|
|
22
|
+
/(href|src)=(["'])([^"']+)["']/g,
|
|
23
|
+
(match, attr, quote, url) => {
|
|
24
|
+
const trimmed = url.trim();
|
|
25
|
+
|
|
26
|
+
if (trimmed.startsWith("/") && !trimmed.startsWith("//")) {
|
|
27
|
+
return `${attr}=${quote}${prefix}${trimmed}${quote}`;
|
|
28
|
+
}
|
|
29
|
+
if (!trimmed.startsWith("/") &&
|
|
30
|
+
!trimmed.startsWith("http://") &&
|
|
31
|
+
!trimmed.startsWith("https://") &&
|
|
32
|
+
!trimmed.startsWith("mailto:") &&
|
|
33
|
+
!trimmed.startsWith("tel:") &&
|
|
34
|
+
!trimmed.startsWith("#")) {
|
|
35
|
+
const cleanUrl = trimmed.replace(/^\.?\//, "");
|
|
36
|
+
return `${attr}=${quote}${prefix}/${cleanUrl}${quote}`;
|
|
37
|
+
}
|
|
38
|
+
return match;
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseAttrs(attrStr: string): Record<string, string> {
|
|
44
|
+
const attrs: Record<string, string> = {};
|
|
45
|
+
for (const m of attrStr.matchAll(/(\w[\w-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g)) {
|
|
46
|
+
attrs[m[1]!] = m[2] ?? m[3] ?? m[4] ?? "true";
|
|
47
|
+
}
|
|
48
|
+
return attrs;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Find all non-self-nesting occurrences of <TagName attrs>inner</TagName>. */
|
|
52
|
+
function findAll(content: string, tag: string): { full: string; attrs: string; inner: string }[] {
|
|
53
|
+
const re = new RegExp(`<${tag}((?:\\s+[^>]*)?)>([\\s\\S]*?)<\\/${tag}>`, "g");
|
|
54
|
+
return [...content.matchAll(re)].map((m) => ({ full: m[0], attrs: m[1]?.trim() ?? "", inner: m[2]! }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Code blocks ─────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
const LANG_ALIASES: Record<string, string> = {
|
|
60
|
+
sh: "bash", shell: "bash", zsh: "bash",
|
|
61
|
+
js: "javascript", ts: "typescript",
|
|
62
|
+
mdx: "markdown",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
async function renderCode(lang: string, meta: string, code: string): Promise<string> {
|
|
66
|
+
const attrs = parseAttrs(meta);
|
|
67
|
+
const title = attrs["title"];
|
|
68
|
+
const isTerminal = attrs["frame"] === "terminal";
|
|
69
|
+
const noFrame = attrs["frame"] === "none";
|
|
70
|
+
const wrap = "wrap" in attrs;
|
|
71
|
+
|
|
72
|
+
const normalLang = LANG_ALIASES[lang] ?? lang;
|
|
73
|
+
let pre: string;
|
|
74
|
+
if (normalLang) {
|
|
75
|
+
try {
|
|
76
|
+
pre = await codeToHtml(code, {
|
|
77
|
+
lang: normalLang,
|
|
78
|
+
themes: { light: "github-light", dark: "github-dark" },
|
|
79
|
+
defaultColor: false,
|
|
80
|
+
});
|
|
81
|
+
} catch {
|
|
82
|
+
pre = `<pre><code>${esc(code)}</code></pre>`;
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
pre = `<pre><code>${esc(code)}</code></pre>`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (wrap) pre = pre.replace("<pre ", `<pre style="white-space:pre-wrap" `);
|
|
89
|
+
|
|
90
|
+
const hasFrame = (isTerminal || title) && !noFrame;
|
|
91
|
+
if (!hasFrame) return `<div class="code-block">${pre}</div>`;
|
|
92
|
+
|
|
93
|
+
const frameKind = isTerminal ? "terminal" : "editor";
|
|
94
|
+
const titleBar = `<div class="code-frame code-frame-${frameKind}">${title ? `<span>${esc(title)}</span>` : ""}</div>`;
|
|
95
|
+
return `<div class="code-block">${titleBar}${pre}</div>`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Callouts ─────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
const CALLOUT_ICONS: Record<string, string> = {
|
|
101
|
+
note: lucideIcon("info") ?? "",
|
|
102
|
+
tip: lucideIcon("lightbulb") ?? "",
|
|
103
|
+
warning: lucideIcon("triangle-alert") ?? "",
|
|
104
|
+
danger: lucideIcon("circle-x") ?? "",
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
async function processCallouts(
|
|
108
|
+
content: string,
|
|
109
|
+
codeBlocks: Map<string, { lang: string; meta: string; code: string }>,
|
|
110
|
+
): Promise<string> {
|
|
111
|
+
for (const { full, attrs, inner } of findAll(content, "Callout")) {
|
|
112
|
+
const type = parseAttrs(attrs)["type"] ?? "note";
|
|
113
|
+
const innerHtml = await renderInner(inner.trim(), codeBlocks);
|
|
114
|
+
const icon = CALLOUT_ICONS[type] ?? CALLOUT_ICONS["note"]!;
|
|
115
|
+
content = content.replace(full,
|
|
116
|
+
`<div class="callout callout-${esc(type)}"><span class="callout-icon">${icon}</span><div class="callout-body">${innerHtml}</div></div>`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return content;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── File tree ─────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
type TreeNode = { name: string; isDir: boolean; children: TreeNode[] };
|
|
125
|
+
|
|
126
|
+
function parseTree(lines: string[]): TreeNode[] {
|
|
127
|
+
const root: TreeNode[] = [];
|
|
128
|
+
const stack: { nodes: TreeNode[]; indent: number }[] = [{ nodes: root, indent: -1 }];
|
|
129
|
+
for (const line of lines) {
|
|
130
|
+
if (!line.trim()) continue;
|
|
131
|
+
const indent = line.length - line.trimStart().length;
|
|
132
|
+
const name = line.trim();
|
|
133
|
+
const isDir = name.endsWith("/");
|
|
134
|
+
while (stack.length > 1 && stack[stack.length - 1]!.indent >= indent) stack.pop();
|
|
135
|
+
const node: TreeNode = { name, isDir, children: [] };
|
|
136
|
+
stack[stack.length - 1]!.nodes.push(node);
|
|
137
|
+
if (isDir) stack.push({ nodes: node.children, indent });
|
|
138
|
+
}
|
|
139
|
+
return root;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const FOLDER_ICON = lucideIcon("folder") ?? "📁";
|
|
143
|
+
const FILE_ICON = lucideIcon("file") ?? "📄";
|
|
144
|
+
|
|
145
|
+
function renderTree(nodes: TreeNode[]): string {
|
|
146
|
+
return nodes.map((n) => {
|
|
147
|
+
const icon = `<span class="tree-icon${n.isDir ? " tree-dir-icon" : ""}">${n.isDir ? FOLDER_ICON : FILE_ICON}</span>`;
|
|
148
|
+
const kids = n.isDir && n.children.length > 0 ? `<ul>${renderTree(n.children)}</ul>` : "";
|
|
149
|
+
return `<li class="${n.isDir ? "tree-dir" : "tree-file"}"><div class="tree-row">${icon}<span>${esc(n.name)}</span></div>${kids}</li>`;
|
|
150
|
+
}).join("");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function processFileTrees(content: string): string {
|
|
154
|
+
for (const { full, inner } of findAll(content, "FileTree")) {
|
|
155
|
+
const tree = parseTree(inner.split("\n"));
|
|
156
|
+
content = content.replace(full, `<div class="file-tree"><ul>${renderTree(tree)}</ul></div>`);
|
|
157
|
+
}
|
|
158
|
+
return content;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Steps ─────────────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
async function processSteps(
|
|
164
|
+
content: string,
|
|
165
|
+
codeBlocks: Map<string, { lang: string; meta: string; code: string }>,
|
|
166
|
+
): Promise<string> {
|
|
167
|
+
for (const { full, inner } of findAll(content, "Steps")) {
|
|
168
|
+
const lines = inner.split("\n");
|
|
169
|
+
const steps: { title: string; body: string[] }[] = [];
|
|
170
|
+
for (const line of lines) {
|
|
171
|
+
const m = line.match(/^\s*#{1,6}\s+(.+)$/);
|
|
172
|
+
if (m) {
|
|
173
|
+
steps.push({ title: m[1]!, body: [] });
|
|
174
|
+
} else if (steps.length > 0) {
|
|
175
|
+
steps[steps.length - 1]!.body.push(line);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const items = await Promise.all(steps.map(async (s, i) => {
|
|
179
|
+
const bodyHtml = s.body.length ? await renderInner(s.body.join("\n").trim(), codeBlocks) : "";
|
|
180
|
+
return `<div class="step"><div class="step-num">${i + 1}</div><div class="step-body"><div class="step-title">${esc(s.title)}</div>${bodyHtml}</div></div>`;
|
|
181
|
+
}));
|
|
182
|
+
content = content.replace(full, `<div class="steps">${items.join("")}</div>`);
|
|
183
|
+
}
|
|
184
|
+
return content;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
async function processTabs(
|
|
190
|
+
content: string,
|
|
191
|
+
codeBlocks: Map<string, { lang: string; meta: string; code: string }>,
|
|
192
|
+
): Promise<string> {
|
|
193
|
+
for (const { full, attrs, inner } of findAll(content, "Tabs")) {
|
|
194
|
+
const group = parseAttrs(attrs)["group"];
|
|
195
|
+
const tabs: { label: string; html: string }[] = [];
|
|
196
|
+
for (const { attrs: ta, inner: ti } of findAll(inner, "Tab")) {
|
|
197
|
+
const label = parseAttrs(ta)["label"] ?? "Tab";
|
|
198
|
+
tabs.push({ label, html: await renderInner(ti.trim(), codeBlocks) });
|
|
199
|
+
}
|
|
200
|
+
const groupAttr = group ? ` data-group="${esc(group)}"` : "";
|
|
201
|
+
const btns = tabs.map((t, i) =>
|
|
202
|
+
`<button class="tab-btn${i === 0 ? " active" : ""}" data-index="${i}">${esc(t.label)}</button>`
|
|
203
|
+
).join("");
|
|
204
|
+
const panels = tabs.map((t, i) =>
|
|
205
|
+
`<div class="tab-panel${i === 0 ? " active" : ""}" data-index="${i}">${t.html}</div>`
|
|
206
|
+
).join("");
|
|
207
|
+
content = content.replace(full,
|
|
208
|
+
`<div class="tabs"${groupAttr}><div class="tab-list">${btns}</div><div class="tab-panels">${panels}</div></div>`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return content;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Main renderer ─────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
const processor = unified()
|
|
217
|
+
.use(remarkParse)
|
|
218
|
+
.use(remarkGfm)
|
|
219
|
+
.use(remarkRehype, { allowDangerousHtml: true })
|
|
220
|
+
.use(rehypeSlug)
|
|
221
|
+
.use(rehypeAutolinkHeadings, {
|
|
222
|
+
behavior: "append",
|
|
223
|
+
content: { type: "text", value: "#" },
|
|
224
|
+
properties: { class: "heading-anchor", ariaHidden: "true", tabIndex: -1 },
|
|
225
|
+
})
|
|
226
|
+
.use(rehypeStringify, { allowDangerousHtml: true });
|
|
227
|
+
|
|
228
|
+
async function renderInner(
|
|
229
|
+
content: string,
|
|
230
|
+
codeBlocks: Map<string, { lang: string; meta: string; code: string }>,
|
|
231
|
+
): Promise<string> {
|
|
232
|
+
let html = String(await processor.process(content));
|
|
233
|
+
for (const [id, entry] of codeBlocks) {
|
|
234
|
+
const codeHtml = await renderCode(entry.lang, entry.meta, entry.code);
|
|
235
|
+
html = html.replace(new RegExp(`<p>\\s*${id}\\s*</p>`), codeHtml);
|
|
236
|
+
html = html.replace(id, codeHtml);
|
|
237
|
+
}
|
|
238
|
+
return html;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function renderMdx(
|
|
242
|
+
content: string,
|
|
243
|
+
opts: { locale?: string; version?: string } = {}
|
|
244
|
+
): Promise<string> {
|
|
245
|
+
const { locale, version } = opts;
|
|
246
|
+
|
|
247
|
+
// 1. Extract fenced code blocks and inline code spans so component regexes
|
|
248
|
+
// don't accidentally match tags inside them.
|
|
249
|
+
const codeBlocks = new Map<string, { lang: string; meta: string; code: string }>();
|
|
250
|
+
const inlineSpans = new Map<string, string>();
|
|
251
|
+
let ci = 0;
|
|
252
|
+
// Fenced code blocks
|
|
253
|
+
content = content.replace(/^([ \t]*)(`{3,})(\w*)(.*)\n([\s\S]*?)\n\1\2[ \t]*$/gm, (_, indent, _fence, lang, meta, code) => {
|
|
254
|
+
const id = `LORECODE${ci++}`;
|
|
255
|
+
const dedented = indent ? code.replace(new RegExp(`^${indent}`, "gm"), "") : code;
|
|
256
|
+
codeBlocks.set(id, { lang, meta: meta.trim(), code: dedented });
|
|
257
|
+
return `\n${id}\n`;
|
|
258
|
+
});
|
|
259
|
+
// Inline code spans (single or multi backtick)
|
|
260
|
+
let ii = 0;
|
|
261
|
+
content = content.replace(/(`+)([^`]|(?!`\1)[^])*?\1/g, (m) => {
|
|
262
|
+
const id = `LOREINLINE${ii++}`;
|
|
263
|
+
inlineSpans.set(id, m);
|
|
264
|
+
return id;
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// 2. Process components
|
|
268
|
+
content = await processCallouts(content, codeBlocks);
|
|
269
|
+
content = processFileTrees(content);
|
|
270
|
+
content = await processSteps(content, codeBlocks);
|
|
271
|
+
content = await processTabs(content, codeBlocks);
|
|
272
|
+
|
|
273
|
+
// 3. Restore inline spans, render markdown, restore code blocks
|
|
274
|
+
for (const [id, raw] of inlineSpans) content = content.replace(id, raw);
|
|
275
|
+
let html = await renderInner(content, codeBlocks);
|
|
276
|
+
|
|
277
|
+
// 4. Add locale/version prefix to relative links
|
|
278
|
+
html = addLinkPrefix(html, locale, version);
|
|
279
|
+
|
|
280
|
+
return html;
|
|
281
|
+
}
|
package/src/parse.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import yaml from "js-yaml";
|
|
2
|
+
|
|
3
|
+
export interface ParsedPage {
|
|
4
|
+
frontmatter: Record<string, unknown>;
|
|
5
|
+
content: string;
|
|
6
|
+
h1Title?: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse an MDX file: extract frontmatter and content.
|
|
12
|
+
*/
|
|
13
|
+
export function parsePage(raw: string): ParsedPage {
|
|
14
|
+
let content = raw;
|
|
15
|
+
let frontmatter: Record<string, unknown> = {};
|
|
16
|
+
|
|
17
|
+
// Strip frontmatter
|
|
18
|
+
if (content.startsWith("---")) {
|
|
19
|
+
const end = content.indexOf("\n---", 3);
|
|
20
|
+
if (end !== -1) {
|
|
21
|
+
const fmText = content.slice(3, end).trim();
|
|
22
|
+
if (fmText) {
|
|
23
|
+
frontmatter = (yaml.load(fmText) as Record<string, unknown>) ?? {};
|
|
24
|
+
}
|
|
25
|
+
content = content.slice(end + 4).trimStart();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Extract leading H1 if present
|
|
30
|
+
let h1Title: string | undefined;
|
|
31
|
+
const h1Match = content.match(/^#\s+(.+)$/m);
|
|
32
|
+
if (h1Match && content.trimStart().startsWith("#")) {
|
|
33
|
+
h1Title = h1Match[1]?.trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const description =
|
|
37
|
+
typeof frontmatter["description"] === "string"
|
|
38
|
+
? frontmatter["description"]
|
|
39
|
+
: undefined;
|
|
40
|
+
|
|
41
|
+
return { frontmatter, content, h1Title, description };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Derive a title from a filename (without extension, without numeric prefix).
|
|
46
|
+
* e.g. "01-quick-start" → "Quick Start"
|
|
47
|
+
*/
|
|
48
|
+
export function filenameToTitle(name: string): string {
|
|
49
|
+
return name
|
|
50
|
+
.replace(/^\d+-/, "")
|
|
51
|
+
.replace(/[-_]/g, " ")
|
|
52
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
53
|
+
}
|
package/src/routing.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Config } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export interface RouteContext {
|
|
4
|
+
version?: string;
|
|
5
|
+
locale?: string;
|
|
6
|
+
path: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function parseUrl(url: string, config: Config): RouteContext {
|
|
10
|
+
const parts = url.split("/").filter(Boolean);
|
|
11
|
+
|
|
12
|
+
const ctx: RouteContext = { path: "" };
|
|
13
|
+
|
|
14
|
+
if (config.internationalization && parts.length > 0) {
|
|
15
|
+
const localeMatch = parts[0]!.match(/^[a-z]{2}(-[a-z]{2})?$/);
|
|
16
|
+
if (localeMatch) {
|
|
17
|
+
ctx.locale = parts.shift();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (config.versioning && parts.length > 0) {
|
|
22
|
+
const versionMatch = parts[0]!.match(/^v\d+/);
|
|
23
|
+
if (versionMatch) {
|
|
24
|
+
ctx.version = parts.shift();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
ctx.path = parts.length > 0 ? "/" + parts.join("/") : "/";
|
|
29
|
+
return ctx;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function buildUrl(
|
|
33
|
+
path: string,
|
|
34
|
+
opts: { locale?: string; version?: string }
|
|
35
|
+
): string {
|
|
36
|
+
const parts: string[] = [];
|
|
37
|
+
|
|
38
|
+
if (opts.locale) parts.push(opts.locale);
|
|
39
|
+
if (opts.version) parts.push(opts.version);
|
|
40
|
+
|
|
41
|
+
const cleanPath = path.replace(/^\//, "").replace(/\/$/, "");
|
|
42
|
+
if (cleanPath) parts.push(cleanPath);
|
|
43
|
+
|
|
44
|
+
const result = "/" + parts.join("/");
|
|
45
|
+
return result.replace(/\/+/g, "/");
|
|
46
|
+
}
|
package/src/serve.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { build } from "./build.ts";
|
|
4
|
+
import { loadConfig } from "./config.ts";
|
|
5
|
+
import { getDefaultVersion } from "./version.ts";
|
|
6
|
+
import { getDefaultLocale } from "./i18n.ts";
|
|
7
|
+
|
|
8
|
+
const STATIC_EXTS = new Set([".svg", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".woff", ".woff2"]);
|
|
9
|
+
|
|
10
|
+
export async function serve(docsDir: string, outDir: string, port = 3000): Promise<void> {
|
|
11
|
+
const config = await loadConfig(docsDir);
|
|
12
|
+
await build(docsDir, outDir);
|
|
13
|
+
|
|
14
|
+
Bun.serve({
|
|
15
|
+
port,
|
|
16
|
+
idleTimeout: 0,
|
|
17
|
+
async fetch(req) {
|
|
18
|
+
const url = new URL(req.url);
|
|
19
|
+
|
|
20
|
+
// Redirect from root to default version/locale
|
|
21
|
+
if (url.pathname === "/") {
|
|
22
|
+
const defaultLocale = config.internationalization
|
|
23
|
+
? getDefaultLocale(config)
|
|
24
|
+
: undefined;
|
|
25
|
+
const defaultVersion = config.versioning
|
|
26
|
+
? getDefaultVersion(config)
|
|
27
|
+
: undefined;
|
|
28
|
+
|
|
29
|
+
const redirectPathParts: string[] = [];
|
|
30
|
+
if (defaultLocale) redirectPathParts.push(defaultLocale);
|
|
31
|
+
if (defaultVersion) redirectPathParts.push(defaultVersion);
|
|
32
|
+
|
|
33
|
+
if (redirectPathParts.length > 0) {
|
|
34
|
+
const redirectPath = "/" + redirectPathParts.join("/") + "/";
|
|
35
|
+
return Response.redirect(url.origin + redirectPath, 302);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let filePath = path.join(outDir, url.pathname);
|
|
40
|
+
try {
|
|
41
|
+
const s = await stat(filePath);
|
|
42
|
+
if (s.isDirectory()) filePath = path.join(filePath, "index.html");
|
|
43
|
+
} catch {
|
|
44
|
+
filePath = path.join(outDir, url.pathname, "index.html");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const file = Bun.file(filePath);
|
|
48
|
+
if (!(await file.exists())) return new Response("Not found", { status: 404 });
|
|
49
|
+
|
|
50
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
51
|
+
const headers: Record<string, string> = {};
|
|
52
|
+
|
|
53
|
+
if (STATIC_EXTS.has(ext)) {
|
|
54
|
+
headers["Cache-Control"] = "public, max-age=31536000, immutable";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const type = file.type;
|
|
58
|
+
const acceptsGzip = req.headers.get("Accept-Encoding")?.includes("gzip") ?? false;
|
|
59
|
+
if (acceptsGzip && (type.startsWith("text/") || type.includes("javascript"))) {
|
|
60
|
+
const compressed = Bun.gzipSync(await file.bytes());
|
|
61
|
+
headers["Content-Encoding"] = "gzip";
|
|
62
|
+
headers["Content-Type"] = type;
|
|
63
|
+
headers["Vary"] = "Accept-Encoding";
|
|
64
|
+
return new Response(compressed, { headers });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return new Response(file, { headers });
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
console.log(`\nServing build at http://localhost:${port}`);
|
|
72
|
+
}
|