| 1 | ; |
| 2 | |
| 3 | // HTML page templates. Plain template strings; every dynamic value is |
| 4 | // escaped by the caller or here. |
| 5 | |
| 6 | const { escapeHtml, encodePath, formatBytes } = require('./render'); |
| 7 | |
| 8 | const SITE = { |
| 9 | title: 'Property Ontology', |
| 10 | subtitle: 'Australian Property Investment, Accommodation and Operations Ontology (pbo)', |
| 11 | }; |
| 12 | |
| 13 | const FILE_ICONS = { |
| 14 | md: 'M', markdown: 'M', py: 'Py', js: 'JS', mjs: 'JS', json: '{}', jsonl: '{}', yaml: 'Y', yml: 'Y', |
| 15 | ttl: 'RDF', rq: 'SPARQL', sh: '$', html: '<>', svg: 'SVG', txt: 'T', docx: 'DOC', zip: 'ZIP', |
| 16 | }; |
| 17 | |
| 18 | function extOf(name) { |
| 19 | const i = name.lastIndexOf('.'); |
| 20 | return i === -1 ? '' : name.slice(i + 1).toLowerCase(); |
| 21 | } |
| 22 | |
| 23 | function iconFor(name) { |
| 24 | return FILE_ICONS[extOf(name)] || '·'; |
| 25 | } |
| 26 | |
| 27 | function layout({ title, body, activeNav, sidebar = '', aside = '', bodyClass = '', head = '' }) { |
| 28 | const nav = [ |
| 29 | ['/', 'Home', 'home'], |
| 30 | ['/report', 'Board report', 'report'], |
| 31 | ['/atlas', 'Semantic atlas', 'atlas'], |
| 32 | ['/files', 'Files', 'files'], |
| 33 | ] |
| 34 | .map(([href, label, key]) => `<a href="${href}" class="nav-link${activeNav === key ? ' active' : ''}">${label}</a>`) |
| 35 | .join(''); |
| 36 | return `<!doctype html> |
| 37 | <html lang="en"> |
| 38 | <head> |
| 39 | <meta charset="utf-8"> |
| 40 | <meta name="viewport" content="width=device-width,initial-scale=1"> |
| 41 | <title>${escapeHtml(title ? `${title} · ${SITE.title}` : SITE.title)}</title> |
| 42 | <meta name="description" content="${escapeHtml(SITE.subtitle)}"> |
| 43 | <link rel="icon" href="/favicon.svg" type="image/svg+xml"> |
| 44 | <link rel="stylesheet" href="/vendor/hljs/github.css" media="(prefers-color-scheme: light)" data-theme-css="light"> |
| 45 | <link rel="stylesheet" href="/vendor/hljs/github-dark.css" media="(prefers-color-scheme: dark)" data-theme-css="dark"> |
| 46 | <link rel="stylesheet" href="/static/style.css"> |
| 47 | <script>(function(){try{var t=localStorage.getItem('theme');if(t){document.documentElement.dataset.theme=t;}}catch(e){}})();</script> |
| 48 | ${head} |
| 49 | </head> |
| 50 | <body class="${escapeHtml(bodyClass)}"> |
| 51 | <header class="topbar"> |
| 52 | <a class="brand" href="/"><span class="brand-mark">pbo</span><span class="brand-text">${SITE.title}</span></a> |
| 53 | <nav class="topnav">${nav}</nav> |
| 54 | <div class="topbar-actions"> |
| 55 | <button type="button" class="icon-btn" id="search-open" title="Find file (press /)"><span class="kbd">/</span> Find file</button> |
| 56 | <button type="button" class="icon-btn" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme">◐</button> |
| 57 | </div> |
| 58 | </header> |
| 59 | <div class="shell${sidebar ? ' has-sidebar' : ''}${aside ? ' has-aside' : ''}"> |
| 60 | ${sidebar ? `<aside class="sidebar" id="sidebar">${sidebar}</aside>` : ''} |
| 61 | <main class="main" id="main">${body}</main> |
| 62 | ${aside ? `<aside class="aside" id="aside">${aside}</aside>` : ''} |
| 63 | </div> |
| 64 | <dialog id="search-dialog" class="search-dialog"> |
| 65 | <form method="dialog" class="search-form"> |
| 66 | <input type="search" id="search-input" placeholder="Type to find a file…" autocomplete="off" spellcheck="false"> |
| 67 | <button type="submit" class="icon-btn" aria-label="Close">Esc</button> |
| 68 | </form> |
| 69 | <ul id="search-results" class="search-results"></ul> |
| 70 | </dialog> |
| 71 | <footer class="footer"> |
| 72 | <span>${escapeHtml(SITE.subtitle)}</span> |
| 73 | <span><a href="https://github.com/rdb420/property_ontology" target="_blank" rel="noopener">GitHub</a></span> |
| 74 | </footer> |
| 75 | <script src="/static/app.js" defer></script> |
| 76 | </body> |
| 77 | </html>`; |
| 78 | } |
| 79 | |
| 80 | /* ------------------------------------------------------------------ */ |
| 81 | |
| 82 | function breadcrumbs(relPath, isDir) { |
| 83 | const parts = relPath ? relPath.split('/') : []; |
| 84 | const items = [`<a href="/files">repo</a>`]; |
| 85 | let acc = ''; |
| 86 | parts.forEach((p, i) => { |
| 87 | acc = acc ? `${acc}/${p}` : p; |
| 88 | const last = i === parts.length - 1; |
| 89 | if (last && !isDir) items.push(`<span class="crumb-current">${escapeHtml(p)}</span>`); |
| 90 | else if (last) items.push(`<a class="crumb-current" href="/files/${encodePath(acc)}">${escapeHtml(p)}</a>`); |
| 91 | else items.push(`<a href="/files/${encodePath(acc)}">${escapeHtml(p)}</a>`); |
| 92 | }); |
| 93 | return `<nav class="crumbs" aria-label="Breadcrumb">${items.join('<span class="crumb-sep">/</span>')}</nav>`; |
| 94 | } |
| 95 | |
| 96 | /** Nested sidebar tree. Directories containing the current path are open. */ |
| 97 | function sidebarTree(root, currentPath) { |
| 98 | const openDirs = new Set(); |
| 99 | if (currentPath) { |
| 100 | const parts = currentPath.split('/'); |
| 101 | let acc = ''; |
| 102 | for (const p of parts) { |
| 103 | acc = acc ? `${acc}/${p}` : p; |
| 104 | openDirs.add(acc); |
| 105 | } |
| 106 | } |
| 107 | const renderNode = (node, depth) => { |
| 108 | if (node.type === 'dir') { |
| 109 | const open = depth === 0 || openDirs.has(node.path); |
| 110 | const kids = node.children.map((c) => renderNode(c, depth + 1)).join(''); |
| 111 | const label = depth === 0 ? 'repo' : escapeHtml(node.name); |
| 112 | return `<li class="tree-dir" data-path="${escapeHtml(node.path)}"><details${open ? ' open' : ''}><summary><span class="tree-caret"></span><a href="/files/${encodePath(node.path)}" class="tree-link dir${node.path === currentPath ? ' current' : ''}">${label}</a><span class="tree-count">${node.fileCount}</span></summary><ul>${kids}</ul></details></li>`; |
| 113 | } |
| 114 | return `<li class="tree-file" data-path="${escapeHtml(node.path)}" data-name="${escapeHtml(node.name.toLowerCase())}"><a href="/files/${encodePath(node.path)}" class="tree-link file${node.path === currentPath ? ' current' : ''}"><span class="fi fi-${escapeHtml(extOf(node.name) || 'none')}">${iconFor(node.name)}</span><span class="tree-name">${escapeHtml(node.name)}</span></a></li>`; |
| 115 | }; |
| 116 | return `<div class="sidebar-head"><input type="search" id="tree-filter" placeholder="Filter files…" autocomplete="off" spellcheck="false"><span class="tree-total">${root.fileCount} files</span></div><ul class="tree" id="tree">${renderNode(root, 0)}</ul>`; |
| 117 | } |
| 118 | |
| 119 | function directoryListing(node, readmeHtml) { |
| 120 | const rows = node.children |
| 121 | .map((c) => { |
| 122 | if (c.type === 'dir') { |
| 123 | return `<tr><td class="col-icon"><span class="fi fi-dir">▸</span></td><td class="col-name"><a href="/files/${encodePath(c.path)}">${escapeHtml(c.name)}/</a></td><td class="col-meta">${c.fileCount} file${c.fileCount === 1 ? '' : 's'}</td><td class="col-size">${formatBytes(c.totalSize)}</td></tr>`; |
| 124 | } |
| 125 | return `<tr><td class="col-icon"><span class="fi fi-${escapeHtml(extOf(c.name) || 'none')}">${iconFor(c.name)}</span></td><td class="col-name"><a href="/files/${encodePath(c.path)}">${escapeHtml(c.name)}</a></td><td class="col-meta">${escapeHtml(extOf(c.name) || 'file')}</td><td class="col-size">${formatBytes(c.size)}</td></tr>`; |
| 126 | }) |
| 127 | .join(''); |
| 128 | const parent = |
| 129 | node.path !== '' |
| 130 | ? `<tr class="row-parent"><td class="col-icon">↰</td><td class="col-name"><a href="/files/${encodePath(node.path.split('/').slice(0, -1).join('/'))}">..</a></td><td></td><td></td></tr>` |
| 131 | : ''; |
| 132 | return ` |
| 133 | <div class="file-head"> |
| 134 | ${breadcrumbs(node.path, true)} |
| 135 | <div class="file-meta"><span>${node.children.filter((c) => c.type === 'dir').length} folders</span><span>${node.fileCount} files</span><span>${formatBytes(node.totalSize)}</span></div> |
| 136 | </div> |
| 137 | <div class="listing-wrap"><table class="listing"><tbody>${parent}${rows}</tbody></table></div> |
| 138 | ${readmeHtml ? `<section class="readme"><div class="readme-head">README.md</div><article class="markdown-body">${readmeHtml}</article></section>` : ''}`; |
| 139 | } |
| 140 | |
| 141 | function fileHeader({ relPath, size, meta, actions }) { |
| 142 | const metaHtml = meta.map((m) => `<span>${m}</span>`).join(''); |
| 143 | return `<div class="file-head"> |
| 144 | ${breadcrumbs(relPath, false)} |
| 145 | <div class="file-meta">${metaHtml}<span>${formatBytes(size)}</span></div> |
| 146 | <div class="file-actions">${actions.join('')}</div> |
| 147 | </div>`; |
| 148 | } |
| 149 | |
| 150 | function tocAside(headings) { |
| 151 | if (!headings || headings.length < 2) return ''; |
| 152 | const items = headings |
| 153 | .map((h) => `<li class="toc-l${h.level}"><a href="#${escapeHtml(h.id)}">${escapeHtml(h.text)}</a></li>`) |
| 154 | .join(''); |
| 155 | return `<div class="toc"><div class="toc-title">On this page</div><ul>${items}</ul></div>`; |
| 156 | } |
| 157 | |
| 158 | module.exports = { layout, breadcrumbs, sidebarTree, directoryListing, fileHeader, tocAside, SITE, extOf, iconFor }; |