From deeb05f2e05b95b783412482e10d62234c58c24c Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Sun, 26 Jul 2026 13:41:08 +0200 Subject: [PATCH] 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`). --- backend/api/package.json | 2 +- common/messages/de.json | 22 +- common/messages/fr.json | 22 +- web/components/faq/faq-content.tsx | 367 ++++++++++++++++++++++++++++ web/components/faq/faq-item.tsx | 96 ++++++++ web/components/faq/faq-markdown.tsx | 93 +++++++ web/components/markdown.tsx | 12 +- web/lib/faq.ts | 176 +++++++++++++ web/pages/faq.tsx | 87 ++++++- web/public/md/de/faq.md | 142 ++++++----- web/public/md/faq.md | 231 ++++++++--------- web/public/md/fr/faq.md | 110 +++++---- 12 files changed, 1121 insertions(+), 239 deletions(-) create mode 100644 web/components/faq/faq-content.tsx create mode 100644 web/components/faq/faq-item.tsx create mode 100644 web/components/faq/faq-markdown.tsx create mode 100644 web/lib/faq.ts diff --git a/backend/api/package.json b/backend/api/package.json index fd28079b..625f0135 100644 --- a/backend/api/package.json +++ b/backend/api/package.json @@ -1,6 +1,6 @@ { "name": "@compass/api", - "version": "1.50.0", + "version": "1.51.0", "private": true, "description": "Backend API endpoints", "main": "src/serve.ts", diff --git a/common/messages/de.json b/common/messages/de.json index 7f91c949..6da19aec 100644 --- a/common/messages/de.json +++ b/common/messages/de.json @@ -1679,5 +1679,25 @@ "share_profile.x_share_profile": "Ich bin auf einer Plattform, die Menschen nach Werten, nicht nach Fotos zusammenbringt.\nKeine Werbung. Keine Algorithmen. Kein Wischen. Nur Tiefe.", "email.new_message.daily_limit": "Um deine Inbox nicht zu überladen, erhältst du höchstens eine E-Mail pro Tag pro Konversation. Wenn {creatorName} heute weitere Nachrichten sendet, wirst du erst morgen wieder per E-Mail benachrichtigt.", "email.new_message.app_prompt": "Willst du Echtzeit-Benachrichtigungen? Lade die Compass App für Android herunter:", - "email.new_message.app_link": "App herunterladen" + "email.new_message.app_link": "App herunterladen", + "faq.empty.cta": "Frage stellen →", + "faq.empty.text": "Versuche ein allgemeineres Wort — oder frag uns direkt, wir beantworten jede Nachricht.", + "faq.empty.title": "Dazu passt hier nichts.", + "faq.eyebrow": "Hilfebereich", + "faq.nav.label": "FAQ-Kategorien", + "faq.nav.title": "Themen", + "faq.search.clear": "Suche zurücksetzen", + "faq.search.count": "{count} passende Fragen", + "faq.search.label": "FAQ durchsuchen", + "faq.search.none": "Keine Frage passt — versuche ein anderes Wort.", + "faq.search.placeholder": "FAQ durchsuchen…", + "faq.seo.description": "Häufige Fragen zu Compass", + "faq.seo.title": "FAQ", + "faq.stuck.contact": "Kontakt aufnehmen", + "faq.stuck.discord": "Auf Discord fragen", + "faq.stuck.label": "Noch offen", + "faq.stuck.text": "Compass wird von den Menschen gebaut, die es nutzen — frag alles, und die Antwort landet meist auf dieser Seite für die nächste Person.", + "faq.stuck.title": "Antwort nicht gefunden?", + "faq.title": "Häufig gestellte Fragen", + "faq.search.count_one": "1 passende Frage" } diff --git a/common/messages/fr.json b/common/messages/fr.json index 1c05bfee..9e9f7f94 100644 --- a/common/messages/fr.json +++ b/common/messages/fr.json @@ -1678,5 +1678,25 @@ "share_profile.x_share_profile": "Je suis sur une plateforme qui met les gens en relation selon leurs valeurs, pas leurs photos.\nPas de pubs. Pas d'algorithmes. Pas de swipe. Juste de la profondeur.", "email.new_message.daily_limit": "Pour éviter de surcharger votre boîte de réception, vous recevrez au plus un e-mail par jour par conversation. Si {creatorName} envoie d'autres messages aujourd'hui, vous ne serez pas notifié par e-mail.", "email.new_message.app_prompt": "Vous voulez des notifications en temps réel ? Téléchargez l'application Compass pour Android :", - "email.new_message.app_link": "Obtenir l'app" + "email.new_message.app_link": "Obtenir l'app", + "faq.empty.cta": "Poser une question →", + "faq.empty.text": "Essayez un mot plus général — ou posez-nous directement la question, nous répondons à chaque message.", + "faq.empty.title": "Aucun résultat pour cette recherche.", + "faq.eyebrow": "Centre d’aide", + "faq.nav.label": "Catégories de la FAQ", + "faq.nav.title": "Thèmes", + "faq.search.clear": "Effacer la recherche", + "faq.search.count": "{count} questions correspondantes", + "faq.search.label": "Rechercher dans la FAQ", + "faq.search.none": "Aucune question ne correspond — essayez un autre mot.", + "faq.search.placeholder": "Rechercher dans la FAQ…", + "faq.seo.description": "Questions fréquentes sur Compass", + "faq.seo.title": "FAQ", + "faq.stuck.contact": "Nous contacter", + "faq.stuck.discord": "Demander sur Discord", + "faq.stuck.label": "Toujours bloqué", + "faq.stuck.text": "Compass est construit par celles et ceux qui l’utilisent — posez n’importe quelle question, la réponse finit généralement sur cette page pour la personne suivante.", + "faq.stuck.title": "Vous n’avez pas trouvé votre réponse ?", + "faq.title": "Questions fréquentes", + "faq.search.count_one": "1 question correspondante" } diff --git a/web/components/faq/faq-content.tsx b/web/components/faq/faq-content.tsx new file mode 100644 index 00000000..1c00c2d5 --- /dev/null +++ b/web/components/faq/faq-content.tsx @@ -0,0 +1,367 @@ +'use client' + +import { + ChatBubbleLeftRightIcon, + LifebuoyIcon, + MagnifyingGlassIcon, + XMarkIcon, +} from '@heroicons/react/24/outline' +import clsx from 'clsx' +import {discordLink} from 'common/constants' +import Link from 'next/link' +import {useEffect, useMemo, useRef, useState} from 'react' +import {eyebrow, surface} from 'web/components/widgets/surface' +import {FaqDoc, FaqQuestion, normalizeForSearch, toPlainText} from 'web/lib/faq' +import {useT} from 'web/lib/locale' + +import {FaqItem} from './faq-item' +import {FaqMarkdown} from './faq-markdown' + +/** + * The FAQ page body. + * + * Three things carry it, in order of how much they matter for a 22-question page: + * + * 1. **Search.** Collapsing the answers is what makes the page scannable, but it also breaks the + * browser's own find-in-page — so the search box is not a nicety here, it is the replacement for + * the thing collapsing took away. It matches question *and* answer text, and expands its hits. + * 2. **Categories.** Twenty-two flat questions is a wall; five labelled groups with a sticky rail is + * a table of contents. The groups come from `##` headings in the markdown, so translators get them + * as ordinary text. + * 3. **Deep links.** Every question has a slug id and the URL updates as you open one, so a support + * answer can be linked to directly instead of described. + */ +export function FaqContent({doc}: {doc: FaqDoc}) { + const t = useT() + const [query, setQuery] = useState('') + const [open, setOpen] = useState>(() => new Set()) + const [activeCategory, setActiveCategory] = useState(doc.categories[0]?.id) + const sectionRefs = useRef>({}) + + // Built once per document rather than per keystroke: `toPlainText` runs over every answer, which is + // wasted work to repeat on each of the ~20 renders a typed query produces. + // + // Question and answer only — deliberately *not* the category title. Folding the title in meant every + // question in "Money & governance" answered a search for "governance", so a query for one topic word + // returned four unrelated questions alongside the real hit. Navigating by topic is the rail's job. + const index = useMemo( + () => + doc.categories.flatMap((c) => + c.questions.map((q) => ({ + id: q.id, + haystack: normalizeForSearch(`${q.question} ${toPlainText(q.answer)}`), + })), + ), + [doc], + ) + + const matches = useMemo(() => { + const terms = normalizeForSearch(query).split(/\s+/).filter(Boolean) + if (!terms.length) return undefined + // Every term must appear somewhere in the entry, in any order — "data safe" and "safe data" find + // the same question. Substring rather than word-boundary matching, so "encrypt" finds + // "encryption". + return new Set( + index.filter((e) => terms.every((term) => e.haystack.includes(term))).map((e) => e.id), + ) + }, [index, query]) + + const visibleCategories = useMemo( + () => + doc.categories + .map((c) => ({...c, questions: c.questions.filter((q) => !matches || matches.has(q.id))})) + .filter((c) => c.questions.length > 0), + [doc, matches], + ) + + const resultCount = visibleCategories.reduce((n, c) => n + c.questions.length, 0) + + const toggle = (q: FaqQuestion) => { + setOpen((prev) => { + const next = new Set(prev) + if (next.has(q.id)) next.delete(q.id) + else next.add(q.id) + return next + }) + // `replaceState` rather than a router push: opening a question should be linkable, but it is not + // a navigation, and pushing would make Back walk through every card the reader expanded. + if (typeof window !== 'undefined' && !open.has(q.id)) { + window.history.replaceState(null, '', `#${q.id}`) + } + } + + // A search that returns three questions and leaves them all shut has answered nothing — the reader + // then has to click each one. Any hit set small enough to read at once is opened outright. + useEffect(() => { + if (!matches || matches.size === 0 || matches.size > 6) return + setOpen((prev) => new Set([...prev, ...matches])) + }, [matches]) + + // Deep links, on mount *and* on `hashchange`. + // + // The mount case is the shared link opened cold. The `hashchange` case is the same link followed by + // someone already on /faq — a same-document navigation, so the component never remounts and the + // browser scrolls to a question that is still collapsed. Back/forward between two question anchors + // has the same shape. The rail's own `#cat-` links land here too and are ignored, since no question + // carries that id. + useEffect(() => { + const openFromHash = () => { + const id = decodeURIComponent(window.location.hash.replace('#', '')) + if (!id) return + setOpen((prev) => new Set([...prev, id])) + // Deferred a frame: the panel has to be expanded before its position is worth measuring, or the + // scroll lands short by the height of the answer. + requestAnimationFrame(() => + document.getElementById(id)?.scrollIntoView({block: 'start', behavior: 'smooth'}), + ) + } + openFromHash() + window.addEventListener('hashchange', openFromHash) + return () => window.removeEventListener('hashchange', openFromHash) + }, []) + + // Scroll-spy for the rail: the active category is the last one whose heading has crossed a line + // near the top of the viewport. + // + // Not an IntersectionObserver, which is the reflex here and is wrong for it. An observer only fires + // on a *crossing*, so once a section taller than the trigger band has scrolled past, nothing + // intersects the band and the highlight sticks on whatever last entered it — which on this page + // means the rail says "The basics" while you are reading "Vision & roadmap". Answers expand and + // collapse under the reader too, moving every section below them, and a position check re-derives + // the answer from scratch on the next scroll frame where an observer would need re-observing. + useEffect(() => { + const update = () => { + const line = 140 + let active: string | undefined + for (const [id, el] of Object.entries(sectionRefs.current)) { + if (el && el.getBoundingClientRect().top <= line) active = id + } + setActiveCategory(active ?? visibleCategories[0]?.id) + } + update() + window.addEventListener('scroll', update, {passive: true}) + window.addEventListener('resize', update) + return () => { + window.removeEventListener('scroll', update) + window.removeEventListener('resize', update) + } + }, [visibleCategories]) + + return ( +
+ {/* ── Header ── */} +
+

