Skip to content

Commit 35f5bad

Browse files
committed
Add FAQ plugin for markdown-it with collapsible question sections
1 parent 8d4a6cf commit 35f5bad

4 files changed

Lines changed: 291 additions & 1 deletion

File tree

.vitepress/config.mts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { defineConfig, HeadConfig } from 'vitepress'
33
import { generateRss, rssPlugin } from './rss'
44
import { generateLlms, llmsPlugin } from './llms'
55
import { isBlogPath } from './locales'
6+
import { faqPlugin } from './faq'
67

78
const baseUrl = 'https://php-testo.github.io'
89

@@ -12,6 +13,12 @@ export default defineConfig({
1213

1314
lastUpdated: false,
1415
cleanUrls: true,
16+
17+
markdown: {
18+
config: (md) => {
19+
md.use(faqPlugin)
20+
},
21+
},
1522
srcExclude: ['CLAUDE.md', 'README.md'],
1623
ignoreDeadLinks: [/feed\.xml$/],
1724

.vitepress/faq.ts

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/**
2+
* FAQ plugin for markdown-it.
3+
*
4+
* Adds `::: question` container — questions can be written anywhere in the article,
5+
* but are collected and rendered as `<details>` accordions grouped by heading level.
6+
*
7+
* Frontmatter `faqLevel` controls grouping:
8+
* 1 (default) — end of each h1 section (= end of page for single-h1 docs)
9+
* 2 — end of each h2 section
10+
* 0 — end of page regardless of headings
11+
* false — no collection, questions render in place as inline spoilers
12+
*
13+
* No external dependencies — block rule is implemented inline (same logic as markdown-it-container).
14+
*/
15+
import type MarkdownIt from 'markdown-it'
16+
17+
const COLON = 0x3A
18+
19+
interface FaqQuestion {
20+
title: string
21+
content: any[]
22+
}
23+
24+
function buildFaqTokens(
25+
state: any,
26+
questions: FaqQuestion[],
27+
md: MarkdownIt,
28+
): any[] {
29+
const result: any[] = []
30+
31+
const open = new state.Token('html_block', '', 0)
32+
open.content = `<section class="faq-section">\n`
33+
result.push(open)
34+
35+
for (const q of questions) {
36+
const detailsOpen = new state.Token('html_block', '', 0)
37+
detailsOpen.content = `<details class="faq-item">\n<summary>${md.renderInline(q.title)}</summary>\n<div class="faq-answer">\n`
38+
result.push(detailsOpen)
39+
40+
result.push(...q.content)
41+
42+
const detailsClose = new state.Token('html_block', '', 0)
43+
detailsClose.content = `</div>\n</details>\n`
44+
result.push(detailsClose)
45+
}
46+
47+
const close = new state.Token('html_block', '', 0)
48+
close.content = `</section>\n`
49+
result.push(close)
50+
51+
return result
52+
}
53+
54+
export function faqPlugin(md: MarkdownIt) {
55+
// Block rule: parse ::: question blocks (same approach as markdown-it-container)
56+
md.block.ruler.before('fence', 'container_question', (state, startLine, endLine, silent) => {
57+
const start = state.bMarks[startLine] + state.tShift[startLine]
58+
const max = state.eMarks[startLine]
59+
60+
if (state.src.charCodeAt(start) !== COLON) return false
61+
62+
let pos = start + 1
63+
while (pos <= max && state.src.charCodeAt(pos) === COLON) pos++
64+
65+
const markerCount = pos - start
66+
if (markerCount < 3) return false
67+
68+
const markup = state.src.slice(start, pos)
69+
const params = state.src.slice(pos, max).trim()
70+
71+
if (!/^question\s+.+/.test(params)) return false
72+
if (silent) return true
73+
74+
// Find closing :::
75+
let nextLine = startLine
76+
let autoClosed = false
77+
78+
for (;;) {
79+
nextLine++
80+
if (nextLine >= endLine) break
81+
82+
const lineStart = state.bMarks[nextLine] + state.tShift[nextLine]
83+
const lineMax = state.eMarks[nextLine]
84+
85+
if (lineStart < lineMax && state.sCount[nextLine] < state.blkIndent) break
86+
if (state.src.charCodeAt(lineStart) !== COLON) continue
87+
if (state.sCount[nextLine] - state.blkIndent >= 4) continue
88+
89+
let closePos = lineStart + 1
90+
while (closePos <= lineMax && state.src.charCodeAt(closePos) === COLON) closePos++
91+
if (closePos - lineStart < markerCount) continue
92+
93+
closePos = state.skipSpaces(closePos)
94+
if (closePos < lineMax) continue
95+
96+
autoClosed = true
97+
break
98+
}
99+
100+
const oldParent = state.parentType
101+
const oldLineMax = state.lineMax
102+
state.parentType = 'container' as any
103+
state.lineMax = nextLine
104+
105+
const openToken = state.push('container_question_open', 'div', 1)
106+
openToken.markup = markup
107+
openToken.block = true
108+
openToken.info = params
109+
openToken.map = [startLine, nextLine]
110+
111+
state.md.block.tokenize(state, startLine + 1, nextLine)
112+
113+
const closeToken = state.push('container_question_close', 'div', -1)
114+
closeToken.markup = markup
115+
closeToken.block = true
116+
117+
state.parentType = oldParent
118+
state.lineMax = oldLineMax
119+
state.line = nextLine + (autoClosed ? 1 : 0)
120+
121+
return true
122+
}, { alt: ['paragraph', 'reference', 'blockquote', 'list'] })
123+
124+
// Inline renderers: used when faqLevel: false (questions stay in place)
125+
md.renderer.rules['container_question_open'] = (tokens, idx) => {
126+
const title = tokens[idx].info.slice('question'.length).trim()
127+
return `<details class="faq-item">\n<summary>${md.renderInline(title)}</summary>\n<div class="faq-answer">\n`
128+
}
129+
md.renderer.rules['container_question_close'] = () => {
130+
return `</div>\n</details>\n`
131+
}
132+
133+
// Core rule: collect question blocks and group them by heading level
134+
md.core.ruler.push('faq-collect', (state) => {
135+
const tokens = state.tokens
136+
const faqLevel = state.env?.frontmatter?.faqLevel
137+
138+
// faqLevel: false → questions render in place, skip collection
139+
if (faqLevel === false) return
140+
141+
const level = (faqLevel ?? 1) as number
142+
const questions: FaqQuestion[] = []
143+
144+
// Phase 1: Extract questions, replace with lightweight markers
145+
let i = 0
146+
while (i < tokens.length) {
147+
if (tokens[i].type === 'container_question_open') {
148+
const title = tokens[i].info.slice('question'.length).trim()
149+
const start = i
150+
let depth = 1
151+
i++
152+
const content: any[] = []
153+
154+
while (i < tokens.length && depth > 0) {
155+
if (tokens[i].type === 'container_question_open') depth++
156+
if (tokens[i].type === 'container_question_close') depth--
157+
if (depth > 0) content.push(tokens[i])
158+
i++
159+
}
160+
161+
const marker = new state.Token('faq_marker', '', 0)
162+
marker.meta = { questionIndex: questions.length }
163+
questions.push({ title, content })
164+
165+
tokens.splice(start, i - start, marker)
166+
i = start + 1
167+
} else {
168+
i++
169+
}
170+
}
171+
172+
if (questions.length === 0) return
173+
174+
// Phase 2: Build new token array, inserting FAQ blocks at section boundaries
175+
// level=0 → tag "h0" matches nothing → all questions flush at the end
176+
// level=1 → flush before each h1 (typically one per page → end of page)
177+
// level=2 → flush before each h2
178+
const tag = `h${level}`
179+
const newTokens: any[] = []
180+
let sectionQuestions: FaqQuestion[] = []
181+
182+
for (const token of tokens) {
183+
if (token.type === 'faq_marker') {
184+
sectionQuestions.push(questions[token.meta.questionIndex])
185+
continue
186+
}
187+
188+
if (token.type === 'heading_open' && token.tag === tag && sectionQuestions.length > 0) {
189+
newTokens.push(...buildFaqTokens(state, sectionQuestions, md))
190+
sectionQuestions = []
191+
}
192+
193+
newTokens.push(token)
194+
}
195+
196+
// Flush remaining questions at the end
197+
if (sectionQuestions.length > 0) {
198+
newTokens.push(...buildFaqTokens(state, sectionQuestions, md))
199+
}
200+
201+
state.tokens = newTokens
202+
})
203+
}

.vitepress/theme/style.css

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,3 +404,57 @@ html {
404404
-webkit-mask-image: none;
405405
}
406406
}
407+
408+
/* ===== FAQ SECTION ===== */
409+
410+
.faq-section {
411+
margin-top: 32px;
412+
}
413+
414+
.vp-doc .faq-section .faq-item {
415+
margin: 0;
416+
padding: 0;
417+
}
418+
419+
.vp-doc .faq-item summary {
420+
margin: 8px 0;
421+
padding: 0;
422+
cursor: pointer;
423+
font-weight: 500;
424+
color: var(--vp-c-brand-1);
425+
list-style: none;
426+
display: inline-flex;
427+
align-items: center;
428+
gap: 8px;
429+
}
430+
431+
.faq-item summary::-webkit-details-marker {
432+
display: none;
433+
}
434+
435+
.faq-item summary::before {
436+
content: '';
437+
width: 0;
438+
height: 0;
439+
border-left: 6px solid var(--vp-c-brand-1);
440+
border-top: 4px solid transparent;
441+
border-bottom: 4px solid transparent;
442+
transition: transform 0.2s;
443+
flex-shrink: 0;
444+
}
445+
446+
.faq-item[open] summary::before {
447+
transform: rotate(90deg);
448+
}
449+
450+
.faq-answer {
451+
padding: 0 0 8px 14px;
452+
}
453+
454+
.faq-answer > p:first-child {
455+
margin-top: 0;
456+
}
457+
458+
.faq-answer > p:last-child {
459+
margin-bottom: 0;
460+
}

CLAUDE.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ ru/ # Russian locale (same structure)
3232
- Avoid tautology in lists, fix typos
3333
- Small sections sometimes better integrated into existing ones
3434

35-
**Markdown:** Use `::: tip`, `::: warning`, `::: info` blocks
35+
**Markdown:** Use `::: tip`, `::: warning`, `::: info`, `::: question` blocks
3636

3737
## Working with Content
3838

@@ -108,6 +108,32 @@ llms_description: "Technical description of what LLM learns from this page"
108108

109109
**When adding new doc pages:** add `llms_description` to the English version frontmatter. Do NOT add llms frontmatter to blog posts.
110110

111+
## FAQ (`::: question`)
112+
113+
Questions can be written anywhere in the article using `::: question` blocks. At build time, they are extracted from their original positions and grouped into collapsible FAQ accordions.
114+
115+
**Syntax:**
116+
```md
117+
::: question Can I run tests without config?
118+
Yes, Testo looks for tests in the `tests` folder by default.
119+
:::
120+
```
121+
122+
**Frontmatter `faqLevel`** controls where questions are rendered:
123+
124+
```yaml
125+
---
126+
faqLevel: 1 # default — end of each h1 section (= end of page for most docs)
127+
faqLevel: 2 # end of each h2 section
128+
faqLevel: 0 # end of page (ignores headings)
129+
faqLevel: false # no collection — questions stay in place as inline spoilers
130+
---
131+
```
132+
133+
**Plugin:** `.vitepress/faq.ts` — markdown-it block rule + core rule, no external dependencies.
134+
135+
**Styles:** `.vitepress/theme/style.css``.faq-section`, `.faq-item` classes.
136+
111137
## VitePress Commands
112138

113139
```bash

0 commit comments

Comments
 (0)