pboProperty Ontology
javascript255 lines9.3 KB
RawDownload
1'use strict';
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
7const path = require('path');
8const hljs = require('highlight.js');
9const { Marked } = require('marked');
10const { markedHighlight } = require('marked-highlight');
11const { gfmHeadingId } = require('marked-gfm-heading-id');
12const { registerLanguages, languageFor } = require('./languages');
13
14registerLanguages(hljs);
15
16const HIGHLIGHT_LIMIT = 600 * 1024; // bytes; above this we skip syntax colouring
17const TEXT_LIMIT = 3 * 1024 * 1024; // bytes; above this we do not inline at all
18
19function escapeHtml(s) {
20 return String(s)
21 .replace(/&/g, '&')
22 .replace(/</g, '&lt;')
23 .replace(/>/g, '&gt;')
24 .replace(/"/g, '&quot;')
25 .replace(/'/g, '&#39;');
26}
27
28function encodePath(rel) {
29 return rel.split('/').map(encodeURIComponent).join('/');
30}
31
32function 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
44function 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 */
59function 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). */
79function 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. */
101function 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
125function 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. */
133function 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
149function 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. */
212function 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. */
219function 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
230function 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
244module.exports = {
245 escapeHtml,
246 encodePath,
247 formatBytes,
248 renderCode,
249 renderJsonl,
250 renderMarkdown,
251 highlightCode,
252 languageFor,
253 HIGHLIGHT_LIMIT,
254 TEXT_LIMIT,
255};