mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 09:48:47 -04:00
- 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`).
177 lines
6.1 KiB
TypeScript
177 lines
6.1 KiB
TypeScript
/**
|
|
* 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<string>()
|
|
|
|
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()
|
|
}
|