Merge pull request #61 from ArnasDon/feat/docs-viewer

feat(docs): in-app docs viewer at /docs
This commit is contained in:
Arnas Donauskas 2026-04-19 21:52:27 +03:00 committed by GitHub
commit c28e4caa32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 578 additions and 1 deletions

13
package-lock.json generated
View File

@ -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",

View File

@ -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",

View 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
View 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
View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@ -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',

View File

@ -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' },
]

View File

@ -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
View 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, '-')
}