|
| 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 "${escapeHtml(shortName)}""></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 "${escapeHtml(shortName)}""></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, '&') |
| 286 | + .replace(/</g, '<') |
| 287 | + .replace(/>/g, '>') |
| 288 | + .replace(/"/g, '"') |
| 289 | +} |
0 commit comments