diff --git a/package-lock.json b/package-lock.json index 5db6ccf..270a777 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e350adc..df69aa9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/docs/[slug]/page.tsx b/src/app/docs/[slug]/page.tsx new file mode 100644 index 0000000..06c4bb3 --- /dev/null +++ b/src/app/docs/[slug]/page.tsx @@ -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 { + 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 ( +
+

+ {doc.section} +

+
+ + +
+ ) +} diff --git a/src/app/docs/layout.tsx b/src/app/docs/layout.tsx new file mode 100644 index 0000000..ddc0987 --- /dev/null +++ b/src/app/docs/layout.tsx @@ -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 {children} +} diff --git a/src/app/docs/page.tsx b/src/app/docs/page.tsx new file mode 100644 index 0000000..ae475ca --- /dev/null +++ b/src/app/docs/page.tsx @@ -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 = {} + for (const p of pages) { + sections[p.section] ??= [] + sections[p.section].push(p) + } + + return ( +
+

+ Documentation +

+

+ Self-host WaCRM end to end +

+

+ 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. +

+ +
+ {Object.entries(sections).map(([section, items]) => ( +
+

+ {section} +

+
+ {items.map((p) => ( + +

+ {p.title} +

+ {p.description && ( +

+ {p.description} +

+ )} + + Read + + + + ))} +
+
+ ))} +
+
+ ) +} diff --git a/src/components/docs/shell.tsx b/src/components/docs/shell.tsx new file mode 100644 index 0000000..f866305 --- /dev/null +++ b/src/components/docs/shell.tsx @@ -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 ( +
+
+
+
+ + + + + + WaCRM + + Docs + + +
+ +
+ + Home + + + + + + Get started + +
+
+
+ +
+ + + {mobileOpen && ( +
+
+
+ +
+
+ )} + +
{children}
+
+
+ ) +} diff --git a/src/components/docs/sidebar.tsx b/src/components/docs/sidebar.tsx new file mode 100644 index 0000000..4ce0b5b --- /dev/null +++ b/src/components/docs/sidebar.tsx @@ -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 = {} + for (const p of pages) { + sections[p.section] ??= [] + sections[p.section].push(p) + } + + return ( + + ) +} diff --git a/src/components/landing/footer.tsx b/src/components/landing/footer.tsx index b465257..4dd9af4 100644 --- a/src/components/landing/footer.tsx +++ b/src/components/landing/footer.tsx @@ -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', diff --git a/src/components/landing/nav.tsx b/src/components/landing/nav.tsx index d1b2afc..9ecd0f4 100644 --- a/src/components/landing/nav.tsx +++ b/src/components/landing/nav.tsx @@ -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' }, ] diff --git a/src/components/landing/open-source.tsx b/src/components/landing/open-source.tsx index 49834df..eaa2eab 100644 --- a/src/components/landing/open-source.tsx +++ b/src/components/landing/open-source.tsx @@ -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() {
+ +
+ + + Read the full documentation + + +
) } diff --git a/src/lib/docs/content.ts b/src/lib/docs/content.ts new file mode 100644 index 0000000..e8d52f3 --- /dev/null +++ b/src/lib/docs/content.ts @@ -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 = { + '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 { + 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 { + 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( + /([\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 `${inner}` + }, + ) + + // 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( + /

{ + 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 `

/g, `

`) + out = out.replace(/