Update app.js

This commit is contained in:
Mark Randall Havens △ The Empathic Technologist ⟁ Doctor Who 42 2025-11-08 12:59:27 -06:00 committed by GitHub
parent 151a42140b
commit 1ed336df18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,6 +1,12 @@
/* ============================================================
Self-Organizing Static Site Framework v2.4 (Local Libs)
============================================================ */
let INDEX, CURRENT_PATH = null, PATH_TO_EL = new Map(); let INDEX, CURRENT_PATH = null, PATH_TO_EL = new Map();
const treeEl = document.getElementById("tree"); const treeEl = document.getElementById("tree");
const mdView = document.getElementById("mdView"); const mdView = document.getElementById("mdView");
const mdWarn = document.getElementById("mdWarn");
const htmlView = document.getElementById("htmlView"); const htmlView = document.getElementById("htmlView");
const metaLine = document.getElementById("meta"); const metaLine = document.getElementById("meta");
const sortSel = document.getElementById("sort"); const sortSel = document.getElementById("sort");
@ -12,26 +18,42 @@ const sidebar = document.querySelector(".sidebar");
const navToggle = document.getElementById("navToggle"); const navToggle = document.getElementById("navToggle");
const overlay = document.querySelector(".overlay"); const overlay = document.querySelector(".overlay");
/* Sidebar toggle */
navToggle.addEventListener("click", () => sidebar.classList.toggle("open")); navToggle.addEventListener("click", () => sidebar.classList.toggle("open"));
overlay.addEventListener("click", () => sidebar.classList.remove("open")); overlay.addEventListener("click", () => sidebar.classList.remove("open"));
/* Load index */
async function loadIndex() { async function loadIndex() {
// No CDN race now; libs are local. Still, surface diagnostics:
if (!window.marked) console.warn("⚠️ marked.js not detected.");
if (!window.DOMPurify) console.warn("⚠️ DOMPurify not detected.");
const res = await fetch("/index.json", { cache: "no-store" }); const res = await fetch("/index.json", { cache: "no-store" });
INDEX = await res.json(); INDEX = await res.json();
populateFilters(); populateFilters();
rebuildTree(); rebuildTree();
window.addEventListener("popstate", () => { const hp = location.hash.startsWith("#=") ? location.hash.slice(2) : null; if (hp) openPath(hp); });
const init = location.hash.startsWith("#=") ? location.hash.slice(2) : INDEX.flat.sort((a, b) => b.mtime - a.mtime)[0]?.path; window.addEventListener("popstate", () => {
const hp = location.hash.startsWith("#=") ? location.hash.slice(2) : null;
if (hp) openPath(hp);
});
const init = location.hash.startsWith("#=")
? location.hash.slice(2)
: INDEX.flat.sort((a,b)=>b.mtime-a.mtime)[0]?.path;
openPath(init); openPath(init);
} }
function populateFilters() { function populateFilters() {
filterSel.innerHTML = '<option value="all">All</option>'; filterSel.innerHTML = '<option value="all">All</option>';
for (const cat of INDEX.sections) { for (const cat of INDEX.sections) {
const opt = document.createElement("option"); const o = document.createElement("option");
opt.value = opt.textContent = cat; o.value = o.textContent = cat;
filterSel.appendChild(opt); filterSel.appendChild(o);
} }
} }
function rebuildTree() { function rebuildTree() {
treeEl.innerHTML = ""; treeEl.innerHTML = "";
PATH_TO_EL.clear(); PATH_TO_EL.clear();
@ -39,66 +61,71 @@ function rebuildTree() {
const sort = sortSel.value; const sort = sortSel.value;
const query = searchBox.value.trim().toLowerCase(); const query = searchBox.value.trim().toLowerCase();
const root = { type: "dir", children: INDEX.tree }; const root = { type: "dir", children: INDEX.tree };
const pruned = filterTree(root, f => (filter === "all" || f.path.split("/")[0] === filter) && (!query || (f.title || f.name).toLowerCase().includes(query))); const pruned = filterTree(root, f =>
(filter==="all" || f.path.split("/")[0]===filter) &&
(!query || (f.title||f.name).toLowerCase().includes(query))
);
sortDir(pruned, sort); sortDir(pruned, sort);
for (const c of pruned.children) treeEl.appendChild(renderNode(c)); for (const c of pruned.children) treeEl.appendChild(renderNode(c));
treeEl.querySelectorAll(".dir").forEach(d => d.classList.add("open")); treeEl.querySelectorAll(".dir").forEach(d => d.classList.add("open"));
} }
function filterTree(node, keep) { function filterTree(node, keep) {
if (node.type === "file") return keep(node) ? node : null; if (node.type === "file") return keep(node) ? node : null;
const kids = node.children.map(c=>filterTree(c,keep)).filter(Boolean); const kids = node.children.map(c=>filterTree(c,keep)).filter(Boolean);
return kids.length ? {...node, children:kids} : null; return kids.length ? {...node, children:kids} : null;
} }
function sortDir(node, sort) { function sortDir(node, sort) {
const cmp = sort === "name" ? (a, b) => a.name.localeCompare(b.name) : const cmp = sort==="name" ? (a,b)=>a.name.localeCompare(b.name)
sort === "old" ? (a, b) => a.mtime - b.mtime : (a, b) => b.mtime - a.mtime; : sort==="old" ? (a,b)=>a.mtime-b.mtime
node.children.sort((a, b) => (a.type === "dir" && b.type !== "dir") ? -1 : (a.type !== "dir" && b.type === "dir") ? 1 : cmp(a, b)); : (a,b)=>b.mtime-a.mtime;
node.children.sort((a,b)=>
(a.type==="dir"&&b.type!=="dir")?-1:
(a.type!=="dir"&&b.type==="dir")?1:cmp(a,b));
node.children.forEach(c=>c.type==="dir"&&sortDir(c,sort)); node.children.forEach(c=>c.type==="dir"&&sortDir(c,sort));
} }
function renderNode(node) { function renderNode(n) {
if (node.type === "dir") { if (n.type==="dir") {
const div = document.createElement("div"); const d = document.createElement("div");
div.className = "dir"; d.className="dir"; d.setAttribute("aria-expanded","false");
div.setAttribute("aria-expanded", "false");
const lbl=document.createElement("span"); const lbl=document.createElement("span");
lbl.className = "label"; lbl.className="label"; lbl.textContent=n.name||"/";
lbl.textContent = node.name || "/";
lbl.addEventListener("click",()=>{ lbl.addEventListener("click",()=>{
const idx = node.children.find(c => c.type === "file" && /^index\.(md|html)$/i.test(c.name)); const idx=n.children.find(c=>c.type==="file"&&/^index\.(md|html)$/i.test(c.name));
if (idx) openPath(idx.path); if(idx) openPath(idx.path); else d.classList.toggle("open");
else div.classList.toggle("open");
}); });
div.appendChild(lbl); d.appendChild(lbl);
const kids=document.createElement("div"); const kids=document.createElement("div");
kids.className="children"; kids.className="children";
node.children.forEach(c => kids.appendChild(renderNode(c))); n.children.forEach(c=>kids.appendChild(renderNode(c)));
div.appendChild(kids); d.appendChild(kids);
return div; return d;
} }
const a=document.createElement("a"); const a=document.createElement("a");
a.className="file"; a.className="file";
a.innerHTML = `${node.pinned ? '<span class="pin">📌</span>' : ''}${iconForExt(node.ext)} ${node.title} <span class="meta">(${fmtDate(node.mtime)} · ${node.name})</span>`; a.innerHTML=`${n.pinned?'📌 ':''}${iconForExt(n.ext)} ${n.title}
a.addEventListener("click", e => { e.preventDefault(); openPath(node.path); }); <span class="meta">(${fmtDate(n.mtime)} · ${n.name})</span>`;
PATH_TO_EL.set(node.path, a); a.addEventListener("click",e=>{e.preventDefault();openPath(n.path);});
PATH_TO_EL.set(n.path,a);
return a; return a;
} }
function iconForExt(ext){return ext===".md"?"📝":"🧩";} function iconForExt(ext){return ext===".md"?"📝":"🧩";}
function fmtDate(ms){return new Date(ms).toISOString().slice(0,10);} function fmtDate(ms){return new Date(ms).toISOString().slice(0,10);}
function findDir(path) {
path = path.replace(/\/$/, ''); function findDir(p){
function search(node) { p=p.replace(/\/$/,'');
if (node.type === "dir" && node.path === path) return node; function search(n){
for (const c of node.children || []) { if(n.type==="dir"&&n.path===p) return n;
const found = search(c); for(const c of n.children||[]){const f=search(c);if(f)return f;}
if (found) return found;
}
} }
return search({children:INDEX.tree}); return search({children:INDEX.tree});
} }
async function openPath(path){ async function openPath(path){
if(path===CURRENT_PATH) return; if(path===CURRENT_PATH) return;
CURRENT_PATH=path; CURRENT_PATH=path;
if(location.hash!==`#=${path}`) history.pushState(null,"",`#=${path}`); if(location.hash!==`#=${path}`) history.pushState(null,"",`#=${path}`);
let f=INDEX.flat.find(x=>x.path===path); let f=INDEX.flat.find(x=>x.path===path);
if(!f){ if(!f){
const dir=findDir(path); const dir=findDir(path);
@ -109,51 +136,63 @@ async function openPath(path) {
metaLine.textContent="Path not found: "+path; metaLine.textContent="Path not found: "+path;
return; return;
} }
metaLine.textContent=`${f.pinned?"📌 ":""}${fmtDate(f.mtime)}${f.name}`; metaLine.textContent=`${f.pinned?"📌 ":""}${fmtDate(f.mtime)}${f.name}`;
if(f.ext===".md") await renderMarkdown(f.path); if(f.ext===".md") await renderMarkdown(f.path);
else renderHTML(f.path); else renderHTML(f.path);
setActive(path); setActive(path);
updatePager(); updatePager();
if(window.innerWidth<900) sidebar.classList.remove("open"); if(window.innerWidth<900) sidebar.classList.remove("open");
} }
/* ---------- Markdown (robust) ---------- */
async function renderMarkdown(path){ async function renderMarkdown(path){
mdView.innerHTML = "<p style='color:var(--muted);font-style:italic;'>Loading…</p>"; mdWarn.style.display = "none";
mdView.style.display = "block"; mdView.innerHTML="<p class='loading-note'>Loading…</p>";
htmlView.style.display="none"; htmlView.style.display="none";
mdView.style.display="block";
try{ try{
const res = await fetch("/" + path); const res=await fetch("/"+path, { cache: "no-store" });
if(!res.ok) throw new Error("File not found: "+path); if(!res.ok) throw new Error("File not found: "+path);
const text=await res.text(); const text=await res.text();
let html = text.replace(/&/g, '&amp;').replace(/</g, '&lt;');
let usedFallback = true; let usedFallback = false;
let html;
if (window.marked) { if (window.marked) {
html = window.marked.parse(text); html = window.marked.parse(text);
usedFallback = false; } else {
// explicit, visible signal
usedFallback = true;
html = text.replace(/&/g,"&amp;").replace(/</g,"&lt;");
} }
let safe = html;
if (window.DOMPurify) safe = window.DOMPurify.sanitize(html); const safe = window.DOMPurify ? window.DOMPurify.sanitize(html) : html;
requestAnimationFrame(()=>{ requestAnimationFrame(()=>{
mdView.innerHTML = safe; mdView.innerHTML = safe;
mdView.scrollTop = 0; mdView.scrollTop = 0;
mdView.classList.add("fade-in"); mdView.classList.add("fade-in");
mdView.style.display = "block";
if (usedFallback) mdWarn.style.display = "block";
}); });
if (usedFallback) console.warn("Markdown rendered as plain text: marked.js not loaded. Check CDN or vendor locally.");
}catch(e){ }catch(e){
mdView.innerHTML=`<p style='color:red;'>${e.message}</p>`; mdView.innerHTML=`<p style='color:red;'>${e.message}</p>`;
} }
} }
/* ---------- HTML ---------- */
function renderHTML(path){ function renderHTML(path){
htmlView.style.display = "none";
mdView.style.display = "none";
metaLine.innerHTML += " <span style='color:var(--muted);'>Loading…</span>";
htmlView.src="/"+path; htmlView.src="/"+path;
htmlView.onload = () => {
htmlView.style.display="block"; htmlView.style.display="block";
htmlView.classList.add("fade-in"); mdView.style.display="none";
metaLine.innerHTML = metaLine.innerHTML.replace(" Loading…", "");
};
htmlView.onerror = () => metaLine.innerHTML += " <span style='color:red;'>Load failed.</span>";
} }
/* ---------- Active + Pager ---------- */
function setActive(path){ function setActive(path){
document.querySelectorAll(".file.active").forEach(el=>el.classList.remove("active")); document.querySelectorAll(".file.active").forEach(el=>el.classList.remove("active"));
const el=PATH_TO_EL.get(path); const el=PATH_TO_EL.get(path);
@ -167,10 +206,14 @@ function setActive(path) {
} }
} }
function updatePager(){ function updatePager(){
const query = searchBox.value.trim().toLowerCase(); const q=searchBox.value.trim().toLowerCase();
const list = INDEX.flat.filter(f => (filterSel.value === "all" || f.path.split("/")[0] === filterSel.value) && (!query || f.title.toLowerCase().includes(query))); const list=INDEX.flat.filter(f=>
const cmp = sortSel.value === "name" ? (a, b) => a.name.localeCompare(b.name) : (filterSel.value==="all"||f.path.split("/")[0]===filterSel.value)&&
sortSel.value === "old" ? (a, b) => a.mtime - b.mtime : (a, b) => b.mtime - a.mtime; (!q||f.title.toLowerCase().includes(q))
);
const cmp=sortSel.value==="name"?(a,b)=>a.name.localeCompare(b.name)
:sortSel.value==="old"?(a,b)=>a.mtime-b.mtime
:(a,b)=>b.mtime-a.mtime;
list.sort(cmp); list.sort(cmp);
const i=list.findIndex(x=>x.path===CURRENT_PATH); const i=list.findIndex(x=>x.path===CURRENT_PATH);
prevBtn.disabled=i<=0; prevBtn.disabled=i<=0;
@ -178,10 +221,17 @@ function updatePager() {
prevBtn.onclick=()=>i>0&&openPath(list[i-1].path); prevBtn.onclick=()=>i>0&&openPath(list[i-1].path);
nextBtn.onclick=()=>i<list.length-1&&openPath(list[i+1].path); nextBtn.onclick=()=>i<list.length-1&&openPath(list[i+1].path);
} }
/* Controls */
let searchTimer; let searchTimer;
searchBox.addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(rebuildTree, 300); }); searchBox.addEventListener("input",()=>{
clearTimeout(searchTimer);
searchTimer=setTimeout(rebuildTree,300);
});
sortSel.addEventListener("change",rebuildTree); sortSel.addEventListener("change",rebuildTree);
filterSel.addEventListener("change",rebuildTree); filterSel.addEventListener("change",rebuildTree);
/* Internal link interception */
document.body.addEventListener("click",e=>{ document.body.addEventListener("click",e=>{
const a=e.target.closest("a[href]"); const a=e.target.closest("a[href]");
if(!a) return; if(!a) return;
@ -191,5 +241,6 @@ document.body.addEventListener("click", e => {
openPath(href.replace(/^\//,"")); openPath(href.replace(/^\//,""));
} }
}); });
window.addEventListener("resize",()=>{ if(window.innerWidth<900) sidebar.classList.remove("open"); }); window.addEventListener("resize",()=>{ if(window.innerWidth<900) sidebar.classList.remove("open"); });
window.addEventListener("DOMContentLoaded",loadIndex); window.addEventListener("DOMContentLoaded",loadIndex);