Skip to content

Commit 4fe7d8f

Browse files
committed
Add functions signature processing module
1 parent 5d11fc8 commit 4fe7d8f

6 files changed

Lines changed: 497 additions & 19 deletions

File tree

.vitepress/config.mts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { generateLlms, llmsPlugin } from './llms'
55
import { isBlogPath } from './locales'
66
import { faqPlugin } from './faq'
77
import { infoBlockPlugin } from './info-block'
8+
import { funcBlockPlugin } from './func-block'
89

910
const baseUrl = 'https://php-testo.github.io'
1011

@@ -19,6 +20,7 @@ export default defineConfig({
1920
config: (md) => {
2021
md.use(faqPlugin)
2122
md.use(infoBlockPlugin)
23+
md.use(funcBlockPlugin)
2224
},
2325
},
2426
srcExclude: ['CLAUDE.md', 'README.md'],
@@ -87,6 +89,8 @@ gtag('config', 'G-VYGDN3X0PR');`],
8789
{ text: 'Data Providers', link: '/docs/plugins/data.md' },
8890
{ text: 'Lifecycle', link: '/docs/plugins/lifecycle.md' },
8991
{ text: 'Retry', link: '/docs/plugins/retry.md' },
92+
{ text: 'Assert & Expect', link: '/docs/plugins/assert.md' },
93+
{ text: 'Benchmarks', link: '/docs/plugins/bench.md' },
9094
],
9195
},
9296
{
@@ -143,6 +147,8 @@ gtag('config', 'G-VYGDN3X0PR');`],
143147
{ text: 'Провайдеры данных', link: '/ru/docs/plugins/data.md' },
144148
{ text: 'Жизненный цикл', link: '/ru/docs/plugins/lifecycle.md' },
145149
{ text: 'Retry', link: '/ru/docs/plugins/retry.md' },
150+
{ text: 'Assert и Expect', link: '/ru/docs/plugins/assert.md' },
151+
{ text: 'Бенчмарки', link: '/ru/docs/plugins/bench.md' },
146152
],
147153
},
148154
{

.vitepress/func-block.ts

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
/**
2+
* Function signature block plugin for markdown-it.
3+
*
4+
* Renders `<signature>` blocks as styled API reference cards
5+
* with Shiki-highlighted PHP signatures and parameter descriptions.
6+
*
7+
* Usage:
8+
* <signature name="Assert::same(mixed $actual, mixed $expected, string $message = '')">
9+
* <description>Method description.</description>
10+
* <param name="$actual">The value being checked.</param>
11+
* <param name="$expected">The expected value.</param>
12+
* <example>
13+
* ```php
14+
* Assert::same($user->role, 'admin');
15+
* ```
16+
* </example>
17+
* </signature>
18+
*/
19+
import type MarkdownIt from 'markdown-it'
20+
import { getLocaleByPath, type LocaleConfig } from './locales'
21+
22+
interface Param {
23+
name: string
24+
desc: string
25+
}
26+
27+
export function funcBlockPlugin(md: MarkdownIt) {
28+
md.block.ruler.before('html_block', 'func_block', (state, startLine, endLine, silent) => {
29+
const pos = state.bMarks[startLine] + state.tShift[startLine]
30+
const max = state.eMarks[startLine]
31+
const firstLine = state.src.slice(pos, max)
32+
33+
if (!firstLine.startsWith('<signature ')) return false
34+
if (silent) return true
35+
36+
// Find closing </signature>
37+
let closeLine = -1
38+
for (let i = startLine; i < endLine; i++) {
39+
const ls = state.bMarks[i] + state.tShift[i]
40+
const le = state.eMarks[i]
41+
if (state.src.slice(ls, le).includes('</signature>')) {
42+
closeLine = i
43+
break
44+
}
45+
}
46+
47+
if (closeLine === -1) return false
48+
49+
const token = state.push('func_block', '', 0)
50+
token.content = state.src.slice(pos, state.eMarks[closeLine])
51+
token.map = [startLine, closeLine + 1]
52+
token.block = true
53+
54+
// Resolve locale from file path for localized labels
55+
const relativePath = state.env?.relativePath || ''
56+
token.meta = { locale: getLocaleByPath('/' + relativePath) }
57+
58+
state.line = closeLine + 1
59+
return true
60+
})
61+
62+
md.renderer.rules['func_block'] = (tokens, idx) => {
63+
return renderFuncBlock(md, tokens[idx].content, tokens[idx].meta?.locale)
64+
}
65+
}
66+
67+
function renderFuncBlock(md: MarkdownIt, raw: string, locale?: LocaleConfig): string {
68+
// Parse name attribute (the full signature)
69+
const nameMatch = raw.match(/<signature\s+[^>]*name="([^"]*)"/)
70+
if (!nameMatch) return ''
71+
const signature = nameMatch[1]
72+
73+
// Parse heading level: h="3" etc., default h="0" for bold text instead of heading
74+
const hMatch = raw.match(/<signature\s+[^>]*h="([^"]*)"/)
75+
const headingLevel = hMatch ? parseInt(hMatch[1], 10) : 0
76+
77+
// Check for compact rendering mode
78+
const compact = /^<signature\s+[^>]*\bcompact\b/.test(raw)
79+
80+
// Extract body between opening tag and </signature>
81+
const openEnd = raw.indexOf('>')
82+
const closeStart = raw.lastIndexOf('</signature>')
83+
if (openEnd === -1 || closeStart === -1) return ''
84+
const body = raw.slice(openEnd + 1, closeStart)
85+
86+
// Extract <short> — one-liner rendered between heading and signature box
87+
const shortMatch = body.match(/<short>([\s\S]*?)<\/short>/)
88+
const short = shortMatch ? shortMatch[1].trim() : ''
89+
90+
// Extract <description> block (supports full markdown)
91+
const descMatch = body.match(/<description>([\s\S]*?)<\/description>/)
92+
const description = descMatch ? descMatch[1].trim() : ''
93+
94+
// Extract <param> tags
95+
const params: Param[] = []
96+
const paramRe = /<param\s+name="([^"]*)">([\s\S]*?)<\/param>/g
97+
let m: RegExpExecArray | null
98+
while ((m = paramRe.exec(body)) !== null) {
99+
params.push({ name: m[1], desc: m[2].trim() })
100+
}
101+
102+
// Extract <example> tags (full markdown blocks)
103+
const examples: string[] = []
104+
const exampleRe = /<example>([\s\S]*?)<\/example>/g
105+
let em: RegExpExecArray | null
106+
while ((em = exampleRe.exec(body)) !== null) {
107+
examples.push(em[1].trim())
108+
}
109+
110+
// Strip namespace from signature for display
111+
const { display } = stripNamespace(signature)
112+
113+
// Extract short name for heading: "Class::method" or just "method"
114+
const shortName = extractShortName(display)
115+
116+
// Localized labels
117+
const paramsLabel = locale?.signatureParamsLabel ?? 'Parameters:'
118+
const examplesLabel = locale?.signatureExamplesLabel ?? 'Examples:'
119+
120+
// Build slug from FQN for unique heading IDs
121+
const slug = buildSlug(signature)
122+
123+
// Build HTML output
124+
const sigHtml = highlightSignature(md, display)
125+
const descHtml = description ? md.render(description) : ''
126+
127+
// Compact mode: everything inline, no section headers
128+
if (compact) {
129+
const shortHtml = short ? md.renderInline(short) : ''
130+
const compactDescHtml = description ? md.render(description) : ''
131+
132+
let html = '<div class="func-compact">'
133+
134+
// Hidden anchor for navigation when heading level is specified
135+
if (headingLevel > 0 && headingLevel <= 6) {
136+
html += `<h${headingLevel} id="${escapeHtml(slug)}" class="func-compact-anchor" tabindex="-1">${escapeHtml(shortName)} <a class="header-anchor" href="#${escapeHtml(slug)}" aria-label="Permalink to &quot;${escapeHtml(shortName)}&quot;">​</a></h${headingLevel}>`
137+
}
138+
139+
html += `<code class="func-sig vp-code">${sigHtml}</code>`
140+
if (shortHtml) html += `<span class="func-compact-short">${shortHtml}</span>`
141+
if (compactDescHtml) html += `<div class="func-compact-desc">${compactDescHtml}</div>`
142+
143+
if (params.length > 0) {
144+
const inline = params.map(p => `<code>${escapeHtml(p.name)}</code> -> ${md.renderInline(p.desc)}`).join('; ')
145+
html += `<div class="func-compact-params">${inline}</div>`
146+
}
147+
148+
for (const ex of examples) {
149+
html += `<div class="func-compact-example">${md.render(ex)}</div>`
150+
}
151+
152+
html += '</div>\n'
153+
return html
154+
}
155+
156+
let html = ''
157+
158+
// Heading or bold text from short name
159+
if (headingLevel > 0 && headingLevel <= 6) {
160+
html += `<h${headingLevel} id="${escapeHtml(slug)}" tabindex="-1">${escapeHtml(shortName)} <a class="header-anchor" href="#${escapeHtml(slug)}" aria-label="Permalink to &quot;${escapeHtml(shortName)}&quot;">​</a></h${headingLevel}>\n`
161+
} else {
162+
html += `<p class="func-title"><strong>${escapeHtml(shortName)}</strong></p>\n`
163+
}
164+
165+
if (short) {
166+
html += `<p class="func-short">${md.renderInline(short)}</p>\n`
167+
}
168+
169+
html += '<div class="func-block">\n'
170+
html += ` <code class="func-sig vp-code">${sigHtml}</code>\n`
171+
172+
if (descHtml) {
173+
html += ` <div class="func-desc">${descHtml}</div>\n`
174+
}
175+
176+
if (params.length > 0) {
177+
html += ' <div class="func-section">\n'
178+
html += ` <p class="func-section-title">${escapeHtml(paramsLabel)}</p>\n`
179+
html += ' <dl class="func-params">\n'
180+
for (const p of params) {
181+
html += ` <dt><code>${escapeHtml(p.name)}</code></dt>\n`
182+
html += ` <dd>${md.renderInline(p.desc)}</dd>\n`
183+
}
184+
html += ' </dl>\n'
185+
html += ' </div>\n'
186+
}
187+
188+
if (examples.length > 0) {
189+
html += ' <div class="func-section">\n'
190+
html += ` <p class="func-section-title">${escapeHtml(examplesLabel)}</p>\n`
191+
for (const ex of examples) {
192+
html += ` <div class="func-example">${md.render(ex)}</div>\n`
193+
}
194+
html += ' </div>\n'
195+
}
196+
197+
html += '</div>\n'
198+
return html
199+
}
200+
201+
/**
202+
* Builds a unique slug from the signature's FQN.
203+
*
204+
* FQN signatures (e.g. `\Testo\Assert::string(...)`) produce slugs like `assert-string`.
205+
* Non-FQN signatures (e.g. `contains(...)`) fall back to the short method name.
206+
*/
207+
function buildSlug(signature: string): string {
208+
// Extract FQN path before the opening paren: \Testo\Assert::same(...) → Testo\Assert::same
209+
const fqnMatch = signature.match(/^\\?(.+?)\(/)
210+
if (fqnMatch) {
211+
return fqnMatch[1]
212+
.replace(/\\/g, '-')
213+
.replace(/::/g, '--') // double dash between class and method
214+
.replace(/->/g, '--')
215+
.toLowerCase()
216+
}
217+
218+
// No FQN — use short method name
219+
const methodMatch = signature.match(/^([A-Za-z_]\w*)/)
220+
return methodMatch ? methodMatch[1].toLowerCase() : 'unknown'
221+
}
222+
223+
/**
224+
* Strips namespace prefix from FQN signatures for display.
225+
* E.g. `\Testo\Assert::method(...)` → `Assert::method(...)`
226+
*/
227+
function stripNamespace(signature: string): { display: string } {
228+
const match = signature.match(/^\\?(?:[A-Za-z_]\w*\\)+(.*)$/)
229+
return { display: match ? match[1] : signature }
230+
}
231+
232+
/**
233+
* Extracts short name for heading from display signature.
234+
* E.g. `Assert::fail(string $message = ''): never` → `Assert::fail`
235+
*/
236+
function extractShortName(display: string): string {
237+
// Match "Class::method" or "Class->method" before the opening paren
238+
const match = display.match(/^([A-Za-z_]\w*(?:::|->)[A-Za-z_]\w*)/)
239+
if (match) return match[1]
240+
241+
// Match just "functionName" before paren
242+
const funcMatch = display.match(/^([A-Za-z_]\w*)/)
243+
return funcMatch ? funcMatch[1] : display
244+
}
245+
246+
/**
247+
* Highlights a PHP signature string using Shiki via markdown-it's highlight option.
248+
*/
249+
function highlightSignature(md: MarkdownIt, signature: string): string {
250+
const fallback = escapeHtml(signature)
251+
252+
const highlight = md.options.highlight
253+
if (!highlight) return fallback
254+
255+
try {
256+
// PHP grammar needs <?php prefix to activate proper tokenization
257+
const result = highlight('<?php\n' + signature, 'php', '')
258+
259+
// Extract content between <code> and </code>
260+
const codeMatch = result.match(/<code[^>]*>([\s\S]*?)<\/code>/)
261+
if (!codeMatch) return fallback
262+
263+
let inner = codeMatch[1].trim()
264+
265+
// Remove first line (<?php) — Shiki separates lines by \n
266+
const newlineIdx = inner.indexOf('\n')
267+
if (newlineIdx !== -1) {
268+
inner = inner.slice(newlineIdx + 1).trim()
269+
}
270+
271+
// Unwrap <span class="line">...tokens...</span> → bare tokens
272+
const lineMatch = inner.match(/^<span class="line">([\s\S]*)<\/span>$/)
273+
if (lineMatch) {
274+
inner = lineMatch[1]
275+
}
276+
277+
return inner || fallback
278+
} catch {
279+
return fallback
280+
}
281+
}
282+
283+
function escapeHtml(str: string): string {
284+
return str
285+
.replace(/&/g, '&amp;')
286+
.replace(/</g, '&lt;')
287+
.replace(/>/g, '&gt;')
288+
.replace(/"/g, '&quot;')
289+
}

.vitepress/llms.ts

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,29 +22,36 @@ function extractH1(content: string): string | null {
2222

2323
function readPages(srcDir: string): PageInfo[] {
2424
const pages: PageInfo[] = []
25-
2625
const docsDir = path.join(srcDir, 'docs')
27-
if (existsSync(docsDir)) {
28-
for (const file of readdirSync(docsDir).filter(f => f.endsWith('.md'))) {
29-
const filePath = path.join(docsDir, file)
30-
const raw = readFileSync(filePath, 'utf-8')
31-
const { data: fm, content } = matter(raw)
32-
33-
if (fm.llms === false || !fm.llms_description) continue
34-
35-
const slug = file.replace(/\.md$/, '')
36-
const llmsValue = fm.llms ?? true
37-
pages.push({
38-
title: fm.title || extractH1(content) || slug,
39-
llms_description: fm.llms_description,
40-
url: `/docs/${slug}`,
41-
srcPath: `docs/${file}`,
42-
content: content.trim(),
43-
section: llmsValue === 'optional' ? 'optional' : 'docs',
44-
})
26+
if (!existsSync(docsDir)) return pages
27+
28+
function scan(dir: string) {
29+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
30+
if (entry.isDirectory()) {
31+
scan(path.join(dir, entry.name))
32+
} else if (entry.name.endsWith('.md')) {
33+
const filePath = path.join(dir, entry.name)
34+
const raw = readFileSync(filePath, 'utf-8')
35+
const { data: fm, content } = matter(raw)
36+
37+
if (fm.llms === false || !fm.llms_description) continue
38+
39+
const relPath = path.relative(srcDir, filePath).replace(/\\/g, '/')
40+
const url = '/' + relPath.replace(/\.md$/, '')
41+
const llmsValue = fm.llms ?? true
42+
pages.push({
43+
title: fm.title || extractH1(content) || entry.name.replace(/\.md$/, ''),
44+
llms_description: fm.llms_description,
45+
url,
46+
srcPath: relPath,
47+
content: content.trim(),
48+
section: llmsValue === 'optional' ? 'optional' : 'docs',
49+
})
50+
}
4551
}
4652
}
4753

54+
scan(docsDir)
4855
return pages
4956
}
5057

0 commit comments

Comments
 (0)