| 1 | ; |
| 2 | |
| 3 | const path = require('path'); |
| 4 | const express = require('express'); |
| 5 | const compression = require('compression'); |
| 6 | const mime = require('mime-types'); |
| 7 | |
| 8 | const { Repo } = require('./lib/repo'); |
| 9 | const render = require('./lib/render'); |
| 10 | const pages = require('./lib/pages'); |
| 11 | const docnav = require('./lib/docnav'); |
| 12 | |
| 13 | const { escapeHtml, encodePath, formatBytes } = render; |
| 14 | |
| 15 | const PORT = Number(process.env.PORT) || 3000; |
| 16 | const REPO_ROOT = process.env.REPO_ROOT || path.resolve(__dirname, '..'); |
| 17 | const REPORT_PATH = process.env.REPORT_PATH || 'property-ontology/documentation/ontology-board-report.html'; |
| 18 | const ATLAS_PATH = process.env.ATLAS_PATH || 'property-ontology/documentation/semantic-atlas.html'; |
| 19 | const README_PATH = process.env.README_PATH || 'property-ontology/README.md'; |
| 20 | |
| 21 | const repo = new Repo(REPO_ROOT); |
| 22 | const app = express(); |
| 23 | |
| 24 | app.disable('x-powered-by'); |
| 25 | app.set('etag', 'weak'); |
| 26 | app.use(compression()); |
| 27 | app.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 | |
| 33 | app.use('/static', express.static(path.join(__dirname, 'public'), { maxAge: '1h' })); |
| 34 | app.use('/vendor/hljs', express.static(path.join(path.dirname(require.resolve('highlight.js/package.json')), 'styles'), { maxAge: '7d', immutable: true })); |
| 35 | app.get('/favicon.svg', (req, res) => res.sendFile(path.join(__dirname, 'public', 'favicon.svg'))); |
| 36 | app.get('/healthz', (req, res) => res.json({ ok: true })); |
| 37 | |
| 38 | /* ------------------------------------------------------------------ */ |
| 39 | /* Helpers */ |
| 40 | /* ------------------------------------------------------------------ */ |
| 41 | |
| 42 | const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); |
| 43 | |
| 44 | function 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 | |
| 53 | async 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 | |
| 65 | function 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 | |
| 74 | const 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 | ]); |
| 79 | const TEXT_NAMES = new Set(['dockerfile', 'makefile', 'license', 'readme', 'procfile', '.gitignore', '.dockerignore']); |
| 80 | const BINARY_EXT = new Set(['docx', 'doc', 'xlsx', 'pptx', 'pdf', 'zip', 'gz', 'tar', 'tgz', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'woff', 'woff2', 'ttf', 'bin']); |
| 81 | const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg']); |
| 82 | |
| 83 | function 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 | |
| 97 | app.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 | |
| 174 | function 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 | |
| 186 | app.get('/report', docRoute(REPORT_PATH, 'The board report', 'report')); |
| 187 | app.get('/atlas', docRoute(ATLAS_PATH, 'The semantic atlas', 'atlas')); |
| 188 | |
| 189 | app.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 | |
| 198 | app.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 | |
| 209 | app.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 | |
| 300 | app.use((req, res) => notFound(res)); |
| 301 | |
| 302 | // eslint-disable-next-line no-unused-vars |
| 303 | app.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 | |
| 313 | app.listen(PORT, '0.0.0.0', () => { |
| 314 | console.log(`property-ontology explorer listening on :${PORT}`); |
| 315 | console.log(`repo root: ${repo.root}`); |
| 316 | }); |
| 317 |