@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.
Files changed (49) hide show
  1. package/README.md +7 -0
  2. package/package.json +48 -36
  3. package/src/build.ts +148 -0
  4. package/src/config.ts +26 -0
  5. package/src/dev.ts +112 -0
  6. package/src/files.ts +193 -0
  7. package/src/i18n.ts +49 -0
  8. package/src/icons.ts +59 -0
  9. package/src/index.ts +28 -0
  10. package/src/mdx.ts +281 -0
  11. package/src/parse.ts +53 -0
  12. package/src/routing.ts +46 -0
  13. package/src/serve.ts +72 -0
  14. package/src/template.ts +747 -0
  15. package/src/types.ts +51 -0
  16. package/src/version.ts +33 -0
  17. package/components/docs/Breadcrumbs.astro +0 -41
  18. package/components/docs/PrevNext.astro +0 -50
  19. package/components/docs/Sidebar.astro +0 -28
  20. package/components/docs/SidebarGroup.astro +0 -55
  21. package/components/docs/SidebarItem.astro +0 -26
  22. package/components/docs/TableOfContents.astro +0 -82
  23. package/components/global/SearchModal.astro +0 -159
  24. package/components/mdx/Accordion.astro +0 -20
  25. package/components/mdx/Callout.astro +0 -53
  26. package/components/mdx/Card.astro +0 -26
  27. package/components/mdx/CardGrid.astro +0 -16
  28. package/components/mdx/CodeTabs.astro +0 -129
  29. package/components/mdx/FileTree.astro +0 -117
  30. package/components/mdx/Step.astro +0 -18
  31. package/components/mdx/Steps.astro +0 -6
  32. package/components/mdx/Tab.astro +0 -11
  33. package/components/mdx/Tabs.astro +0 -73
  34. package/components.ts +0 -11
  35. package/config.ts +0 -42
  36. package/index.ts +0 -2
  37. package/integration.ts +0 -68
  38. package/layouts/DocsLayout.astro +0 -277
  39. package/loaders.ts +0 -5
  40. package/routes/docs.astro +0 -201
  41. package/schema.ts +0 -13
  42. package/styles/global.css +0 -78
  43. package/styles/prose.css +0 -148
  44. package/utils/navigation.ts +0 -32
  45. package/utils/rehype-file-tree.ts +0 -229
  46. package/utils/sidebar.ts +0 -97
  47. package/utils/toc.ts +0 -28
  48. package/virtual.d.ts +0 -9
  49. 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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
+ }