Highly ambitious ATProtocol AppView service and sdks
1import type { Route } from "@std/http/unstable-route";
2import { withAuth } from "../../routes/middleware.ts";
3import { renderHTML } from "../../utils/render.tsx";
4import { codeToHtml } from "@shikijs/shiki";
5import { DocsPage } from "./templates/DocsPage.tsx";
6import { DocsIndexPage } from "./templates/DocsIndexPage.tsx";
7import { render } from "preact-render-to-string";
8import { CodeBlock } from "./templates/fragments/CodeBlock.tsx";
9import { marked } from "marked";
10import type { Tokens } from "marked";
11import { markedHighlight } from "marked-highlight";
12
13// Categorized documentation structure
14const DOCS_CATEGORIES = [
15 {
16 category: "Getting Started",
17 docs: [
18 {
19 slug: "intro",
20 title: "Introduction",
21 description: "Overview of Slices platform",
22 },
23 {
24 slug: "getting-started",
25 title: "Quick Start",
26 description: "Learn how to set up and use Slices",
27 },
28 ],
29 },
30 {
31 category: "Core Concepts",
32 docs: [
33 {
34 slug: "concepts",
35 title: "Core Concepts",
36 description: "Understand slices, lexicons, and collections",
37 },
38 ],
39 },
40 {
41 category: "Reference",
42 docs: [
43 {
44 slug: "api-reference",
45 title: "API Reference",
46 description: "Complete endpoint documentation",
47 },
48 {
49 slug: "graphql-api",
50 title: "GraphQL API",
51 description: "Query indexed data with GraphQL",
52 },
53 {
54 slug: "sdk-usage",
55 title: "SDK Usage",
56 description: "Advanced client patterns and examples",
57 },
58 ],
59 },
60 {
61 category: "Extensions",
62 docs: [
63 {
64 slug: "plugins",
65 title: "Plugins",
66 description: "Extensions and tools for development",
67 },
68 ],
69 },
70];
71
72// Flatten for backward compatibility
73const AVAILABLE_DOCS = DOCS_CATEGORIES.flatMap((category) => category.docs);
74
75const DOCS_PATH = Deno.env.get("DOCS_PATH") || "../docs";
76
77async function readMarkdownFile(slug: string): Promise<string | null> {
78 try {
79 const filePath = `${DOCS_PATH}/${slug}.md`;
80 const content = await Deno.readTextFile(filePath);
81 return content;
82 } catch (error) {
83 console.error(`Failed to read ${slug}.md:`, error);
84 return null;
85 }
86}
87
88// Decode HTML entities
89function decodeHtmlEntities(text: string): string {
90 const entities: Record<string, string> = {
91 "&": "&",
92 "<": "<",
93 ">": ">",
94 """: '"',
95 "'": "'",
96 " ": " ",
97 };
98
99 return text.replace(/&[^;]+;/g, (entity) => entities[entity] || entity);
100}
101
102// Extract headers for table of contents
103function extractHeaders(html: string) {
104 const headerRegex = /<h([1-6])[^>]*>(.*?)<\/h[1-6]>/g;
105 const headers: Array<{ level: number; text: string; id: string }> = [];
106
107 let match;
108 while ((match = headerRegex.exec(html)) !== null) {
109 const level = parseInt(match[1]);
110 const rawText = match[2].replace(/<[^>]+>/g, ""); // Strip HTML tags
111 const text = decodeHtmlEntities(rawText); // Decode HTML entities
112 const id = text
113 .toLowerCase()
114 .replace(/[^\w\s-]/g, "") // Remove special characters
115 .replace(/\s+/g, "-") // Replace spaces with hyphens
116 .trim();
117
118 headers.push({ level, text, id });
119 }
120
121 return headers;
122}
123
124// Markdown to HTML converter using marked with custom renderer and Shiki
125async function markdownToHtml(markdown: string): Promise<string> {
126 // Configure marked with highlight extension for code blocks
127 marked.use(
128 markedHighlight({
129 async: true,
130 async highlight(code, lang, info) {
131 // Parse filename from info string (e.g., "javascript filename.js")
132 const parts = (info || "").split(/\s+/);
133 const filename =
134 parts.length > 1 ? parts.slice(1).join(" ") : undefined;
135
136 // Handle Mermaid diagrams - client-side rendering
137 if (lang === "mermaid") {
138 return `
139 <div class="my-8 w-full overflow-x-auto">
140 <div class="mermaid w-full" style="font-size: 16px;">${code}</div>
141 </div>
142 `;
143 }
144
145 const highlightedCode = await codeToHtml(code, {
146 lang: lang || "text",
147 themes: {
148 light: "github-light",
149 dark: "github-dark",
150 },
151 });
152
153 // Generate the complete code block using composition
154 if (filename) {
155 return render(
156 <CodeBlock>
157 <CodeBlock.Header>
158 <span>{filename}</span>
159 <CodeBlock.CopyButton />
160 </CodeBlock.Header>
161 <CodeBlock.Code highlightedCode={highlightedCode} />
162 </CodeBlock>
163 );
164 } else {
165 return render(<CodeBlock.Code highlightedCode={highlightedCode} />);
166 }
167 },
168 langPrefix: "language-",
169 })
170 );
171
172 // Configure marked with custom renderer
173 const renderer = new marked.Renderer();
174
175 // Custom inline code renderer
176 renderer.codespan = function (token: Tokens.Codespan) {
177 return `<code class="bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 px-1.5 py-0.5 rounded text-sm font-mono font-normal">${token.text}</code>`;
178 };
179
180 // Custom header renderer with IDs and styling
181 renderer.heading = function (token: Tokens.Heading) {
182 const text = this.parser.parseInline(token.tokens);
183 const level = token.depth;
184 const id = text
185 .toLowerCase()
186 .replace(/<[^>]+>/g, "") // Strip HTML tags
187 .replace(/[^\w\s-]/g, "") // Remove special characters
188 .replace(/\s+/g, "-") // Replace spaces with hyphens
189 .trim();
190
191 const styles = {
192 1: "text-2xl font-bold text-zinc-900 dark:text-white mt-10 mb-6",
193 2: "text-xl font-bold text-zinc-900 dark:text-white mt-10 mb-4",
194 3: "text-lg font-semibold text-zinc-900 dark:text-white mt-8 mb-4",
195 4: "text-base font-semibold text-zinc-900 dark:text-white mt-6 mb-3",
196 5: "text-sm font-semibold text-zinc-900 dark:text-white mt-4 mb-2",
197 6: "text-xs font-semibold text-zinc-900 dark:text-white mt-4 mb-2",
198 };
199
200 return `<h${level} id="${id}" class="${
201 styles[level as keyof typeof styles]
202 }">${text}</h${level}>`;
203 };
204
205 // Custom link renderer to handle .md links
206 renderer.link = function (token: Tokens.Link) {
207 let href = token.href;
208 const title = token.title;
209 const text = this.parser.parseInline(token.tokens);
210
211 // Convert relative .md links to docs routes
212 if (href.endsWith(".md") && !href.startsWith("http")) {
213 const slug = href.replace(/^\.\//, "").replace(/\.md$/, "");
214 href = `/docs/${slug}`;
215 }
216
217 const titleAttr = title ? ` title="${title}"` : "";
218 return `<a href="${href}"${titleAttr} class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 underline">${text}</a>`;
219 };
220
221 // Custom paragraph renderer
222 renderer.paragraph = function (token: Tokens.Paragraph) {
223 const text = this.parser.parseInline(token.tokens);
224 return `<p class="mb-4 leading-relaxed text-zinc-700 dark:text-zinc-300">${text}</p>`;
225 };
226
227 // Custom list renderer - wrap with classes
228 const originalList = renderer.list.bind(renderer);
229 renderer.list = function(token: Tokens.List) {
230 const html = originalList(token);
231 const type = token.ordered ? 'ol' : 'ul';
232 const classNames = token.ordered
233 ? 'list-decimal list-inside my-4 text-zinc-700 dark:text-zinc-300'
234 : 'list-disc list-inside my-4 text-zinc-700 dark:text-zinc-300';
235
236 return html.replace(new RegExp(`<${type}>`), `<${type} class="${classNames}">`);
237 };
238
239 // Custom list item renderer
240 const originalListitem = renderer.listitem.bind(renderer);
241 renderer.listitem = function(token: Tokens.ListItem) {
242 const html = originalListitem(token);
243 return html.replace('<li>', '<li class="mb-1">');
244 };
245
246 // Custom strong/bold renderer
247 renderer.strong = function (token: Tokens.Strong) {
248 const text = this.parser.parseInline(token.tokens);
249 return `<strong class="font-semibold text-zinc-900 dark:text-white">${text}</strong>`;
250 };
251
252 // Custom emphasis/italic renderer
253 renderer.em = function (token: Tokens.Em) {
254 const text = this.parser.parseInline(token.tokens);
255 return `<em class="italic">${text}</em>`;
256 };
257
258 // Custom code block renderer - return content as-is since marked-highlight handles it
259 renderer.code = function (token: Tokens.Code) {
260 return token.text; // marked-highlight has already processed this
261 };
262
263 // Custom table renderer
264 renderer.table = function (token: Tokens.Table) {
265 const header = token.header
266 .map((cell: Tokens.TableCell, i: number) => {
267 const text = this.parser.parseInline(cell.tokens);
268 const align = token.align[i]
269 ? ` style="text-align: ${token.align[i]}"`
270 : "";
271 return `<th class="px-4 py-2 text-left text-sm font-semibold text-zinc-900 dark:text-white border-b-2 border-zinc-200 dark:border-zinc-700"${align}>${text}</th>`;
272 })
273 .join("");
274
275 const rows = token.rows
276 .map((row: Tokens.TableCell[]) => {
277 const cells = row
278 .map((cell: Tokens.TableCell, i: number) => {
279 const text = this.parser.parseInline(cell.tokens);
280 const align = token.align[i]
281 ? ` style="text-align: ${token.align[i]}"`
282 : "";
283 return `<td class="px-4 py-2 text-sm text-zinc-700 dark:text-zinc-300 border-b border-zinc-100 dark:border-zinc-800"${align}>${text}</td>`;
284 })
285 .join("");
286 return `<tr class="hover:bg-zinc-50 dark:hover:bg-zinc-800/50">${cells}</tr>`;
287 })
288 .join("");
289
290 return `<div class="overflow-x-auto my-6">
291 <table class="min-w-full divide-y divide-zinc-200 dark:divide-zinc-700">
292 <thead class="bg-zinc-50 dark:bg-zinc-800/50">
293 <tr>${header}</tr>
294 </thead>
295 <tbody class="bg-white dark:bg-zinc-900 divide-y divide-zinc-100 dark:divide-zinc-800">
296 ${rows}
297 </tbody>
298 </table>
299 </div>`;
300 };
301
302 // Set options and use the custom renderer
303 marked.setOptions({
304 renderer: renderer,
305 gfm: true,
306 breaks: false, // This helps with multi-line list items
307 pedantic: false,
308 });
309
310 // Parse markdown to HTML using marked (this handles multi-line list items properly)
311 const html = await marked.parse(markdown);
312
313 return html;
314}
315
316async function handleDocsIndex(request: Request): Promise<Response> {
317 const { currentUser } = await withAuth(request);
318 return renderHTML(
319 <DocsIndexPage categories={DOCS_CATEGORIES} currentUser={currentUser} />
320 );
321}
322
323async function handleDocsPage(request: Request): Promise<Response> {
324 const { currentUser } = await withAuth(request);
325 const url = new URL(request.url);
326 const slug = url.pathname.split("/")[2]; // /docs/{slug}
327
328 if (!slug) {
329 // Redirect to docs index
330 return new Response(null, {
331 status: 302,
332 headers: { Location: "/docs" },
333 });
334 }
335
336 // Check if slug is valid
337 const docInfo = AVAILABLE_DOCS.find((doc) => doc.slug === slug);
338 if (!docInfo) {
339 return new Response("Documentation page not found", { status: 404 });
340 }
341
342 // Read markdown content
343 const markdownContent = await readMarkdownFile(slug);
344 if (!markdownContent) {
345 return new Response("Documentation content not found", { status: 404 });
346 }
347
348 // Convert to HTML with Shiki syntax highlighting
349 const htmlContent = await markdownToHtml(markdownContent);
350
351 // Extract headers for table of contents
352 const headers = extractHeaders(htmlContent);
353
354 return renderHTML(
355 <DocsPage
356 title={docInfo.title}
357 content={htmlContent}
358 headers={headers}
359 docs={AVAILABLE_DOCS}
360 categories={DOCS_CATEGORIES}
361 currentSlug={slug}
362 currentUser={currentUser}
363 />
364 );
365}
366
367// ============================================================================
368// ROUTE EXPORTS
369// ============================================================================
370
371export const docsRoutes: Route[] = [
372 // Docs index page
373 {
374 method: "GET",
375 pattern: new URLPattern({ pathname: "/docs" }),
376 handler: handleDocsIndex,
377 },
378 // Individual docs pages
379 {
380 method: "GET",
381 pattern: new URLPattern({ pathname: "/docs/:slug" }),
382 handler: handleDocsPage,
383 },
384];