Files
Compass/web/components/faq/faq-markdown.tsx
MartinBraquet deeb05f2e0 Add structured FAQ page with search, categories, and deep links
- Implemented `parseFaq` to transform markdown into structured data for improved rendering and SEO.
- Introduced `react-markdown` with custom styling for better alignment with the application design.
- Added search functionality with query matching and automatic expansion of relevant answers.
- Designed a sticky navigation rail for easier category navigation.
- Enabled deep linking to questions and categories for external reference.
- Created reusable components for FAQ items and markdown rendering.
- Updated markdown file handling to exclude FAQ from generic loader (`MD_PATHS`).
2026-07-26 13:41:08 +02:00

94 lines
4.0 KiB
TypeScript

import clsx from 'clsx'
import ReactMarkdown from 'react-markdown'
import {CustomLink} from 'web/components/links'
/**
* The answer body renderer.
*
* The old FAQ handed the entire file to `react-markdown` inside one `prose prose-neutral` div, which
* is why it read as a document rather than as part of the product: `prose` is a *reset for unstyled
* HTML*, not a design, so none of the tokens `/about` and `/home` are built on (`text-ink-600`,
* `primary-*`, the leading scale) ever reached it. This maps the handful of node types an FAQ answer
* actually uses onto those tokens instead.
*
* `a` goes through `CustomLink` — the FAQ links to `/news`, `/vote`, `/support`, `/stats` and a dozen
* other internal routes, and `CustomLink` is what keeps those as client-side navigations while still
* opening external links in a new tab.
*/
export function FaqMarkdown({children, className}: {children: string; className?: string}) {
return (
<div className={clsx('text-[15px] leading-relaxed text-ink-600', className)}>
<ReactMarkdown
components={{
p: ({node: _node, children, ...props}) => (
<p className="mb-4 last:mb-0" {...props}>
{children}
</p>
),
a: ({node: _node, children, ...props}) => (
<CustomLink
className="font-medium text-primary-700 underline decoration-primary-500/35 underline-offset-2 transition-colors hover:decoration-primary-500"
{...props}
>
{children}
</CustomLink>
),
strong: ({node: _node, children, ...props}) => (
<strong className="font-semibold text-ink-900" {...props}>
{children}
</strong>
),
// A drawn marker rather than a native bullet: the bulleted answers are the page's densest
// content, and a small primary dot pinned to the first line's optical centre is both calmer
// than a default disc and consistent with the flow steps on /about. Done as a `before:` on
// the children so the `li` renderer stays shared with ordered lists, which keep their real
// numbers.
//
// `list-none pl-0 mt-0` undoes globals.css's blanket `ul { list-style: disc; padding-left:
// 1.25rem; margin-top: 0.5rem }` — without it every bullet is drawn twice, once by the
// browser and once by us, at two different indents.
ul: ({node: _node, children, ...props}) => (
<ul
className={clsx(
'mb-4 mt-0 list-none space-y-2.5 pl-0 last:mb-0',
"[&>li]:relative [&>li]:pl-5 [&>li]:before:absolute [&>li]:before:left-0 [&>li]:before:top-[0.6em] [&>li]:before:h-1.5 [&>li]:before:w-1.5 [&>li]:before:rounded-full [&>li]:before:bg-primary-500/70 [&>li]:before:content-['']",
)}
{...props}
>
{children}
</ul>
),
// `list-outside` because the global `ol` rule sets `list-style-position: inside`, which puts
// the number in the text flow and kills the hanging indent on wrapped lines.
ol: ({node: _node, children, ...props}) => (
<ol
className="mb-4 mt-0 list-decimal list-outside space-y-2.5 pl-5 last:mb-0 marker:text-ink-400"
{...props}
>
{children}
</ol>
),
code: ({node: _node, children, ...props}) => (
<code
className="rounded bg-canvas-100 px-1.5 py-0.5 font-mono text-[0.9em] text-ink-800"
{...props}
>
{children}
</code>
),
blockquote: ({node: _node, children, ...props}) => (
<blockquote
className="mb-4 border-l-2 border-primary-300 pl-4 italic last:mb-0"
{...props}
>
{children}
</blockquote>
),
}}
>
{children}
</ReactMarkdown>
</div>
)
}