mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(docs): in-app docs viewer at /docs
Renders every file in docs/*.md as a proper documentation site styled like Stripe / Vercel / Linear docs: sticky header, grouped left sidebar (Setup / Deploy / Operate), dark prose with inline code pills, syntax -ready code blocks, responsive table styling, and prev/next pagination. - src/lib/docs/content.ts — parses md with marked and decorates the HTML with Tailwind utility classes via post-processing regex. Also rewrites inter-doc `./other.md` links to `/docs/other` and extracts an h2/h3 TOC for future use. - src/app/docs/layout.tsx + src/components/docs/shell.tsx — shared frame with docs-scoped header (GitHub, Home, Get started), collapsing sidebar on mobile. - src/app/docs/page.tsx — grouped index cards per section. - src/app/docs/[slug]/page.tsx — individual doc + prev/next nav. - Adds "Docs" to landing nav, footer, and a "Read the full documentation" CTA under the open-source cards. - Adds `marked` dependency. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9fc3eb772b
commit
45fd2a9845
13
package-lock.json
generated
13
package-lock.json
generated
@ -18,6 +18,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"marked": "^18.0.2",
|
||||
"next": "16.2.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
@ -7018,6 +7019,18 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.2",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.2.tgz",
|
||||
"integrity": "sha512-NsmlUYBS/Zg57rgDWMYdnre6OTj4e+qq/JS2ot3KrYLSoHLw+sDu0Nm1ZGpRgYAq6c+b1ekaY5NzVchMCQnzcg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"marked": "^18.0.2",
|
||||
"next": "16.2.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
|
||||
87
src/app/docs/[slug]/page.tsx
Normal file
87
src/app/docs/[slug]/page.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react'
|
||||
import { listDocs, loadDoc } from '@/lib/docs/content'
|
||||
|
||||
interface DocPageProps {
|
||||
params: Promise<{ slug: string }>
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const pages = await listDocs()
|
||||
return pages.map((p) => ({ slug: p.slug }))
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: DocPageProps): Promise<Metadata> {
|
||||
const { slug } = await params
|
||||
const doc = await loadDoc(slug)
|
||||
if (!doc) return {}
|
||||
return {
|
||||
title: doc.title,
|
||||
description: doc.description,
|
||||
}
|
||||
}
|
||||
|
||||
export default async function DocPage({ params }: DocPageProps) {
|
||||
const { slug } = await params
|
||||
const doc = await loadDoc(slug)
|
||||
if (!doc) notFound()
|
||||
|
||||
const pages = await listDocs()
|
||||
const idx = pages.findIndex((p) => p.slug === slug)
|
||||
const prev = idx > 0 ? pages[idx - 1] : null
|
||||
const next = idx >= 0 && idx < pages.length - 1 ? pages[idx + 1] : null
|
||||
|
||||
return (
|
||||
<article className="mx-auto w-full max-w-3xl">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-emerald-400">
|
||||
{doc.section}
|
||||
</p>
|
||||
<div
|
||||
className="doc-prose mt-2"
|
||||
dangerouslySetInnerHTML={{ __html: doc.html }}
|
||||
/>
|
||||
|
||||
<nav
|
||||
aria-label="Pagination"
|
||||
className="mt-16 grid grid-cols-1 gap-3 border-t border-slate-800 pt-8 sm:grid-cols-2"
|
||||
>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`/docs/${prev.slug}`}
|
||||
className="group flex flex-col gap-1 rounded-xl border border-slate-800 bg-slate-900/40 px-5 py-4 transition-colors hover:border-slate-700 hover:bg-slate-900/70"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Previous
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{prev.title}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{next ? (
|
||||
<Link
|
||||
href={`/docs/${next.slug}`}
|
||||
className="group flex flex-col items-end gap-1 rounded-xl border border-slate-800 bg-slate-900/40 px-5 py-4 text-right transition-colors hover:border-slate-700 hover:bg-slate-900/70"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
Next
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{next.title}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</nav>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
18
src/app/docs/layout.tsx
Normal file
18
src/app/docs/layout.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { DocsShell } from '@/components/docs/shell'
|
||||
import { listDocs } from '@/lib/docs/content'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: { template: '%s — WaCRM Docs', default: 'WaCRM Docs' },
|
||||
description:
|
||||
'Setup, configuration, and deployment docs for the WaCRM template.',
|
||||
}
|
||||
|
||||
export default async function DocsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const pages = await listDocs()
|
||||
return <DocsShell pages={pages}>{children}</DocsShell>
|
||||
}
|
||||
60
src/app/docs/page.tsx
Normal file
60
src/app/docs/page.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import Link from 'next/link'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import { listDocs } from '@/lib/docs/content'
|
||||
|
||||
export default async function DocsIndexPage() {
|
||||
const pages = await listDocs()
|
||||
const sections: Record<string, typeof pages> = {}
|
||||
for (const p of pages) {
|
||||
sections[p.section] ??= []
|
||||
sections[p.section].push(p)
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="mx-auto w-full max-w-3xl">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-emerald-400">
|
||||
Documentation
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-bold tracking-tight text-white sm:text-4xl">
|
||||
Self-host WaCRM end to end
|
||||
</h1>
|
||||
<p className="mt-4 text-base leading-relaxed text-slate-400">
|
||||
Everything you need to take the template from a fresh fork to a
|
||||
production deploy. Work through the pages in order, or jump to the
|
||||
one you need.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-col gap-10">
|
||||
{Object.entries(sections).map(([section, items]) => (
|
||||
<section key={section}>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
{section}
|
||||
</h2>
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{items.map((p) => (
|
||||
<Link
|
||||
key={p.slug}
|
||||
href={`/docs/${p.slug}`}
|
||||
className="group flex flex-col rounded-xl border border-slate-800 bg-slate-900/40 p-5 transition-colors hover:border-slate-700 hover:bg-slate-900/70"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
{p.title}
|
||||
</h3>
|
||||
{p.description && (
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-slate-400">
|
||||
{p.description}
|
||||
</p>
|
||||
)}
|
||||
<span className="mt-4 inline-flex items-center gap-1.5 text-xs font-medium text-emerald-400 transition-colors group-hover:text-emerald-300">
|
||||
Read
|
||||
<ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
94
src/components/docs/shell.tsx
Normal file
94
src/components/docs/shell.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
"use client"
|
||||
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Menu, X } from 'lucide-react'
|
||||
import { DocsSidebar } from './sidebar'
|
||||
import { GithubIcon } from '@/components/landing/github-icon'
|
||||
import { MessageSquare } from 'lucide-react'
|
||||
import type { DocPage } from '@/lib/docs/content'
|
||||
|
||||
const REPO_URL = 'https://github.com/ArnasDon/wacrm'
|
||||
|
||||
interface DocsShellProps {
|
||||
pages: DocPage[]
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function DocsShell({ pages, children }: DocsShellProps) {
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const closeMobile = () => setMobileOpen(false)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-100">
|
||||
<header className="sticky top-0 z-40 border-b border-slate-800 bg-slate-950/80 backdrop-blur">
|
||||
<div className="mx-auto flex h-16 w-full max-w-7xl items-center justify-between gap-4 px-4 sm:px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-slate-800 hover:text-white lg:hidden"
|
||||
aria-label={mobileOpen ? 'Close docs menu' : 'Open docs menu'}
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
>
|
||||
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2" aria-label="WaCRM home">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500">
|
||||
<MessageSquare className="h-4 w-4 text-white" />
|
||||
</span>
|
||||
<span className="text-lg font-semibold text-white">WaCRM</span>
|
||||
<span className="hidden rounded-md border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-slate-400 sm:inline-block">
|
||||
Docs
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Link
|
||||
href="/"
|
||||
className="hidden rounded-lg px-3 py-1.5 text-sm text-slate-300 transition-colors hover:bg-slate-800 hover:text-white sm:inline-flex"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<a
|
||||
href={REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="View source on GitHub"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-slate-800 hover:text-white"
|
||||
>
|
||||
<GithubIcon className="h-4 w-4" />
|
||||
</a>
|
||||
<Link
|
||||
href="/signup"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-emerald-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-emerald-400"
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mx-auto flex w-full max-w-7xl gap-8 px-4 sm:px-6">
|
||||
<aside className="sticky top-16 hidden h-[calc(100vh-4rem)] w-64 flex-shrink-0 overflow-y-auto py-10 pr-2 lg:block">
|
||||
<DocsSidebar pages={pages} />
|
||||
</aside>
|
||||
|
||||
{mobileOpen && (
|
||||
<div className="fixed inset-0 top-16 z-30 flex lg:hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm"
|
||||
onClick={closeMobile}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="relative ml-0 mr-auto flex h-full w-72 max-w-[85vw] flex-col overflow-y-auto border-r border-slate-800 bg-slate-950 px-4 py-6">
|
||||
<DocsSidebar pages={pages} onNavigate={closeMobile} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="min-w-0 flex-1 py-10">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
68
src/components/docs/sidebar.tsx
Normal file
68
src/components/docs/sidebar.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
"use client"
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DocPage } from '@/lib/docs/content'
|
||||
|
||||
interface DocsSidebarProps {
|
||||
pages: DocPage[]
|
||||
onNavigate?: () => void
|
||||
}
|
||||
|
||||
export function DocsSidebar({ pages, onNavigate }: DocsSidebarProps) {
|
||||
const pathname = usePathname()
|
||||
|
||||
const sections: Record<string, DocPage[]> = {}
|
||||
for (const p of pages) {
|
||||
sections[p.section] ??= []
|
||||
sections[p.section].push(p)
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label="Documentation" className="flex flex-col gap-6 text-sm">
|
||||
<Link
|
||||
href="/docs"
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-md px-3 py-2 transition-colors',
|
||||
pathname === '/docs'
|
||||
? 'bg-slate-800 text-white'
|
||||
: 'text-slate-300 hover:bg-slate-800/60 hover:text-white',
|
||||
)}
|
||||
>
|
||||
<span>Overview</span>
|
||||
</Link>
|
||||
|
||||
{Object.entries(sections).map(([section, items]) => (
|
||||
<div key={section}>
|
||||
<p className="mb-2 px-3 text-[10px] font-semibold uppercase tracking-wider text-slate-500">
|
||||
{section}
|
||||
</p>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{items.map((p) => {
|
||||
const href = `/docs/${p.slug}`
|
||||
const active = pathname === href
|
||||
return (
|
||||
<li key={p.slug}>
|
||||
<Link
|
||||
href={href}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
'block rounded-md px-3 py-1.5 transition-colors',
|
||||
active
|
||||
? 'bg-emerald-500/10 text-emerald-300'
|
||||
: 'text-slate-400 hover:bg-slate-800/60 hover:text-white',
|
||||
)}
|
||||
>
|
||||
{p.title}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@ -37,6 +37,7 @@ export function Footer() {
|
||||
external: true,
|
||||
},
|
||||
{ href: '#self-host', label: 'Self-host' },
|
||||
{ href: '/docs', label: 'Docs' },
|
||||
{
|
||||
href: 'https://www.hostinger.com/web-apps-hosting',
|
||||
label: 'Deploy on Hostinger',
|
||||
|
||||
@ -19,6 +19,7 @@ const LINKS = [
|
||||
{ href: '#features', label: 'Features' },
|
||||
{ href: '#how-it-works', label: 'How it works' },
|
||||
{ href: '#self-host', label: 'Self-host' },
|
||||
{ href: '/docs', label: 'Docs' },
|
||||
{ href: '#faq', label: 'FAQ' },
|
||||
]
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { ArrowRight, Server } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { ArrowRight, BookOpen, Server } from 'lucide-react'
|
||||
import { Section, SectionHeader } from './section'
|
||||
import { GithubIcon } from './github-icon'
|
||||
|
||||
@ -63,6 +64,17 @@ export function OpenSource() {
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-6 flex max-w-5xl items-center justify-center">
|
||||
<Link
|
||||
href="/docs"
|
||||
className="group inline-flex items-center gap-2 rounded-lg border border-slate-800 bg-slate-900/40 px-5 py-3 text-sm font-medium text-slate-200 transition-colors hover:border-slate-700 hover:bg-slate-900/70 hover:text-white"
|
||||
>
|
||||
<BookOpen className="h-4 w-4 text-emerald-400" />
|
||||
Read the full documentation
|
||||
<ArrowRight className="h-4 w-4 text-slate-400 transition-transform group-hover:translate-x-0.5 group-hover:text-slate-200" />
|
||||
</Link>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
222
src/lib/docs/content.ts
Normal file
222
src/lib/docs/content.ts
Normal file
@ -0,0 +1,222 @@
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { marked } from 'marked'
|
||||
|
||||
const DOCS_DIR = path.join(process.cwd(), 'docs')
|
||||
|
||||
export interface DocPage {
|
||||
slug: string
|
||||
title: string
|
||||
description?: string
|
||||
order: number
|
||||
section: string
|
||||
}
|
||||
|
||||
export interface DocContent extends DocPage {
|
||||
html: string
|
||||
toc: Array<{ id: string; text: string; depth: number }>
|
||||
}
|
||||
|
||||
// Hand-curated because file order on disk is not reading order.
|
||||
const ORDER: Record<string, { order: number; section: string; description: string }> = {
|
||||
'getting-started': {
|
||||
order: 1,
|
||||
section: 'Setup',
|
||||
description: 'Fork, install, run WaCRM locally in about 15 minutes.',
|
||||
},
|
||||
'supabase-setup': {
|
||||
order: 2,
|
||||
section: 'Setup',
|
||||
description: 'Create the database, apply migrations, grab the keys.',
|
||||
},
|
||||
'whatsapp-setup': {
|
||||
order: 3,
|
||||
section: 'Setup',
|
||||
description: 'Meta app, access token, webhook verification.',
|
||||
},
|
||||
'environment-variables': {
|
||||
order: 4,
|
||||
section: 'Setup',
|
||||
description: 'Full reference for every env var WaCRM reads.',
|
||||
},
|
||||
'deployment-hostinger': {
|
||||
order: 5,
|
||||
section: 'Deploy',
|
||||
description: 'Ship WaCRM on Hostinger Managed Node.js Hosting.',
|
||||
},
|
||||
'automations-and-cron': {
|
||||
order: 6,
|
||||
section: 'Deploy',
|
||||
description: 'Schedule the drain so Wait steps resume on time.',
|
||||
},
|
||||
troubleshooting: {
|
||||
order: 7,
|
||||
section: 'Operate',
|
||||
description: 'The usual suspects when something breaks.',
|
||||
},
|
||||
}
|
||||
|
||||
// Tailwind classes applied after marked renders.
|
||||
// Listed as full class strings so Tailwind's source scanner picks them up.
|
||||
const CLASSES = {
|
||||
h1: 'text-3xl sm:text-4xl font-bold tracking-tight text-white mt-1 mb-4 scroll-mt-20',
|
||||
h2: 'text-2xl font-semibold tracking-tight text-white mt-10 mb-3 pt-6 border-t border-slate-800 scroll-mt-20',
|
||||
h3: 'text-lg font-semibold text-white mt-8 mb-2 scroll-mt-20',
|
||||
p: 'my-3 text-slate-300 leading-relaxed',
|
||||
ul: 'my-3 pl-6 list-disc marker:text-slate-500 text-slate-300 leading-relaxed space-y-1.5',
|
||||
ol: 'my-3 pl-6 list-decimal marker:text-slate-500 text-slate-300 leading-relaxed space-y-1.5',
|
||||
li: 'pl-1',
|
||||
blockquote: 'my-4 rounded-r-lg border-l-4 border-emerald-500 bg-emerald-500/5 px-4 py-3 text-slate-200',
|
||||
hr: 'my-8 border-t border-slate-800',
|
||||
a: 'text-emerald-400 underline decoration-transparent underline-offset-2 transition-colors hover:decoration-emerald-500/60',
|
||||
codespan: 'rounded-md border border-slate-700 bg-slate-800 px-1.5 py-0.5 text-[0.85em] font-mono text-slate-100',
|
||||
pre: 'my-5 overflow-x-auto rounded-lg border border-slate-800 bg-slate-900/80 p-4 text-[0.825rem] leading-relaxed font-mono text-slate-100',
|
||||
table: 'my-5 block w-full overflow-x-auto rounded-lg border border-slate-800 text-sm',
|
||||
th: 'border-b border-slate-800 px-3.5 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-white bg-slate-900/60',
|
||||
td: 'border-b border-slate-800 px-3.5 py-2.5 text-left align-top text-slate-300',
|
||||
strong: 'font-semibold text-white',
|
||||
}
|
||||
|
||||
let pagesCache: DocPage[] | null = null
|
||||
|
||||
export async function listDocs(): Promise<DocPage[]> {
|
||||
if (pagesCache) return pagesCache
|
||||
const entries = await fs.readdir(DOCS_DIR)
|
||||
const pages: DocPage[] = []
|
||||
for (const file of entries) {
|
||||
if (!file.endsWith('.md') || file === 'README.md') continue
|
||||
const slug = file.replace(/\.md$/, '')
|
||||
const meta = ORDER[slug]
|
||||
if (!meta) continue
|
||||
const raw = await fs.readFile(path.join(DOCS_DIR, file), 'utf8')
|
||||
const title = extractTitle(raw) ?? slug
|
||||
pages.push({
|
||||
slug,
|
||||
title,
|
||||
description: meta.description,
|
||||
order: meta.order,
|
||||
section: meta.section,
|
||||
})
|
||||
}
|
||||
pages.sort((a, b) => a.order - b.order)
|
||||
pagesCache = pages
|
||||
return pages
|
||||
}
|
||||
|
||||
export async function loadDoc(slug: string): Promise<DocContent | null> {
|
||||
const pages = await listDocs()
|
||||
const meta = pages.find((p) => p.slug === slug)
|
||||
if (!meta) return null
|
||||
const raw = await fs.readFile(path.join(DOCS_DIR, `${slug}.md`), 'utf8')
|
||||
|
||||
// Resolve inter-doc links like `./supabase-setup.md` to `/docs/supabase-setup`.
|
||||
const rewritten = raw.replace(
|
||||
/\]\(\.\/([a-zA-Z0-9-_]+)\.md([^)]*)\)/g,
|
||||
(_, target: string, rest: string) => `](/docs/${target}${rest})`,
|
||||
)
|
||||
|
||||
const vanilla = await marked.parse(rewritten, { gfm: true, breaks: false })
|
||||
const { html, toc } = decorate(vanilla as string)
|
||||
|
||||
return { ...meta, html, toc }
|
||||
}
|
||||
|
||||
function decorate(html: string): { html: string; toc: DocContent['toc'] } {
|
||||
const toc: DocContent['toc'] = []
|
||||
|
||||
let out = html
|
||||
|
||||
// Headings: add id + Tailwind classes. Collect h2/h3 for TOC.
|
||||
out = out.replace(
|
||||
/<h([1-6])>([\s\S]*?)<\/h\1>/g,
|
||||
(_, depthStr: string, inner: string) => {
|
||||
const depth = Number(depthStr)
|
||||
const text = stripTags(inner)
|
||||
const id = slugify(text)
|
||||
if (depth >= 2 && depth <= 3) toc.push({ id, text, depth })
|
||||
const cls =
|
||||
depth === 1
|
||||
? CLASSES.h1
|
||||
: depth === 2
|
||||
? CLASSES.h2
|
||||
: depth === 3
|
||||
? CLASSES.h3
|
||||
: ''
|
||||
return `<h${depth} id="${id}" class="${cls}">${inner}</h${depth}>`
|
||||
},
|
||||
)
|
||||
|
||||
// Drop the top border from the very first h2 so it doesn't read as a
|
||||
// divider right under the page title.
|
||||
let firstH2 = true
|
||||
out = out.replace(
|
||||
/<h2 (id="[^"]*") class="([^"]*)"/g,
|
||||
(match, idAttr: string, cls: string) => {
|
||||
if (!firstH2) return match
|
||||
firstH2 = false
|
||||
const trimmed = cls
|
||||
.replace(/(?:^|\s)border-t(?:\s|$)/, ' ')
|
||||
.replace(/(?:^|\s)border-slate-800(?:\s|$)/, ' ')
|
||||
.replace(/(?:^|\s)pt-6(?:\s|$)/, ' ')
|
||||
.replace(/(?:^|\s)mt-10(?:\s|$)/, ' mt-6 ')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
return `<h2 ${idAttr} class="${trimmed}"`
|
||||
},
|
||||
)
|
||||
|
||||
out = out.replace(/<p>/g, `<p class="${CLASSES.p}">`)
|
||||
out = out.replace(/<ul>/g, `<ul class="${CLASSES.ul}">`)
|
||||
out = out.replace(/<ol>/g, `<ol class="${CLASSES.ol}">`)
|
||||
out = out.replace(/<li>/g, `<li class="${CLASSES.li}">`)
|
||||
out = out.replace(/<blockquote>/g, `<blockquote class="${CLASSES.blockquote}">`)
|
||||
out = out.replace(/<hr>/g, `<hr class="${CLASSES.hr}" />`)
|
||||
out = out.replace(/<strong>/g, `<strong class="${CLASSES.strong}">`)
|
||||
out = out.replace(/<table>/g, `<table class="${CLASSES.table}">`)
|
||||
out = out.replace(/<th>/g, `<th class="${CLASSES.th}">`)
|
||||
out = out.replace(/<th align="([^"]+)">/g, `<th align="$1" class="${CLASSES.th}">`)
|
||||
out = out.replace(/<td>/g, `<td class="${CLASSES.td}">`)
|
||||
out = out.replace(/<td align="([^"]+)">/g, `<td align="$1" class="${CLASSES.td}">`)
|
||||
|
||||
// Inline <code> from marked comes wrapped in <pre> for code blocks, or
|
||||
// bare for codespan. We distinguish by whether <pre> precedes it.
|
||||
out = out.replace(
|
||||
/<pre><code( class="language-[^"]*")?>/g,
|
||||
(_, langAttr = '') =>
|
||||
`<pre class="${CLASSES.pre}"><code${langAttr}>`,
|
||||
)
|
||||
// Remaining <code> tags are inline codespans.
|
||||
out = out.replace(
|
||||
/<code(?! class="language-)([^>]*)>/g,
|
||||
(_, rest: string) => `<code${rest} class="${CLASSES.codespan}">`,
|
||||
)
|
||||
|
||||
// Links: style + open http(s) in new tabs.
|
||||
out = out.replace(
|
||||
/<a href="([^"]+)"([^>]*)>/g,
|
||||
(_, href: string, rest: string) => {
|
||||
const isExternal = /^https?:\/\//i.test(href)
|
||||
const extra = isExternal ? ' target="_blank" rel="noopener noreferrer"' : ''
|
||||
return `<a href="${href}"${rest} class="${CLASSES.a}"${extra}>`
|
||||
},
|
||||
)
|
||||
|
||||
return { html: out, toc }
|
||||
}
|
||||
|
||||
function extractTitle(raw: string): string | null {
|
||||
const match = raw.match(/^#\s+(.+?)\s*$/m)
|
||||
return match ? match[1].trim() : null
|
||||
}
|
||||
|
||||
function stripTags(input: string): string {
|
||||
return input.replace(/<[^>]*>/g, '').trim()
|
||||
}
|
||||
|
||||
function slugify(input: string): string {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user