{t('faq.eyebrow', 'Help center')}

+

+ {t('faq.title', 'Frequently asked questions')} +

+ {doc.intro && ( + {doc.intro} + )} +
+ + {/* ── Search ── */} +
+ + setQuery(e.target.value)} + placeholder={t('faq.search.placeholder', 'Search the FAQ…')} + aria-label={t('faq.search.label', 'Search the FAQ')} + className={clsx( + 'w-full rounded-2xl bg-canvas-50 py-3.5 pl-12 pr-11 text-base text-ink-900 placeholder:text-ink-400', + // `appearance-none border-0` before the ring: `type="search"` keeps a UA `1px solid grey` + // border that Tailwind's preflight does not zero out, so the field drew two edges — a cool + // grey one inside our warm `canvas-200` ring. The ring is the only edge we want. + 'appearance-none border-0 ring-1 ring-canvas-200 transition-shadow', + 'focus:outline-none focus:ring-2 focus:ring-primary-500/50', + // The native clear affordance is Chrome-only and sits where our own button goes. + '[&::-webkit-search-cancel-button]:appearance-none', + )} + /> + {query && ( + + )} +
+ {/* Announced, not just shown: filtering happens with focus still in the input, so a screen + reader user gets no signal that the page below them changed. */} +

+ {query && + (resultCount === 0 + ? t('faq.search.none', 'No questions match — try another word.') + : // A separate key for one hit rather than one string with `{count}`. `t()` has no + // pluralisation, so the single template renders "1 passende Fragen" in German and + // "1 questions correspondantes" in French — wrong in both, and a search for a specific + // term lands on exactly one hit often enough to be the common case. + resultCount === 1 + ? t('faq.search.count_one', '1 matching question') + : t('faq.search.count', '{count} matching questions', {count: resultCount}))} +

+ + {/* ── Body ── */} +
+ {/* The rail. Hidden below `lg` — at tablet width it either steals the column the answers need + or wraps into a chip row that duplicates the section headings two lines below it. */} + + +
+ {visibleCategories.map((category, i) => ( +
{ + sectionRefs.current[category.id] = el + }} + className={clsx('scroll-mt-24', i > 0 && 'mt-12')} + > + {category.title && ( +
+ {/* A real `h2` so the categories are in the document outline, but styled as the + same eyebrow /about uses — hence `m-0 font-figtree`, which undoes the global + heading margins and the serif face that would otherwise land on an 11px cap. */} +

+ {category.title} +

+
+
+ )} +
+ {category.questions.map((q) => ( + toggle(q)} /> + ))} +
+
+ ))} + + {resultCount === 0 && ( +
+ +

+ {t('faq.empty.title', 'Nothing here matches that.')} +

+

+ {t( + 'faq.empty.text', + 'Try a broader word — or ask us directly, we answer every message.', + )} +

+ + {t('faq.empty.cta', 'Ask a question →')} + +
+ )} +
+
+ + {/* Outside the grid, not inside the answer column: the answer column is ~520px once the rail + and the app sidebar are subtracted, which is narrow enough that a two-column closing block + placed in it wraps its own headline to three words a line. The finale spans the page, the + way the closing strip on /about does. */} + +
+ ) +} + +/** + * The closing block. + * + * An FAQ that ends on its last answer leaves the reader whose question was not on the list with + * nowhere to go, and that reader is the one most worth catching. Dark, like the closing strip on + * `/about`, so the page ends on something rather than trailing off. + */ +function StillStuck() { + const t = useT() + + return ( +
+
+
+
+
+ + + {t('faq.stuck.label', 'Still stuck')} + +
+

+ {t('faq.stuck.title', "Didn't find your answer?")} +

+

+ {t( + 'faq.stuck.text', + 'Compass is built by the people who use it — ask anything, and the answer usually ends up on this page for the next person.', + )} +

+
+
+ + {t('faq.stuck.contact', 'Contact us')} + + + + {t('faq.stuck.discord', 'Ask on Discord')} + +
+
+
+ ) +} diff --git a/web/components/faq/faq-item.tsx b/web/components/faq/faq-item.tsx new file mode 100644 index 00000000..648f0f29 --- /dev/null +++ b/web/components/faq/faq-item.tsx @@ -0,0 +1,96 @@ +import {ChevronDownIcon} from '@heroicons/react/24/outline' +import clsx from 'clsx' +import {surface, surfaceHover} from 'web/components/widgets/surface' +import {FaqQuestion} from 'web/lib/faq' + +import {FaqMarkdown} from './faq-markdown' + +/** + * One question, as a disclosure card. + * + * **The answer stays in the DOM when collapsed.** It is hidden by a `grid-template-rows` collapse, not + * by unmounting — which is what keeps 22 answers' worth of text in the served HTML for crawlers, and + * what lets a `#hash` deep link scroll to a question that has not been opened yet. Conditionally + * rendering the body would have quietly traded away the SEO the build-time parse was for. + * + * The `0fr → 1fr` grid trick rather than a `max-height` guess: answers here range from two lines to + * roughly thirty, so any single `max-height` is either a clipped answer or a long dead pause on the + * short ones. Grid animates to the content's real height with no measurement. + */ +export function FaqItem({ + item, + open, + onToggle, +}: { + item: FaqQuestion + open: boolean + onToggle: () => void +}) { + return ( +
+ {/* `m-0` because globals.css gives every heading `margin: 1.5rem 0 0.5rem` — inside a card whose + padding already sets the spacing, that is 32px of dead height per question, times 22. */} +

+ +

+ +
+ {/* The clipper. The row collapses to zero height, so this has to hide the overflow or the + answer stays painted over the card below it. */} +
+ {/* `inert` rather than `hidden` or unmounting: a zero-height `overflow-hidden` box still + holds focusable links, so without this, tabbing through the page walks into every + collapsed answer. `inert` takes the subtree out of the tab order and the accessibility + tree while leaving it in the DOM, which is exactly the split this needs — invisible to + a keyboard, still present for crawlers. */} +
+
+ {item.answer} +
+
+
+
+
+ ) +} diff --git a/web/components/faq/faq-markdown.tsx b/web/components/faq/faq-markdown.tsx new file mode 100644 index 00000000..50bcd45e --- /dev/null +++ b/web/components/faq/faq-markdown.tsx @@ -0,0 +1,93 @@ +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 ( +
+ ( +

+ {children} +

+ ), + a: ({node: _node, children, ...props}) => ( + + {children} + + ), + strong: ({node: _node, children, ...props}) => ( + + {children} + + ), + // 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}) => ( +
    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} +
