pboProperty Ontology
javascript317 lines14.9 KB
RawDownload
1'use strict';
2
3const path = require('path');
4const express = require('express');
5const compression = require('compression');
6const mime = require('mime-types');
7
8const { Repo } = require('./lib/repo');
9const render = require('./lib/render');
10const pages = require('./lib/pages');
11const docnav = require('./lib/docnav');
12
13const { escapeHtml, encodePath, formatBytes } = render;
14
15const PORT = Number(process.env.PORT) || 3000;
16const REPO_ROOT = process.env.REPO_ROOT || path.resolve(__dirname, '..');
17const REPORT_PATH = process.env.REPORT_PATH || 'property-ontology/documentation/ontology-board-report.html';
18const ATLAS_PATH = process.env.ATLAS_PATH || 'property-ontology/documentation/semantic-atlas.html';
19const README_PATH = process.env.README_PATH || 'property-ontology/README.md';
20
21const repo = new Repo(REPO_ROOT);
22const app = express();
23
24app.disable('x-powered-by');
25app.set('etag', 'weak');
26app.use(compression());
27app.use((req, res, next) => {
28 res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
29 res.setHeader('X-Content-Type-Options', 'nosniff');
30 next();
31});
32
33app.use('/static', express.static(path.join(__dirname, 'public'), { maxAge: '1h' }));
34app.use('/vendor/hljs', express.static(path.join(path.dirname(require.resolve('highlight.js/package.json')), 'styles'), { maxAge: '7d', immutable: true }));
35app.get('/favicon.svg', (req, res) => res.sendFile(path.join(__dirname, 'public', 'favicon.svg')));
36app.get('/healthz', (req, res) => res.json({ ok: true }));
37
38/* ------------------------------------------------------------------ */
39/* Helpers */
40/* ------------------------------------------------------------------ */
41
42const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
43
44function notFound(res, what = 'Not found') {
45 res.status(404).send(
46 pages.layout({
47 title: '404',
48 body: `<div class="empty"><h1>404</h1><p>${escapeHtml(what)}</p><p><a href="/files">Browse the repository</a></p></div>`,
49 }),
50 );
51}
52
53async function fileSet() {
54 const files = await repo.allFiles();
55 const dirs = new Set(['']);
56 const set = new Set();
57 for (const f of files) {
58 set.add(f.path);
59 const parts = f.path.split('/');
60 for (let i = 1; i < parts.length; i++) dirs.add(parts.slice(0, i).join('/'));
61 }
62 return { files: set, dirs };
63}
64
65function sendRepoFile(res, rel, abs, download) {
66 const type = mime.lookup(rel) || 'application/octet-stream';
67 const charsetType = mime.contentType(type) || type;
68 res.setHeader('Content-Type', charsetType);
69 if (download) res.setHeader('Content-Disposition', `attachment; filename="${path.posix.basename(rel).replace(/"/g, '')}"`);
70 res.setHeader('Cache-Control', 'public, max-age=300');
71 res.sendFile(abs);
72}
73
74const TEXT_EXT = new Set([
75 'md', 'markdown', 'txt', 'py', 'js', 'mjs', 'cjs', 'ts', 'json', 'jsonl', 'yaml', 'yml', 'ttl', 'n3',
76 'trig', 'rq', 'sparql', 'sh', 'bash', 'html', 'htm', 'xml', 'svg', 'css', 'toml', 'ini', 'cfg', 'csv',
77 'tsv', 'log', 'sql', 'gitignore', 'dockerignore', 'env', 'lock',
78]);
79const TEXT_NAMES = new Set(['dockerfile', 'makefile', 'license', 'readme', 'procfile', '.gitignore', '.dockerignore']);
80const BINARY_EXT = new Set(['docx', 'doc', 'xlsx', 'pptx', 'pdf', 'zip', 'gz', 'tar', 'tgz', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'woff', 'woff2', 'ttf', 'bin']);
81const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg']);
82
83function classify(name, size) {
84 const ext = pages.extOf(name);
85 const lower = name.toLowerCase();
86 if (BINARY_EXT.has(ext) && !IMAGE_EXT.has(ext)) return 'binary';
87 if (IMAGE_EXT.has(ext)) return ext === 'svg' ? 'svg' : 'image';
88 if (TEXT_EXT.has(ext) || TEXT_NAMES.has(lower)) return 'text';
89 // Unknown extension: treat small files as text, big as binary.
90 return size < 512 * 1024 ? 'text' : 'binary';
91}
92
93/* ------------------------------------------------------------------ */
94/* Routes */
95/* ------------------------------------------------------------------ */
96
97app.get(
98 '/',
99 wrap(async (req, res) => {
100 const tree = await repo.tree();
101 const files = await repo.allFiles();
102 const byExt = new Map();
103 for (const f of files) {
104 const e = pages.extOf(f.name) || 'other';
105 byExt.set(e, (byExt.get(e) || 0) + 1);
106 }
107 const extStats = [...byExt.entries()]
108 .sort((a, b) => b[1] - a[1])
109 .slice(0, 12)
110 .map(([e, n]) => `<span class="chip"><span class="fi fi-${escapeHtml(e)}">${pages.iconFor(`x.${e}`)}</span>.${escapeHtml(e)} <b>${n}</b></span>`)
111 .join('');
112
113 const topDirs = tree.children
114 .filter((c) => c.type === 'dir')
115 .map(
116 (d) => `<a class="dir-card" href="/files/${encodePath(d.path)}"><div class="dir-card-name">${escapeHtml(d.name)}/</div><div class="dir-card-meta">${d.fileCount} files · ${formatBytes(d.totalSize)}</div></a>`,
117 )
118 .join('');
119 const topFiles = tree.children
120 .filter((c) => c.type === 'file')
121 .map((f) => `<a class="chip" href="/files/${encodePath(f.path)}">${escapeHtml(f.name)}</a>`)
122 .join('');
123
124 const [reportStat, atlasStat] = await Promise.all([repo.stat(REPORT_PATH), repo.stat(ATLAS_PATH)]);
125
126 let readmeHtml = '';
127 const readmeStat = await repo.stat(README_PATH);
128 if (readmeStat && readmeStat.isFile) {
129 const text = await repo.readFile(README_PATH, 'utf8');
130 readmeHtml = render.renderMarkdown(text, README_PATH, await fileSet()).html;
131 }
132
133 const body = `
134<section class="hero">
135 <p class="kicker">Release candidate 0.1.0</p>
136 <h1>Australian Property Investment, Accommodation and Operations Ontology</h1>
137 <p class="lead">An evidence-first OWL 2 DL ontology (<code>pbo:</code>) derived from a corpus of Australian property investment, rooming-house, coliving, commercial-leasing and property-management sources. Browse the board briefing, the fully generated semantic atlas, or explore every file in the repository with rendered Markdown, Turtle, SPARQL, YAML and Python.</p>
138</section>
139<section class="cards">
140 <a class="card card-report" href="/report">
141 <div class="card-kicker">Presentation</div>
142 <h2>Board briefing report</h2>
143 <p>Slide-style briefing: what the ontology is, its shape, how the pieces connect, evidence quality, test results and what remains before release.</p>
144 <div class="card-foot">${reportStat ? formatBytes(reportStat.size) : 'missing'} · HTML</div>
145 </a>
146 <a class="card card-atlas" href="/atlas">
147 <div class="card-kicker">Reference</div>
148 <h2>Semantic atlas</h2>
149 <p>Complete data-driven projection of the bundle: class profiles, property registers, SKOS vocabularies, SHACL overlay, instance threads, competency questions and traceability.</p>
150 <div class="card-foot">${atlasStat ? formatBytes(atlasStat.size) : 'missing'} · HTML with 190+ Mermaid diagrams</div>
151 </a>
152 <a class="card card-files" href="/files">
153 <div class="card-kicker">Source</div>
154 <h2>Repository explorer</h2>
155 <p>Every file in the repo, rendered for humans: Markdown with diagrams and tables, syntax-highlighted Turtle, SPARQL, YAML, JSON and Python, plus downloads for binaries.</p>
156 <div class="card-foot">${tree.fileCount} files · ${formatBytes(tree.totalSize)}</div>
157 </a>
158</section>
159<section class="section">
160 <h2 class="section-title">Top-level folders</h2>
161 <div class="dir-grid">${topDirs}</div>
162 ${topFiles ? `<div class="chips">${topFiles}</div>` : ''}
163</section>
164<section class="section">
165 <h2 class="section-title">File types</h2>
166 <div class="chips">${extStats}</div>
167</section>
168${readmeHtml ? `<section class="readme"><div class="readme-head"><a href="/files/${encodePath(README_PATH)}">${escapeHtml(README_PATH)}</a></div><article class="markdown-body">${readmeHtml}</article></section>` : ''}`;
169
170 res.send(pages.layout({ title: '', body, activeNav: 'home', bodyClass: 'page-home' }));
171 }),
172);
173
174function docRoute(relPath, label, navKey) {
175 return wrap(async (req, res) => {
176 const rel = repo.safeRelative(relPath);
177 const st = rel !== null ? await repo.stat(rel) : null;
178 if (!st || !st.isFile) return notFound(res, `${label} has not been generated yet (${relPath}).`);
179 res.setHeader('Content-Type', 'text/html; charset=utf-8');
180 res.setHeader('Cache-Control', 'public, max-age=300');
181 if ('raw' in req.query) return res.sendFile(st.abs);
182 res.send(await docnav.withNav(st.abs, navKey));
183 });
184}
185
186app.get('/report', docRoute(REPORT_PATH, 'The board report', 'report'));
187app.get('/atlas', docRoute(ATLAS_PATH, 'The semantic atlas', 'atlas'));
188
189app.get(
190 '/api/tree',
191 wrap(async (req, res) => {
192 const files = await repo.allFiles();
193 res.setHeader('Cache-Control', 'public, max-age=60');
194 res.json(files.map((f) => ({ p: f.path, s: f.size })));
195 }),
196);
197
198app.get(
199 /^\/raw(?:\/(.*))?$/,
200 wrap(async (req, res) => {
201 const rel = repo.safeRelative(req.params[0] || '');
202 if (rel === null || rel === '') return notFound(res);
203 const st = await repo.stat(rel);
204 if (!st || !st.isFile) return notFound(res);
205 sendRepoFile(res, rel, st.abs, 'download' in req.query);
206 }),
207);
208
209app.get(
210 /^\/files(?:\/(.*))?$/,
211 wrap(async (req, res) => {
212 const rel = repo.safeRelative(req.params[0] || '');
213 if (rel === null) return notFound(res);
214 const st = await repo.stat(rel);
215 if (!st) return notFound(res, `No such path: ${rel}`);
216 const tree = await repo.tree();
217 const sidebar = pages.sidebarTree(tree, rel);
218
219 if (st.isDir) {
220 const node = await repo.node(rel);
221 if (!node) return notFound(res);
222 let readmeHtml = '';
223 const readme = node.children.find((c) => c.type === 'file' && /^readme\.(md|markdown)$/i.test(c.name));
224 if (readme) {
225 const text = await repo.readFile(readme.path, 'utf8');
226 readmeHtml = render.renderMarkdown(text, readme.path, await fileSet()).html;
227 }
228 const body = pages.directoryListing(node, readmeHtml);
229 return res.send(pages.layout({ title: rel || 'repo', body, activeNav: 'files', sidebar, bodyClass: 'page-dir' }));
230 }
231
232 if (!st.isFile) return notFound(res);
233 const name = path.posix.basename(rel);
234 const ext = pages.extOf(name);
235 const kind = classify(name, st.size);
236 const rawUrl = `/raw/${encodePath(rel)}`;
237 const actions = [
238 `<a class="btn" href="${rawUrl}" target="_blank" rel="noopener">Raw</a>`,
239 `<a class="btn" href="${rawUrl}?download">Download</a>`,
240 `<button class="btn" type="button" data-copy-text="${escapeHtml(rel)}">Copy path</button>`,
241 ];
242
243 let content = '';
244 let aside = '';
245 const meta = [];
246 let head = '';
247
248 if (kind === 'binary') {
249 meta.push(escapeHtml(ext || 'binary'));
250 content = `<div class="binary-card"><div class="binary-icon">${pages.iconFor(name)}</div><h2>${escapeHtml(name)}</h2><p>This is a binary file (${escapeHtml(ext || 'unknown type')}) and cannot be rendered in the browser.</p><p><a class="btn primary" href="${rawUrl}?download">Download ${formatBytes(st.size)}</a></p></div>`;
251 } else if (kind === 'image') {
252 meta.push('image');
253 content = `<div class="image-view"><img src="${rawUrl}" alt="${escapeHtml(name)}"></div>`;
254 } else if (st.size > render.TEXT_LIMIT) {
255 meta.push(escapeHtml(ext || 'text'), 'too large to render inline');
256 content = `<div class="binary-card"><div class="binary-icon">${pages.iconFor(name)}</div><h2>${escapeHtml(name)}</h2><p>This file is ${formatBytes(st.size)}, above the inline rendering limit (${formatBytes(render.TEXT_LIMIT)}).</p><p><a class="btn primary" href="${rawUrl}" target="_blank" rel="noopener">Open raw</a> <a class="btn" href="${rawUrl}?download">Download</a></p></div>`;
257 } else {
258 const text = await repo.readFile(rel, 'utf8');
259 const lineCount = text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
260 const wantsSource = 'source' in req.query;
261
262 if ((ext === 'md' || ext === 'markdown') && !wantsSource) {
263 const md = render.renderMarkdown(text, rel, await fileSet());
264 meta.push('markdown', `${lineCount} lines`);
265 if (md.mermaidCount) meta.push(`${md.mermaidCount} diagram${md.mermaidCount === 1 ? '' : 's'}`);
266 actions.unshift(`<a class="btn active" href="?">Rendered</a><a class="btn" href="?source">Source</a>`);
267 content = `<article class="markdown-body">${md.html}</article>`;
268 aside = pages.tocAside(md.headings);
269 if (md.mermaidCount) head = `<script type="module" src="/static/mermaid-init.js"></script>`;
270 } else if (ext === 'jsonl' && !wantsSource) {
271 const j = render.renderJsonl(text);
272 meta.push('JSON Lines', `${j.records} records`);
273 actions.unshift(`<a class="btn active" href="?">Records</a><a class="btn" href="?source">Source</a>`);
274 content = j.html;
275 } else if (kind === 'svg' && !wantsSource) {
276 meta.push('svg');
277 actions.unshift(`<a class="btn active" href="?">Image</a><a class="btn" href="?source">Source</a>`);
278 content = `<div class="image-view"><img src="${rawUrl}" alt="${escapeHtml(name)}"></div>`;
279 } else if ((ext === 'html' || ext === 'htm') && !wantsSource) {
280 meta.push('html', `${lineCount} lines`);
281 actions.unshift(`<a class="btn active" href="?">Preview</a><a class="btn" href="?source">Source</a>`);
282 content = `<div class="html-preview"><div class="html-preview-bar">Sandboxed preview · <a href="${rawUrl}" target="_blank" rel="noopener">open full page ↗</a></div><iframe src="${rawUrl}" sandbox="allow-scripts allow-same-origin" title="${escapeHtml(name)}" loading="lazy"></iframe></div>`;
283 } else {
284 const code = render.renderCode(text, name);
285 meta.push(escapeHtml(code.lang || 'text'), `${code.lines} lines`);
286 if (code.skippedHighlight) meta.push('highlighting skipped (large file)');
287 if (ext === 'md' || ext === 'markdown' || ext === 'jsonl' || kind === 'svg' || ext === 'html' || ext === 'htm') {
288 actions.unshift(`<a class="btn" href="?">${ext === 'jsonl' ? 'Records' : ext === 'svg' ? 'Image' : ext === 'html' || ext === 'htm' ? 'Preview' : 'Rendered'}</a><a class="btn active" href="?source">Source</a>`);
289 }
290 actions.push(`<button class="btn" type="button" id="wrap-toggle" title="Toggle line wrapping">Wrap</button>`);
291 content = code.html;
292 }
293 }
294
295 const body = pages.fileHeader({ relPath: rel, size: st.size, meta, actions }) + `<div class="file-body">${content}</div>`;
296 res.send(pages.layout({ title: name, body, activeNav: 'files', sidebar, aside, head, bodyClass: `page-file kind-${kind}` }));
297 }),
298);
299
300app.use((req, res) => notFound(res));
301
302// eslint-disable-next-line no-unused-vars
303app.use((err, req, res, next) => {
304 console.error(err);
305 res.status(500).send(
306 pages.layout({
307 title: 'Error',
308 body: `<div class="empty"><h1>Something went wrong</h1><pre>${escapeHtml(err.message || String(err))}</pre></div>`,
309 }),
310 );
311});
312
313app.listen(PORT, '0.0.0.0', () => {
314 console.log(`property-ontology explorer listening on :${PORT}`);
315 console.log(`repo root: ${repo.root}`);
316});
317