mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +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",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
|
"marked": "^18.0.2",
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
@ -7018,6 +7019,18 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
|
"marked": "^18.0.2",
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "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,
|
external: true,
|
||||||
},
|
},
|
||||||
{ href: '#self-host', label: 'Self-host' },
|
{ href: '#self-host', label: 'Self-host' },
|
||||||
|
{ href: '/docs', label: 'Docs' },
|
||||||
{
|
{
|
||||||
href: 'https://www.hostinger.com/web-apps-hosting',
|
href: 'https://www.hostinger.com/web-apps-hosting',
|
||||||
label: 'Deploy on Hostinger',
|
label: 'Deploy on Hostinger',
|
||||||
|
|||||||
@ -19,6 +19,7 @@ const LINKS = [
|
|||||||
{ href: '#features', label: 'Features' },
|
{ href: '#features', label: 'Features' },
|
||||||
{ href: '#how-it-works', label: 'How it works' },
|
{ href: '#how-it-works', label: 'How it works' },
|
||||||
{ href: '#self-host', label: 'Self-host' },
|
{ href: '#self-host', label: 'Self-host' },
|
||||||
|
{ href: '/docs', label: 'Docs' },
|
||||||
{ href: '#faq', label: 'FAQ' },
|
{ 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 { Section, SectionHeader } from './section'
|
||||||
import { GithubIcon } from './github-icon'
|
import { GithubIcon } from './github-icon'
|
||||||
|
|
||||||
@ -63,6 +64,17 @@ export function OpenSource() {
|
|||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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>
|
</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