Highly ambitious ATProtocol AppView service and sdks
at main 384 lines 12 kB view raw
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 "&amp;": "&", 92 "&lt;": "<", 93 "&gt;": ">", 94 "&quot;": '"', 95 "&#39;": "'", 96 "&nbsp;": " ", 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];