Skip to content

Commit 1c534a8

Browse files
committed
Add function references module
1 parent 4fe7d8f commit 1c534a8

6 files changed

Lines changed: 385 additions & 11 deletions

File tree

.vitepress/config.mts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import { isBlogPath } from './locales'
66
import { faqPlugin } from './faq'
77
import { infoBlockPlugin } from './info-block'
88
import { funcBlockPlugin } from './func-block'
9+
import { preScanSignatures } from './func-registry'
910

1011
const baseUrl = 'https://php-testo.github.io'
12+
const srcDir = fileURLToPath(new URL('..', import.meta.url))
1113

1214
export default defineConfig({
1315
title: 'Testo',
@@ -18,6 +20,7 @@ export default defineConfig({
1820

1921
markdown: {
2022
config: (md) => {
23+
preScanSignatures(srcDir)
2124
md.use(faqPlugin)
2225
md.use(infoBlockPlugin)
2326
md.use(funcBlockPlugin)

.vitepress/func-block.ts

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
*/
1919
import type MarkdownIt from 'markdown-it'
2020
import { getLocaleByPath, type LocaleConfig } from './locales'
21+
import { getEntry } from './func-registry'
2122

2223
interface Param {
2324
name: string
@@ -59,12 +60,68 @@ export function funcBlockPlugin(md: MarkdownIt) {
5960
return true
6061
})
6162

62-
md.renderer.rules['func_block'] = (tokens, idx) => {
63-
return renderFuncBlock(md, tokens[idx].content, tokens[idx].meta?.locale)
63+
md.renderer.rules['func_block'] = (tokens, idx, options, env) => {
64+
return renderFuncBlock(md, tokens[idx].content, tokens[idx].meta?.locale, env)
65+
}
66+
67+
// Inline rule: parse <func>..FQN..</func> references
68+
md.inline.ruler.before('html_inline', 'func_ref', (state, silent) => {
69+
if (state.src.slice(state.pos, state.pos + 6) !== '<func>') return false
70+
71+
const closeIdx = state.src.indexOf('</func>', state.pos + 6)
72+
if (closeIdx === -1) return false
73+
74+
if (silent) return true
75+
76+
const token = state.push('func_ref', '', 0)
77+
token.content = state.src.slice(state.pos + 6, closeIdx).trim()
78+
79+
const relativePath = state.env?.relativePath || ''
80+
token.meta = { locale: getLocaleByPath('/' + relativePath) }
81+
82+
state.pos = closeIdx + 7
83+
return true
84+
})
85+
86+
// Renderer for <func> inline references
87+
md.renderer.rules['func_ref'] = (tokens, idx) => {
88+
const token = tokens[idx]
89+
const rawFqn = token.content
90+
const locale = token.meta?.locale
91+
92+
// Build display name: strip namespace, keep ()
93+
const displayFqn = stripNamespace(rawFqn).display
94+
95+
// Look up in registry
96+
const entry = getEntry(locale?.code ?? 'en', rawFqn)
97+
98+
if (entry) {
99+
const sigHtml = highlightSignature(md, entry.signature)
100+
// Highlight inline display using the full signature, then extract the short portion
101+
const displayHtml = highlightFuncRef(md, displayFqn, entry.signature)
102+
const shortHtml = entry.short ? md.renderInline(entry.short) : ''
103+
104+
const tooltip = `<span class="func-ref-tooltip">`
105+
+ `<code class="func-ref-tooltip-sig vp-code">${sigHtml}</code>`
106+
+ (shortHtml ? `<span class="func-ref-tooltip-short">${shortHtml}</span>` : '')
107+
+ `</span>`
108+
109+
// Link only if signature has a navigable anchor (h > 0)
110+
if (entry.hasAnchor) {
111+
const href = entry.pagePath + '#' + entry.slug
112+
return `<a href="${href}" class="func-ref vp-code">${displayHtml}${tooltip}</a>`
113+
}
114+
115+
// Tooltip without link
116+
return `<span class="func-ref vp-code">${displayHtml}${tooltip}</span>`
117+
}
118+
119+
// No match in registry — render as plain inline code
120+
return `<code>${escapeHtml(displayFqn)}</code>`
64121
}
65122
}
66123

67-
function renderFuncBlock(md: MarkdownIt, raw: string, locale?: LocaleConfig): string {
124+
function renderFuncBlock(md: MarkdownIt, raw: string, locale?: LocaleConfig, env?: any): string {
68125
// Parse name attribute (the full signature)
69126
const nameMatch = raw.match(/<signature\s+[^>]*name="([^"]*)"/)
70127
if (!nameMatch) return ''
@@ -122,12 +179,12 @@ function renderFuncBlock(md: MarkdownIt, raw: string, locale?: LocaleConfig): st
122179

123180
// Build HTML output
124181
const sigHtml = highlightSignature(md, display)
125-
const descHtml = description ? md.render(description) : ''
182+
const descHtml = description ? md.render(description, env) : ''
126183

127184
// Compact mode: everything inline, no section headers
128185
if (compact) {
129186
const shortHtml = short ? md.renderInline(short) : ''
130-
const compactDescHtml = description ? md.render(description) : ''
187+
const compactDescHtml = description ? md.render(description, env) : ''
131188

132189
let html = '<div class="func-compact">'
133190

@@ -146,7 +203,7 @@ function renderFuncBlock(md: MarkdownIt, raw: string, locale?: LocaleConfig): st
146203
}
147204

148205
for (const ex of examples) {
149-
html += `<div class="func-compact-example">${md.render(ex)}</div>`
206+
html += `<div class="func-compact-example">${md.render(ex, env)}</div>`
150207
}
151208

152209
html += '</div>\n'
@@ -189,7 +246,7 @@ function renderFuncBlock(md: MarkdownIt, raw: string, locale?: LocaleConfig): st
189246
html += ' <div class="func-section">\n'
190247
html += ` <p class="func-section-title">${escapeHtml(examplesLabel)}</p>\n`
191248
for (const ex of examples) {
192-
html += ` <div class="func-example">${md.render(ex)}</div>\n`
249+
html += ` <div class="func-example">${md.render(ex, env)}</div>\n`
193250
}
194251
html += ' </div>\n'
195252
}
@@ -243,6 +300,15 @@ function extractShortName(display: string): string {
243300
return funcMatch ? funcMatch[1] : display
244301
}
245302

303+
/**
304+
* Highlights a short func reference (e.g. `Assert::blank()`) by wrapping it
305+
* as a PHP static call expression so Shiki can tokenize it properly.
306+
*/
307+
function highlightFuncRef(md: MarkdownIt, displayFqn: string, _fullSignature: string): string {
308+
// displayFqn is e.g. "Assert::blank()" — valid PHP as a static method call
309+
return highlightSignature(md, displayFqn) || escapeHtml(displayFqn)
310+
}
311+
246312
/**
247313
* Highlights a PHP signature string using Shiki via markdown-it's highlight option.
248314
*/

.vitepress/func-registry.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* Signature registry for cross-referencing `<func>` inline tags.
3+
*
4+
* Pre-scans all .md files to collect `<signature>` blocks with FQN names and h > 0,
5+
* then provides lookup for inline `<func>` references to generate links and tooltips.
6+
*/
7+
import { readFileSync, readdirSync, statSync } from 'fs'
8+
import { join, relative } from 'path'
9+
import { getLocaleByPath } from './locales'
10+
11+
export interface RegistryEntry {
12+
fqn: string // Normalized: \Testo\Assert::blank
13+
slug: string // Anchor: testo-assert--blank
14+
pagePath: string // URL path: /docs/plugins/assert or /ru/docs/plugins/assert
15+
signature: string // Display: Assert::blank(mixed $actual, string $message = ''): void
16+
short: string // One-liner description (raw markdown)
17+
hasAnchor: boolean // true if h > 0 — linkable heading exists
18+
}
19+
20+
// locale code -> normalized FQN -> entry
21+
const registry = new Map<string, Map<string, RegistryEntry>>()
22+
23+
/**
24+
* Normalize FQN by stripping arguments and return type.
25+
* `\Testo\Assert::blank(mixed $actual): void` → `\Testo\Assert::blank`
26+
* `\Testo\Assert::blank()` → `\Testo\Assert::blank`
27+
*/
28+
export function normalizeFqn(raw: string): string {
29+
const parenIdx = raw.indexOf('(')
30+
let base = parenIdx !== -1 ? raw.slice(0, parenIdx) : raw
31+
base = base.trim()
32+
if (!base.startsWith('\\')) base = '\\' + base
33+
return base
34+
}
35+
36+
/**
37+
* Build slug from FQN signature (same logic as func-block.ts buildSlug).
38+
*/
39+
function buildSlugFromFqn(signature: string): string {
40+
const fqnMatch = signature.match(/^\\?(.+?)\(/)
41+
if (fqnMatch) {
42+
return fqnMatch[1]
43+
.replace(/\\/g, '-')
44+
.replace(/::/g, '--')
45+
.replace(/->/g, '--')
46+
.toLowerCase()
47+
}
48+
const methodMatch = signature.match(/^([A-Za-z_]\w*)/)
49+
return methodMatch ? methodMatch[1].toLowerCase() : 'unknown'
50+
}
51+
52+
/**
53+
* Strip namespace prefix from signature for display.
54+
*/
55+
function stripNamespaceForDisplay(signature: string): string {
56+
const match = signature.match(/^\\?(?:[A-Za-z_]\w*\\)+(.*)$/)
57+
return match ? match[1] : signature
58+
}
59+
60+
/**
61+
* Pre-scan all .md files in srcDir to populate the signature registry.
62+
* Call once before markdown-it processes any pages.
63+
*/
64+
export function preScanSignatures(srcDir: string): void {
65+
registry.clear()
66+
const mdFiles = collectMdFiles(srcDir)
67+
68+
// Match all <signature> blocks with a name attribute
69+
const sigRe = /<signature\s+([^>]*)>([\s\S]*?)<\/signature>/g
70+
71+
for (const filePath of mdFiles) {
72+
const content = readFileSync(filePath, 'utf-8')
73+
const relPath = relative(srcDir, filePath).replace(/\\/g, '/')
74+
const locale = getLocaleByPath('/' + relPath)
75+
const pagePath = '/' + relPath.replace(/\.md$/, '').replace(/\/index$/, '/')
76+
77+
if (!registry.has(locale.code)) {
78+
registry.set(locale.code, new Map())
79+
}
80+
const localeMap = registry.get(locale.code)!
81+
82+
sigRe.lastIndex = 0
83+
let match: RegExpExecArray | null
84+
while ((match = sigRe.exec(content)) !== null) {
85+
const attrs = match[1]
86+
const body = match[2]
87+
88+
// Extract name attribute (required)
89+
const nameMatch = attrs.match(/\bname="([^"]*)"/)
90+
if (!nameMatch) continue
91+
const signature = nameMatch[1]
92+
93+
// Only collect FQN signatures (starting with \)
94+
if (!signature.startsWith('\\')) continue
95+
96+
const fqn = normalizeFqn(signature)
97+
98+
// Skip if already registered (first wins)
99+
if (localeMap.has(fqn)) continue
100+
101+
// Check if heading level > 0 (has a navigable anchor)
102+
const hMatch = attrs.match(/\bh="([1-6])"/)
103+
const hasAnchor = !!hMatch
104+
105+
const shortMatch = body.match(/<short>([\s\S]*?)<\/short>/)
106+
const short = shortMatch ? shortMatch[1].trim() : ''
107+
108+
localeMap.set(fqn, {
109+
fqn,
110+
slug: buildSlugFromFqn(signature),
111+
pagePath,
112+
signature: stripNamespaceForDisplay(signature),
113+
short,
114+
hasAnchor,
115+
})
116+
}
117+
}
118+
}
119+
120+
/**
121+
* Look up a signature entry by FQN and locale.
122+
*/
123+
export function getEntry(localeCode: string, rawFqn: string): RegistryEntry | undefined {
124+
const fqn = normalizeFqn(rawFqn)
125+
return registry.get(localeCode)?.get(fqn)
126+
}
127+
128+
/**
129+
* Recursively collect all .md files in a directory.
130+
*/
131+
function collectMdFiles(dir: string): string[] {
132+
const results: string[] = []
133+
for (const entry of readdirSync(dir)) {
134+
const fullPath = join(dir, entry)
135+
const stat = statSync(fullPath)
136+
if (stat.isDirectory()) {
137+
// Skip node_modules, .vitepress, etc.
138+
if (entry.startsWith('.') || entry === 'node_modules') continue
139+
results.push(...collectMdFiles(fullPath))
140+
} else if (entry.endsWith('.md') && entry !== 'CLAUDE.md' && entry !== 'README.md') {
141+
results.push(fullPath)
142+
}
143+
}
144+
return results
145+
}

.vitepress/theme/index.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,57 @@ import BlogPostHeader from './BlogPostHeader.vue'
1212
import { isBlogPath, getBlogBackLink, localNavBackKey } from '../locales'
1313
import './style.css'
1414

15+
function setupFuncRefTooltips() {
16+
document.addEventListener('mouseenter', (e) => {
17+
const ref = (e.target as Element).closest?.('.func-ref')
18+
if (!ref) return
19+
20+
const tip = ref.querySelector('.func-ref-tooltip') as HTMLElement
21+
if (!tip) return
22+
23+
const rect = ref.getBoundingClientRect()
24+
const gap = 8
25+
26+
// Reset and show with default max-width for measuring
27+
tip.style.left = '0'
28+
tip.style.top = '0'
29+
tip.style.maxWidth = ''
30+
tip.classList.add('is-visible')
31+
32+
// If signature overflows at 480px, expand to fit it
33+
const sig = tip.querySelector('.func-ref-tooltip-sig') as HTMLElement
34+
if (sig && sig.scrollWidth > sig.clientWidth) {
35+
const needed = sig.scrollWidth + 28 // 14px padding * 2
36+
tip.style.maxWidth = Math.min(needed, window.innerWidth - 16) + 'px'
37+
}
38+
39+
// Measure tooltip after adjustment
40+
const tipRect = tip.getBoundingClientRect()
41+
42+
// Vertical: above if fits, below otherwise
43+
let top = rect.top - tipRect.height - gap
44+
if (top < 0) top = rect.bottom + gap
45+
tip.style.top = top + 'px'
46+
47+
// Horizontal: center on the func element, clamp to viewport
48+
const refCenter = rect.left + rect.width / 2
49+
let left = refCenter - tipRect.width / 2
50+
if (left + tipRect.width > window.innerWidth - 8) {
51+
left = window.innerWidth - tipRect.width - 8
52+
}
53+
if (left < 8) left = 8
54+
tip.style.left = left + 'px'
55+
}, true)
56+
57+
document.addEventListener('mouseleave', (e) => {
58+
const ref = (e.target as Element).closest?.('.func-ref')
59+
if (!ref) return
60+
61+
const tip = ref.querySelector('.func-ref-tooltip') as HTMLElement
62+
if (tip) tip.classList.remove('is-visible')
63+
}, true)
64+
}
65+
1566
export default {
1667
extends: DefaultTheme,
1768
Layout: defineComponent({
@@ -29,10 +80,14 @@ export default {
2980
})
3081
},
3182
}),
32-
enhanceApp({ app }) {
83+
enhanceApp({ app, router }) {
3384
app.component('CodeTabs', CodeTabs)
3485
app.component('JetBrainsPluginButton', JetBrainsPluginButton)
3586
app.component('JetBrainsPlugin', JetBrainsPlugin)
3687
app.component('BlogPosts', BlogPosts)
88+
89+
if (typeof window !== 'undefined') {
90+
setupFuncRefTooltips()
91+
}
3792
},
3893
} satisfies Theme

0 commit comments

Comments
 (0)