+ ), + // `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}) => ( +
    + {children} +
+ ), + code: ({node: _node, children, ...props}) => ( + + {children} + + ), + blockquote: ({node: _node, children, ...props}) => ( +
+ {children} +
+ ), + }} + > + {children} +
+
+ ) +} diff --git a/web/components/markdown.tsx b/web/components/markdown.tsx index ef70d276..fe2e11d0 100644 --- a/web/components/markdown.tsx +++ b/web/components/markdown.tsx @@ -7,14 +7,10 @@ import {CustomLink} from 'web/components/links' import {PageBase} from 'web/components/page-base' import {SEO} from 'web/components/SEO' -export const MD_PATHS = [ - 'constitution', - 'faq', - 'financials', - 'members', - 'support', - 'tips-bio', -] as const +// `faq` is deliberately absent: /faq no longer renders through this generic loader. It parses its +// markdown at build time (see web/lib/faq.ts) so the questions reach the served HTML and the page can +// group, search, and deep-link them — none of which is possible with the file as one opaque blob. +export const MD_PATHS = ['constitution', 'financials', 'members', 'support', 'tips-bio'] as const type Props = { content: string diff --git a/web/lib/faq.ts b/web/lib/faq.ts new file mode 100644 index 00000000..33a228ee --- /dev/null +++ b/web/lib/faq.ts @@ -0,0 +1,176 @@ +/** + * Turns an FAQ markdown file into the structure the FAQ page renders. + * + * The markdown already carries every distinction the UI needs — `##` is a category, `###` is a + * question, everything until the next heading is that question's answer — so the source files stay + * plain prose that a translator can work on without touching any markup. All this does is stop + * treating the file as one opaque blob, which is what forced the old page to render it as a single + * undifferentiated `prose` column. + * + * Answers stay as *markdown strings*, not HTML: the page renders them with `react-markdown` and a + * component map, so the links keep going through `CustomLink` (client-side routing for internal + * hrefs) and the lists pick up the design tokens. Pre-rendering to an HTML string here would trade + * both away for nothing. + * + * Runs in `getStaticProps`, so the result is in the served HTML — which is what makes the FAQPage + * JSON-LD possible and removes the empty-page flash the old client-side `fetch` had. + */ + +export type FaqQuestion = { + /** Slug used as the element id, so `/faq#is-my-data-safe` deep-links to a question. */ + id: string + question: string + /** Raw markdown, rendered by the page. */ + answer: string +} + +export type FaqCategory = { + id: string + title: string + questions: FaqQuestion[] +} + +export type FaqDoc = { + title: string + /** Everything between the `#` title and the first category — rendered as the page's lede. */ + intro: string + categories: FaqCategory[] +} + +const HEADING = /^(#{1,6})\s+(.*?)\s*$/ + +/** + * Slugs come from the question text rather than its position, so an anchor survives questions being + * reordered or inserted above it — the thing that actually happens to an FAQ. The cost is that the + * anchors are per-locale, since the text they derive from is translated; a shared cross-locale anchor + * would need an explicit id in the markdown, which is exactly the markup the source files are meant + * to stay free of. + */ +export function slugify(text: string) { + return ( + text + .normalize('NFD') + // Strip diacritics so `Confidentialité` and `Für` produce clean ASCII anchors. + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60) + .replace(/-+$/g, '') + ) +} + +export function parseFaq(markdown: string): FaqDoc { + const lines = markdown.split('\n') + + let title = '' + const intro: string[] = [] + const categories: FaqCategory[] = [] + // Tracks fenced blocks so a `### ` inside one is never mistaken for a question. No FAQ answer has + // a code fence today, but a parser that silently shatters the first one that shows up is a trap. + let inFence = false + let current: FaqCategory | undefined + let question: {question: string; answer: string[]} | undefined + const seen = new Set() + + const flushQuestion = () => { + if (!question || !current) return + let id = slugify(question.question) || `q${current.questions.length + 1}` + // Two questions can legitimately slugify the same way across a long file; the suffix keeps the + // ids unique so `getElementById` and the deep link stay unambiguous. + if (seen.has(id)) { + let n = 2 + while (seen.has(`${id}-${n}`)) n++ + id = `${id}-${n}` + } + seen.add(id) + current.questions.push({id, question: question.question, answer: trim(question.answer)}) + question = undefined + } + + for (const line of lines) { + if (/^\s*(```|~~~)/.test(line)) inFence = !inFence + + const m = inFence ? null : HEADING.exec(line) + const depth = m ? m[1].length : 0 + + if (m && depth === 1 && !title) { + title = m[2] + continue + } + + if (m && depth === 2) { + flushQuestion() + current = {id: slugify(m[2]), title: m[2], questions: []} + categories.push(current) + continue + } + + if (m && depth >= 3) { + flushQuestion() + // A `###` before any `##` still has to land somewhere; an untitled category renders without a + // heading, so a file that never groups its questions degrades to one flat list rather than + // dropping content. + if (!current) { + current = {id: 'faq', title: '', questions: []} + categories.push(current) + } + question = {question: m[2], answer: []} + continue + } + + if (question) question.answer.push(line) + else if (!current) intro.push(line) + // Prose sitting directly under a `##` with no `###` after it has nowhere to go in this model. + // Dropping it is deliberate — the alternative is inventing a heading-less pseudo-question. + } + + flushQuestion() + + return { + title, + intro: trim(intro), + // A category whose questions all failed to parse would render as a heading over nothing. + categories: categories.filter((c) => c.questions.length > 0), + } +} + +function trim(lines: string[]) { + const out = [...lines] + while (out.length && !out[0].trim()) out.shift() + while (out.length && !out[out.length - 1].trim()) out.pop() + return out.join('\n') +} + +/** Flattened, in document order — for search and for the JSON-LD. */ +export function allQuestions(doc: FaqDoc): FaqQuestion[] { + return doc.categories.flatMap((c) => c.questions) +} + +/** + * Answer markdown reduced to plain text, for the search index and the JSON-LD `acceptedAnswer`. + * + * Deliberately a regex pass rather than a real render: search wants the words, and Google wants the + * text of the answer. Neither needs the tree, and pulling a second markdown pipeline in for this + * would be far more machinery than the job. + */ +export function toPlainText(markdown: string) { + return markdown + .replace(/^\[\/\/\]:.*$/gm, '') // markdown comments (`[//]: # '...'`) + .replace(/```[\s\S]*?```/g, ' ') + .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // keep link text, drop the href + .replace(/[*_`>#]/g, '') + .replace(/^\s*[-+*]\s+/gm, '') + .replace(/\\([\\`*_{}[\]()#+\-.!])/g, '$1') + .replace(/\s+/g, ' ') + .trim() +} + +/** Accent- and case-insensitive, so `donnees` matches `données` and `fur` matches `für`. */ +export function normalizeForSearch(text: string) { + return text + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() +} diff --git a/web/pages/faq.tsx b/web/pages/faq.tsx index 8d403e8d..ecb04394 100644 --- a/web/pages/faq.tsx +++ b/web/pages/faq.tsx @@ -1,5 +1,86 @@ -import {MarkdownPageLoader} from 'web/components/MarkdownPageLoader' +import {defaultLocale, OG_DESCRIPTION, supportedLocales} from 'common/constants' +import fs from 'fs' +import Head from 'next/head' +import path from 'path' +import {FaqContent} from 'web/components/faq/faq-content' +import {PageBase} from 'web/components/page-base' +import {SEO} from 'web/components/SEO' +import {allQuestions, FaqDoc, parseFaq, toPlainText} from 'web/lib/faq' +import {useLocale, useT} from 'web/lib/locale' -export default function Page() { - return +type Props = { + /** Every locale's parsed FAQ, keyed by locale code. */ + docs: Record +} + +/** + * Read and parsed at build time rather than fetched at runtime. + * + * The old page fetched `/md/{locale}/faq.md` from a `useEffect` and rendered it client-side, which + * meant the HTML a crawler received contained no questions at all — on the one page in the app that + * is *made of* the thing Google shows rich results for. Parsing here puts the Q&A in the served + * markup and makes the `FAQPage` JSON-LD below possible. + * + * All locales ship in the props (roughly 30KB of text across three) because locale here is a cookie + * read by `I18nContext`, not a route segment — there is no per-locale URL to statically render + * separately, so the client has to be able to switch without another round trip. This still works + * under `output: 'export'` for the Android build, since `getStaticProps` runs at build time. + */ +export const getStaticProps = async () => { + const dir = path.join(process.cwd(), 'public', 'md') + const docs: Record = {} + + for (const locale of supportedLocales) { + const file = + locale === defaultLocale ? path.join(dir, 'faq.md') : path.join(dir, locale, 'faq.md') + // A locale without its own translation simply falls back to English at render time rather than + // failing the build — the same behaviour the old runtime fetch had. + if (!fs.existsSync(file)) continue + docs[locale] = parseFaq(fs.readFileSync(file, 'utf-8')) + } + + if (!docs[defaultLocale]) throw new Error(`Missing ${path.join(dir, 'faq.md')}`) + + return {props: {docs}} +} + +export default function Faq({docs}: Props) { + const t = useT() + const {locale} = useLocale() + const doc = docs[locale] ?? docs[defaultLocale] + + return ( + + + {/* Structured data for rich results. Always the default-locale document: the pre-rendered HTML + is English (locale is a cookie, resolved after hydration), so emitting the visitor's + translated questions here would describe markup the crawler never saw. */} + + ` inside the JSON would close the tag early; escaping the slash is the standard fix and + // stays valid JSON. + return json.replace(/