UNPKG

@apideck/docs-mcp

Version:

MCP server that lets agents search and retrieve your documentation at query time instead of relying on stale training data.

208 lines • 7.16 kB
// SPDX-License-Identifier: MIT import yaml from 'js-yaml'; const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/; export const parseFrontmatter = (raw) => { const match = FRONTMATTER_RE.exec(raw); if (!match) return { data: {}, body: raw, hasFrontmatter: false }; let data = {}; try { const parsed = yaml.load(match[1] ?? ''); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { data = parsed; } } catch { // Malformed YAML: keep the page, drop the metadata. } return { data, body: raw.slice(match[0].length), hasFrontmatter: true }; }; /** GitHub-style heading anchors. */ export const slugify = (text) => text .toLowerCase() .trim() .replace(/`([^`]*)`/g, '$1') .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') .replace(/[^\p{L}\p{N}\s_-]/gu, '') .replace(/\s+/g, '-') .replace(/-{2,}/g, '-') .replace(/^-|-$/g, ''); const FENCE_RE = /^\s{0,3}(`{3,}|~{3,})/; const HEADING_RE = /^\s{0,3}(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/; /** * Split a markdown body on ATX headings. Headings inside fenced code blocks are * ignored. The preamble (text before the first heading) becomes a level-0 * section when non-empty. */ export const splitSections = (body) => { const lines = body.split(/\r?\n/); const sections = []; const stack = []; const anchorCounts = new Map(); let current = { heading: '', level: 0, anchor: '', headingPath: [], content: '', startLine: 1, }; let buffer = []; let fenceChar = null; let fenceLen = 0; const flush = () => { current.content = buffer.join('\n').trim(); if (current.content !== '' || current.level > 0) sections.push(current); buffer = []; }; lines.forEach((line, i) => { const fence = FENCE_RE.exec(line); if (fence) { const marker = fence[1] ?? ''; const char = marker[0] ?? '`'; if (fenceChar === null) { fenceChar = char; fenceLen = marker.length; } else if (char === fenceChar && marker.length >= fenceLen) { fenceChar = null; fenceLen = 0; } buffer.push(line); return; } const heading = fenceChar === null ? HEADING_RE.exec(line) : null; if (!heading) { buffer.push(line); return; } flush(); const level = (heading[1] ?? '#').length; const text = (heading[2] ?? '').trim(); while (stack.length > 0 && (stack[stack.length - 1]?.level ?? 0) >= level) { stack.pop(); } stack.push({ level, text }); let anchor = slugify(text); if (anchor === '') anchor = `section-${sections.length + 1}`; const seen = anchorCounts.get(anchor) ?? 0; anchorCounts.set(anchor, seen + 1); if (seen > 0) anchor = `${anchor}-${seen}`; current = { heading: text, level, anchor, headingPath: stack.map((s) => s.text), content: '', startLine: i + 1, }; }); flush(); return sections; }; const stripFences = (md) => md.replace(/(`{3,}|~{3,})[\s\S]*?\1/g, ' '); const LINK_RE = /!?\[[^\]]*\]\(\s*(?:<([^>]*)>|([^)\s]+))(?:\s+["'][^"']*["'])?\s*\)/g; const EXTERNAL_RE = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i; /** Relative link targets in the body. Images, external, mailto and pure-anchor links are skipped. */ export const extractLinks = (body) => { const out = []; const text = stripFences(body); for (const match of text.matchAll(LINK_RE)) { if (match[0].startsWith('!')) continue; const href = match[1] ?? match[2] ?? ''; if (href === '' || EXTERNAL_RE.test(href)) continue; out.push(href); } return out; }; /** Reduce markdown to readable plain text, for snippets and word counts. */ export const toPlainText = (md) => stripFences(md) .replace(/`([^`]*)`/g, '$1') .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') .replace(/^\s{0,3}#{1,6}[ \t]+/gm, '') .replace(/^\s*[-*+][ \t]+/gm, '') .replace(/^\s*\d+\.[ \t]+/gm, '') .replace(/^\s*>[ \t]?/gm, '') .replace(/^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/gm, ' ') .replace(/\|/g, ' ') .replace(/(\*\*|__|~~|\*)/g, '') .replace(/<\/?[a-zA-Z][^>]*>/g, ' ') .replace(/\s+/g, ' ') .trim(); export const countWords = (md) => { const text = toPlainText(md); return text === '' ? 0 : text.split(/\s+/).length; }; export const titleFromPath = (path) => { const last = path.split('/').filter(Boolean).pop() ?? path; return last .replace(/[-_]+/g, ' ') .replace(/\b\w/g, (c) => c.toUpperCase()) .trim(); }; /** Frontmatter `title`, else the first H1, else a title derived from the filename. */ export const deriveTitle = (frontmatter, sections, path) => { const fm = frontmatter['title']; if (typeof fm === 'string' && fm.trim() !== '') return fm.trim(); const h1 = sections.find((s) => s.level === 1); if (h1) return h1.heading; return titleFromPath(path); }; const DATE_KEYS = ['updated', 'last_updated', 'lastUpdated', 'lastmod', 'date']; /** A parseable date from frontmatter, if present. */ export const frontmatterDate = (frontmatter) => { for (const key of DATE_KEYS) { const value = frontmatter[key]; if (value instanceof Date && !Number.isNaN(value.getTime())) return value; if (typeof value === 'string' || typeof value === 'number') { const d = new Date(value); if (!Number.isNaN(d.getTime())) return d; } } return null; }; /** Short excerpt around the first query term hit, or the start of the text. */ export const makeSnippet = (plain, terms, width = 240) => { if (plain.length <= width) return plain; const lower = plain.toLowerCase(); let hit = -1; for (const term of terms) { const t = term.toLowerCase(); if (t === '') continue; const idx = lower.indexOf(t); if (idx !== -1 && (hit === -1 || idx < hit)) hit = idx; } if (hit === -1) return `${plain.slice(0, width).trimEnd()}…`; const half = Math.floor(width / 2); let start = Math.max(0, hit - half); let end = Math.min(plain.length, start + width); if (end - start < width) start = Math.max(0, end - width); // Snap to word boundaries. if (start > 0) { const sp = plain.indexOf(' ', start); if (sp !== -1 && sp < hit) start = sp + 1; } if (end < plain.length) { const sp = plain.lastIndexOf(' ', end); if (sp > hit) end = sp; } return `${start > 0 ? '…' : ''}${plain.slice(start, end).trim()}${end < plain.length ? '…' : ''}`; }; //# sourceMappingURL=markdown.js.map