| 1 | ; |
| 2 | |
| 3 | // Injects the site navigation bar into the standalone generated documents |
| 4 | // (board report, semantic atlas) when they are served at /report and /atlas. |
| 5 | // The source HTML files are left untouched; the bar is added in memory. |
| 6 | // Behaviour: fixed at the top, slides away when scrolling down, returns when |
| 7 | // scrolling up or when the pointer reaches the top edge. |
| 8 | |
| 9 | const fsp = require('fs/promises'); |
| 10 | |
| 11 | const NAV_HEIGHT = 44; |
| 12 | |
| 13 | function navMarkup(active) { |
| 14 | const links = [ |
| 15 | ['/', 'Home', 'home'], |
| 16 | ['/report', 'Board report', 'report'], |
| 17 | ['/atlas', 'Semantic atlas', 'atlas'], |
| 18 | ['/files', 'Files', 'files'], |
| 19 | ] |
| 20 | .map(([href, label, key]) => `<a href="${href}"${key === active ? ' class="pbo-active"' : ''}>${label}</a>`) |
| 21 | .join(''); |
| 22 | |
| 23 | return ` |
| 24 | <style id="pbo-docnav-style"> |
| 25 | #pbo-docnav{position:fixed;top:0;left:0;right:0;height:${NAV_HEIGHT}px;z-index:2147483000;display:flex;align-items:center;gap:14px;padding:0 16px;background:#1a2332;color:#f1faee;border-bottom:1px solid rgba(255,255,255,.08);font:14px/1 "Inter","Segoe UI",system-ui,-apple-system,Helvetica,Arial,sans-serif;box-shadow:0 2px 12px rgba(0,0,0,.18);transition:transform .22s ease,opacity .22s ease;will-change:transform} |
| 26 | #pbo-docnav.pbo-hidden{transform:translateY(-100%);opacity:0;pointer-events:none} |
| 27 | #pbo-docnav .pbo-brand{display:inline-flex;align-items:center;gap:9px;color:#f1faee;font-weight:600;text-decoration:none;margin-right:6px} |
| 28 | #pbo-docnav .pbo-mark{font:600 11px/1 ui-monospace,Menlo,Consolas,monospace;letter-spacing:.08em;background:#2d8b8b;color:#fff;padding:4px 7px;border-radius:5px} |
| 29 | #pbo-docnav nav{display:flex;gap:2px} |
| 30 | #pbo-docnav nav a{color:#a8dadc;text-decoration:none;padding:7px 10px;border-radius:6px;font-size:13.5px} |
| 31 | #pbo-docnav nav a:hover{background:rgba(255,255,255,.08);color:#fff} |
| 32 | #pbo-docnav nav a.pbo-active{background:rgba(255,255,255,.14);color:#fff} |
| 33 | #pbo-docnav .pbo-right{margin-left:auto;display:flex;gap:8px;align-items:center} |
| 34 | #pbo-docnav .pbo-right a{color:#a8dadc;text-decoration:none;font-size:12.5px;border:1px solid rgba(255,255,255,.14);padding:5px 9px;border-radius:6px} |
| 35 | #pbo-docnav .pbo-right a:hover{background:rgba(255,255,255,.1);color:#fff} |
| 36 | body{margin-top:${NAV_HEIGHT}px!important} |
| 37 | #pbo-docnav-hotzone{position:fixed;top:0;left:0;right:0;height:8px;z-index:2147482999} |
| 38 | @media (max-width:640px){#pbo-docnav .pbo-brand span:last-child{display:none}#pbo-docnav .pbo-right{display:none}} |
| 39 | @media print{#pbo-docnav,#pbo-docnav-hotzone{display:none!important}body{margin-top:0!important}} |
| 40 | /* The semantic atlas pins a render-status badge to the top-right; keep it visible below the bar. */ |
| 41 | body > #status{top:${NAV_HEIGHT}px!important} |
| 42 | </style> |
| 43 | <div id="pbo-docnav-hotzone" aria-hidden="true"></div> |
| 44 | <header id="pbo-docnav"> |
| 45 | <a class="pbo-brand" href="/"><span class="pbo-mark">pbo</span><span>Property Ontology</span></a> |
| 46 | <nav aria-label="Site">${links}</nav> |
| 47 | <div class="pbo-right"><a href="?raw" title="Open the document without the navigation bar">Raw document ↗</a></div> |
| 48 | </header> |
| 49 | <script id="pbo-docnav-script"> |
| 50 | (function(){ |
| 51 | var bar=document.getElementById('pbo-docnav'); |
| 52 | var hot=document.getElementById('pbo-docnav-hotzone'); |
| 53 | if(!bar)return; |
| 54 | var last=window.pageYOffset||document.documentElement.scrollTop||0; |
| 55 | var ticking=false,hidden=false; |
| 56 | var THRESH=6,MIN=${NAV_HEIGHT + 20}; |
| 57 | function set(h){if(h===hidden)return;hidden=h;bar.classList.toggle('pbo-hidden',h);} |
| 58 | function onScroll(){ |
| 59 | var y=window.pageYOffset||document.documentElement.scrollTop||0; |
| 60 | var d=y-last; |
| 61 | if(y<=MIN){set(false);} |
| 62 | else if(d>THRESH){set(true);} |
| 63 | else if(d<-THRESH){set(false);} |
| 64 | if(Math.abs(d)>THRESH)last=y; |
| 65 | ticking=false; |
| 66 | } |
| 67 | window.addEventListener('scroll',function(){if(!ticking){ticking=true;requestAnimationFrame(onScroll);}},{passive:true}); |
| 68 | if(hot){hot.addEventListener('mouseenter',function(){set(false);});} |
| 69 | bar.addEventListener('mouseenter',function(){set(false);}); |
| 70 | document.addEventListener('keydown',function(e){if(e.key==='Escape')set(false);}); |
| 71 | window.addEventListener('hashchange',function(){last=window.pageYOffset||0;}); |
| 72 | })(); |
| 73 | </script>`; |
| 74 | } |
| 75 | |
| 76 | const cache = new Map(); // abs path -> { mtimeMs, html } |
| 77 | |
| 78 | /** |
| 79 | * Read a standalone HTML document and return it with the nav bar injected |
| 80 | * right after the opening <body> tag. Cached by file mtime. |
| 81 | */ |
| 82 | async function withNav(absPath, active) { |
| 83 | const st = await fsp.stat(absPath); |
| 84 | const key = `${absPath}::${active}`; |
| 85 | const hit = cache.get(key); |
| 86 | if (hit && hit.mtimeMs === st.mtimeMs) return hit.html; |
| 87 | |
| 88 | const src = await fsp.readFile(absPath, 'utf8'); |
| 89 | const snippet = navMarkup(active); |
| 90 | // Insert before the closing </body>. Generated documents inline large JS |
| 91 | // bundles in <head> that contain literal "<body>" strings, so matching the |
| 92 | // opening tag is unsafe; the final </body> is always the real one. |
| 93 | const idx = src.lastIndexOf('</body>'); |
| 94 | const html = idx !== -1 ? src.slice(0, idx) + snippet + src.slice(idx) : src + snippet; |
| 95 | cache.set(key, { mtimeMs: st.mtimeMs, html }); |
| 96 | return html; |
| 97 | } |
| 98 | |
| 99 | module.exports = { withNav, NAV_HEIGHT }; |