| 1 | ; |
| 2 | |
| 3 | // Content renderers: Markdown → HTML (with heading ids, mermaid, relative |
| 4 | // link rewriting), source code → line-numbered highlighted HTML, JSONL → |
| 5 | // record cards, plus small helpers shared by the page templates. |
| 6 | |
| 7 | const path = require('path'); |
| 8 | const hljs = require('highlight.js'); |
| 9 | const { Marked } = require('marked'); |
| 10 | const { markedHighlight } = require('marked-highlight'); |
| 11 | const { gfmHeadingId } = require('marked-gfm-heading-id'); |
| 12 | const { registerLanguages, languageFor } = require('./languages'); |
| 13 | |
| 14 | registerLanguages(hljs); |
| 15 | |
| 16 | const HIGHLIGHT_LIMIT = 600 * 1024; // bytes; above this we skip syntax colouring |
| 17 | const TEXT_LIMIT = 3 * 1024 * 1024; // bytes; above this we do not inline at all |
| 18 | |
| 19 | function escapeHtml(s) { |
| 20 | return String(s) |
| 21 | .replace(/&/g, '&') |
| 22 | .replace(/</g, '<') |
| 23 | .replace(/>/g, '>') |
| 24 | .replace(/"/g, '"') |
| 25 | .replace(/'/g, '''); |
| 26 | } |
| 27 | |
| 28 | function encodePath(rel) { |
| 29 | return rel.split('/').map(encodeURIComponent).join('/'); |
| 30 | } |
| 31 | |
| 32 | function formatBytes(n) { |
| 33 | if (n < 1024) return `${n} B`; |
| 34 | const units = ['KB', 'MB', 'GB']; |
| 35 | let v = n / 1024; |
| 36 | let i = 0; |
| 37 | while (v >= 1024 && i < units.length - 1) { |
| 38 | v /= 1024; |
| 39 | i++; |
| 40 | } |
| 41 | return `${v.toFixed(v >= 100 ? 0 : 1)} ${units[i]}`; |
| 42 | } |
| 43 | |
| 44 | function highlightCode(code, lang) { |
| 45 | if (lang && lang !== 'plaintext' && hljs.getLanguage(lang)) { |
| 46 | try { |
| 47 | return { html: hljs.highlight(code, { language: lang, ignoreIllegals: true }).value, lang }; |
| 48 | } catch { |
| 49 | /* fall through */ |
| 50 | } |
| 51 | } |
| 52 | return { html: escapeHtml(code), lang: 'plaintext' }; |
| 53 | } |
| 54 | |
| 55 | /** |
| 56 | * Split highlighted HTML into lines, re-opening any <span> tags that were |
| 57 | * left open at a line break so each line is self-contained markup. |
| 58 | */ |
| 59 | function splitHighlightedLines(html) { |
| 60 | const lines = html.split('\n'); |
| 61 | const out = []; |
| 62 | let open = []; // stack of opening tags |
| 63 | const tagRe = /<span\b[^>]*>|<\/span>/g; |
| 64 | for (const line of lines) { |
| 65 | const prefix = open.join(''); |
| 66 | let m; |
| 67 | tagRe.lastIndex = 0; |
| 68 | while ((m = tagRe.exec(line))) { |
| 69 | if (m[0] === '</span>') open.pop(); |
| 70 | else open.push(m[0]); |
| 71 | } |
| 72 | const suffix = '</span>'.repeat(open.length); |
| 73 | out.push(prefix + line + suffix); |
| 74 | } |
| 75 | return out; |
| 76 | } |
| 77 | |
| 78 | /** Full source view with line numbers and anchors (#L12). */ |
| 79 | function renderCode(code, filename, opts = {}) { |
| 80 | const lang = opts.lang !== undefined ? opts.lang : languageFor(filename); |
| 81 | const bytes = Buffer.byteLength(code, 'utf8'); |
| 82 | const skipHighlight = bytes > HIGHLIGHT_LIMIT; |
| 83 | const { html, lang: usedLang } = skipHighlight ? { html: escapeHtml(code), lang: 'plaintext' } : highlightCode(code, lang); |
| 84 | const lines = splitHighlightedLines(html); |
| 85 | if (lines.length && lines[lines.length - 1] === '') lines.pop(); |
| 86 | const rows = lines |
| 87 | .map((l, i) => { |
| 88 | const n = i + 1; |
| 89 | return `<tr id="L${n}"><td class="ln"><a href="#L${n}" data-line="${n}">${n}</a></td><td class="lc">${l || '\n'}</td></tr>`; |
| 90 | }) |
| 91 | .join(''); |
| 92 | return { |
| 93 | html: `<div class="code-view hljs" data-lang="${escapeHtml(usedLang || 'plaintext')}"><table class="code-table"><tbody>${rows}</tbody></table></div>`, |
| 94 | lines: lines.length, |
| 95 | lang: usedLang, |
| 96 | skippedHighlight: skipHighlight, |
| 97 | }; |
| 98 | } |
| 99 | |
| 100 | /** Render each JSONL record as a pretty-printed, highlighted card. */ |
| 101 | function renderJsonl(text) { |
| 102 | const lines = text.split('\n').filter((l) => l.trim() !== ''); |
| 103 | const cards = lines.map((line, i) => { |
| 104 | let body; |
| 105 | let title = `Record ${i + 1}`; |
| 106 | try { |
| 107 | const obj = JSON.parse(line); |
| 108 | const pretty = JSON.stringify(obj, null, 2); |
| 109 | body = highlightCode(pretty, 'json').html; |
| 110 | const id = obj && (obj.id || obj.ID || obj.name || obj.slug); |
| 111 | if (id) title = `<span class="rec-id">${escapeHtml(String(id))}</span>`; |
| 112 | } catch { |
| 113 | body = escapeHtml(line); |
| 114 | title = `Record ${i + 1} <span class="badge warn">unparsable</span>`; |
| 115 | } |
| 116 | return `<details class="jsonl-rec" id="R${i + 1}" ${i < 25 ? 'open' : ''}><summary><span class="rec-n">#${i + 1}</span> ${title}</summary><pre class="hljs"><code>${body}</code></pre></details>`; |
| 117 | }); |
| 118 | return { html: `<div class="jsonl-view">${cards.join('')}</div>`, records: lines.length }; |
| 119 | } |
| 120 | |
| 121 | /* ------------------------------------------------------------------ */ |
| 122 | /* Markdown */ |
| 123 | /* ------------------------------------------------------------------ */ |
| 124 | |
| 125 | function isRelativeHref(href) { |
| 126 | if (!href) return false; |
| 127 | if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return false; // scheme (http:, mailto:, data:) |
| 128 | if (href.startsWith('/') || href.startsWith('#') || href.startsWith('//')) return false; |
| 129 | return true; |
| 130 | } |
| 131 | |
| 132 | /** Resolve an href relative to the markdown file's directory → repo path + hash. */ |
| 133 | function resolveRelative(href, fileDir) { |
| 134 | const [pathPart, hash] = href.split('#'); |
| 135 | const [cleanPath] = pathPart.split('?'); |
| 136 | const decoded = (() => { |
| 137 | try { |
| 138 | return decodeURIComponent(cleanPath); |
| 139 | } catch { |
| 140 | return cleanPath; |
| 141 | } |
| 142 | })(); |
| 143 | let target = path.posix.normalize(path.posix.join(fileDir || '', decoded)); |
| 144 | if (target === '.') target = ''; |
| 145 | if (target.startsWith('..')) return null; |
| 146 | return { target, hash: hash ? `#${hash}` : '' }; |
| 147 | } |
| 148 | |
| 149 | function createMarkdown({ fileDir, fileSet }) { |
| 150 | const marked = new Marked( |
| 151 | markedHighlight({ |
| 152 | emptyLangClass: 'hljs', |
| 153 | langPrefix: 'hljs language-', |
| 154 | highlight(code, lang) { |
| 155 | if (lang === 'mermaid') return code; // handled by renderer below |
| 156 | const language = hljs.getLanguage(lang) ? lang : 'plaintext'; |
| 157 | return hljs.highlight(code, { language, ignoreIllegals: true }).value; |
| 158 | }, |
| 159 | }), |
| 160 | gfmHeadingId(), |
| 161 | ); |
| 162 | |
| 163 | const renderer = { |
| 164 | code({ text, lang, escaped }) { |
| 165 | if ((lang || '').trim().toLowerCase() === 'mermaid') { |
| 166 | return `<div class="mermaid-block"><pre class="mermaid" data-src="${escapeHtml(text)}">${escapeHtml(text)}</pre><details class="mermaid-src"><summary>Diagram source</summary><pre class="hljs"><code>${escapeHtml(text)}</code></pre></details></div>\n`; |
| 167 | } |
| 168 | const language = (lang || '').trim().split(/\s+/)[0]; |
| 169 | const cls = language ? ` class="hljs language-${escapeHtml(language)}"` : ' class="hljs"'; |
| 170 | const body = escaped ? text : escapeHtml(text); |
| 171 | return `<div class="md-code"><button class="copy-btn" type="button" data-copy title="Copy">Copy</button><pre><code${cls}>${body}</code></pre></div>\n`; |
| 172 | }, |
| 173 | link({ href, title, tokens }) { |
| 174 | const text = this.parser.parseInline(tokens); |
| 175 | let out = href; |
| 176 | let extra = ''; |
| 177 | if (isRelativeHref(href)) { |
| 178 | const r = resolveRelative(href, fileDir); |
| 179 | if (r) { |
| 180 | const isDir = fileSet ? fileSet.dirs.has(r.target) : false; |
| 181 | const isFile = fileSet ? fileSet.files.has(r.target) : true; |
| 182 | if (isFile || isDir) out = `/files/${encodePath(r.target)}${r.hash}`; |
| 183 | else extra = ' class="broken-link" title="Target not found in repository"'; |
| 184 | } |
| 185 | } else if (/^https?:/i.test(href)) { |
| 186 | extra = ' target="_blank" rel="noopener noreferrer"'; |
| 187 | } |
| 188 | const t = title ? ` title="${escapeHtml(title)}"` : ''; |
| 189 | return `<a href="${escapeHtml(out)}"${t}${extra}>${text}</a>`; |
| 190 | }, |
| 191 | image({ href, title, text }) { |
| 192 | let out = href; |
| 193 | if (isRelativeHref(href)) { |
| 194 | const r = resolveRelative(href, fileDir); |
| 195 | if (r) out = `/raw/${encodePath(r.target)}`; |
| 196 | } |
| 197 | const t = title ? ` title="${escapeHtml(title)}"` : ''; |
| 198 | return `<img src="${escapeHtml(out)}" alt="${escapeHtml(text || '')}"${t} loading="lazy">`; |
| 199 | }, |
| 200 | table(token) { |
| 201 | // Wrap tables so wide ontology registers scroll instead of overflowing. |
| 202 | const header = token.header.map((c) => this.tablecell(c)).join(''); |
| 203 | const rows = token.rows.map((r) => `<tr>${r.map((c) => this.tablecell(c)).join('')}</tr>`).join(''); |
| 204 | return `<div class="table-wrap"><table><thead><tr>${header}</tr></thead><tbody>${rows}</tbody></table></div>\n`; |
| 205 | }, |
| 206 | }; |
| 207 | marked.use({ renderer, gfm: true, breaks: false }); |
| 208 | return marked; |
| 209 | } |
| 210 | |
| 211 | /** Extract YAML front matter if present. */ |
| 212 | function splitFrontMatter(text) { |
| 213 | const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text); |
| 214 | if (!m) return { frontMatter: null, body: text }; |
| 215 | return { frontMatter: m[1], body: text.slice(m[0].length) }; |
| 216 | } |
| 217 | |
| 218 | /** Collect h1–h3 headings (id + text) for the table of contents. */ |
| 219 | function extractHeadings(html) { |
| 220 | const out = []; |
| 221 | const re = /<h([1-3])\s+id="([^"]+)"[^>]*>([\s\S]*?)<\/h\1>/g; |
| 222 | let m; |
| 223 | while ((m = re.exec(html))) { |
| 224 | const text = m[3].replace(/<[^>]+>/g, '').trim(); |
| 225 | out.push({ level: Number(m[1]), id: m[2], text }); |
| 226 | } |
| 227 | return out; |
| 228 | } |
| 229 | |
| 230 | function renderMarkdown(text, relPath, fileSet) { |
| 231 | const fileDir = path.posix.dirname(relPath); |
| 232 | const { frontMatter, body } = splitFrontMatter(text); |
| 233 | const marked = createMarkdown({ fileDir: fileDir === '.' ? '' : fileDir, fileSet }); |
| 234 | const html = marked.parse(body); |
| 235 | const headings = extractHeadings(html); |
| 236 | const mermaidCount = (html.match(/<pre class="mermaid"/g) || []).length; |
| 237 | let fm = ''; |
| 238 | if (frontMatter) { |
| 239 | fm = `<details class="front-matter" open><summary>Front matter</summary><pre class="hljs"><code>${highlightCode(frontMatter, 'yaml').html}</code></pre></details>`; |
| 240 | } |
| 241 | return { html: fm + html, headings, mermaidCount }; |
| 242 | } |
| 243 | |
| 244 | module.exports = { |
| 245 | escapeHtml, |
| 246 | encodePath, |
| 247 | formatBytes, |
| 248 | renderCode, |
| 249 | renderJsonl, |
| 250 | renderMarkdown, |
| 251 | highlightCode, |
| 252 | languageFor, |
| 253 | HIGHLIGHT_LIMIT, |
| 254 | TEXT_LIMIT, |
| 255 | }; |