399 lines
15 KiB
JavaScript
399 lines
15 KiB
JavaScript
/**
|
|
* sitemap-interactive.js
|
|
* Drop into /assets/scripts/ — no .org changes required.
|
|
*
|
|
* Handles two page structures generated by org-publish:
|
|
* 1. FLAT LIST — a top-level <ul class="org-ul"> (e.g. Sitemap)
|
|
* 2. OUTLINE — <div class="outline-2/3"> with <h2>/<h3> headings and
|
|
* nested <ul class="org-ul"> (e.g. "2025 List", "Blogs List")
|
|
*
|
|
* Activated on any page whose <h1 class="title"> matches SITEMAP_PATTERNS.
|
|
*/
|
|
|
|
(function () {
|
|
"use strict";
|
|
|
|
const SITEMAP_PATTERNS = ["sitemap", "list"];
|
|
|
|
function init() {
|
|
const titleEl = document.querySelector("h1.title");
|
|
if (!titleEl) return;
|
|
const title = titleEl.textContent.trim().toLowerCase();
|
|
if (!SITEMAP_PATTERNS.some((p) => title.includes(p))) return;
|
|
|
|
const contentDiv = document.getElementById("content");
|
|
if (!contentDiv) return;
|
|
|
|
const hasOutline = !!contentDiv.querySelector(".outline-2, .outline-3");
|
|
const topUl = !hasOutline && contentDiv.querySelector("ul.org-ul");
|
|
if (!hasOutline && !topUl) return;
|
|
|
|
// ── Parsers ───────────────────────────────────────────────────────────
|
|
|
|
function parseLi(li) {
|
|
const link = li.querySelector(":scope > a");
|
|
const childUl = li.querySelector(":scope > ul");
|
|
const textNode = Array.from(li.childNodes).find(
|
|
(n) => n.nodeType === Node.TEXT_NODE && n.textContent.trim()
|
|
);
|
|
const tags = Array.from(li.querySelectorAll(".post-tag")).map((t) =>
|
|
t.textContent.trim()
|
|
);
|
|
const dateEl = li.querySelector(".post-date");
|
|
const date = dateEl ? dateEl.textContent.trim() : null;
|
|
|
|
return {
|
|
label: link
|
|
? link.textContent.trim()
|
|
: textNode
|
|
? textNode.textContent.trim()
|
|
: li.childNodes[0]?.textContent?.trim() || "",
|
|
href: link ? link.getAttribute("href") : null,
|
|
tags,
|
|
date,
|
|
children: childUl ? parseUl(childUl) : [],
|
|
};
|
|
}
|
|
|
|
function parseUl(ul) {
|
|
return Array.from(ul.children)
|
|
.filter((li) => li.tagName === "LI")
|
|
.map(parseLi);
|
|
}
|
|
|
|
function parseFlatList() {
|
|
return { tree: parseUl(topUl), replaceTarget: topUl, wrapOutline: false };
|
|
}
|
|
|
|
function parseOutline() {
|
|
const tree = [];
|
|
const outline2s = contentDiv.querySelectorAll(":scope .outline-2");
|
|
|
|
if (outline2s.length === 0) {
|
|
// Only outline-3 directly (flat month grouping without year wrapper)
|
|
contentDiv.querySelectorAll(":scope .outline-3").forEach((section) => {
|
|
const heading = section.querySelector("h3");
|
|
const ul = section.querySelector("ul.org-ul");
|
|
tree.push({
|
|
label: heading ? heading.textContent.trim() : "Section",
|
|
href: null, tags: [], date: null,
|
|
children: ul ? parseUl(ul) : [],
|
|
});
|
|
});
|
|
} else {
|
|
outline2s.forEach((o2) => {
|
|
const h2 = o2.querySelector(":scope > div > h2, :scope > h2");
|
|
const groupNode = {
|
|
label: h2 ? h2.textContent.trim() : "Group",
|
|
href: null, tags: [], date: null,
|
|
children: [],
|
|
};
|
|
const outline3s = o2.querySelectorAll(".outline-3");
|
|
if (outline3s.length > 0) {
|
|
outline3s.forEach((o3) => {
|
|
const h3 = o3.querySelector(":scope > div > h3, :scope > h3");
|
|
const ul = o3.querySelector("ul.org-ul");
|
|
groupNode.children.push({
|
|
label: h3 ? h3.textContent.trim() : "Month",
|
|
href: null, tags: [], date: null,
|
|
children: ul ? parseUl(ul) : [],
|
|
});
|
|
});
|
|
} else {
|
|
const ul = o2.querySelector("ul.org-ul");
|
|
if (ul) groupNode.children = parseUl(ul);
|
|
}
|
|
tree.push(groupNode);
|
|
});
|
|
}
|
|
|
|
return { tree, replaceTarget: null, wrapOutline: true };
|
|
}
|
|
|
|
const parsed = hasOutline ? parseOutline() : parseFlatList();
|
|
const { tree } = parsed;
|
|
if (!tree.length) return;
|
|
|
|
// ── Styles ────────────────────────────────────────────────────────────
|
|
if (!document.getElementById("sm-styles")) {
|
|
const style = document.createElement("style");
|
|
style.id = "sm-styles";
|
|
style.textContent = `
|
|
.sitemap-interactive { font-family: inherit; margin: 1.5rem 0; }
|
|
.sm-toolbar {
|
|
display: flex; align-items: center; gap: .6rem;
|
|
margin-bottom: 1rem; flex-wrap: wrap;
|
|
}
|
|
.sm-search {
|
|
flex: 1; min-width: 160px; padding: .35rem .7rem;
|
|
border: 1px solid var(--border, #444); border-radius: 4px;
|
|
background: var(--bg, transparent); color: inherit; font-size: .9rem;
|
|
}
|
|
.sm-search:focus { outline: 2px solid var(--accent, #7aa2f7); outline-offset: 1px; }
|
|
.sm-expand-all, .sm-collapse-all {
|
|
padding: .3rem .65rem; border: 1px solid var(--border, #444);
|
|
border-radius: 4px; background: transparent; color: inherit;
|
|
font-size: .8rem; cursor: pointer; opacity: .75; transition: opacity .15s;
|
|
}
|
|
.sm-expand-all:hover, .sm-collapse-all:hover { opacity: 1; }
|
|
|
|
.sm-tree ul { list-style: none; margin: 0; padding: 0 0 0 1.4rem; }
|
|
.sm-tree > ul { padding-left: 0; }
|
|
.sm-tree li { margin: 0; }
|
|
.sm-node {
|
|
display: flex; align-items: center; gap: .4rem;
|
|
padding: .2rem .3rem; border-radius: 4px;
|
|
line-height: 1.5; transition: background .1s; flex-wrap: wrap;
|
|
}
|
|
.sm-node:hover { background: var(--hover-bg, rgba(122,162,247,.08)); }
|
|
.sm-node.sm-hidden { display: none; }
|
|
|
|
.sm-toggle {
|
|
width: 1.2rem; height: 1.2rem; display: inline-flex;
|
|
align-items: center; justify-content: center;
|
|
cursor: pointer; border: none; background: none; color: inherit;
|
|
font-size: .7rem; opacity: .6;
|
|
transition: transform .18s, opacity .15s;
|
|
flex-shrink: 0; padding: 0; border-radius: 3px;
|
|
}
|
|
.sm-toggle:hover { opacity: 1; background: var(--hover-bg, rgba(122,162,247,.15)); }
|
|
.sm-toggle.open { transform: rotate(90deg); }
|
|
.sm-toggle-placeholder { width: 1.2rem; flex-shrink: 0; }
|
|
|
|
.sm-icon { font-size: .8rem; opacity: .5; flex-shrink: 0; }
|
|
|
|
.sm-label a { color: var(--link, inherit); text-decoration: none; font-size: .9rem; }
|
|
.sm-label a:hover { text-decoration: underline; }
|
|
.sm-label span { font-size: .9rem; font-weight: 600; opacity: .85; }
|
|
|
|
.sm-badge {
|
|
font-size: .65rem; padding: .05rem .35rem; border-radius: 8px;
|
|
background: var(--badge-bg, rgba(122,162,247,.15));
|
|
color: var(--badge-fg, #7aa2f7); opacity: .8;
|
|
}
|
|
.sm-date { font-size: .75rem; opacity: .45; margin-left: .2rem; }
|
|
.sm-tags { display: inline-flex; gap: .25rem; margin-left: .2rem; }
|
|
.sm-tag {
|
|
font-size: .65rem; padding: .05rem .3rem; border-radius: 8px;
|
|
background: var(--tag-bg, rgba(160,200,120,.15));
|
|
color: var(--tag-fg, #9ece6a); opacity: .85;
|
|
}
|
|
|
|
.sm-children { overflow: hidden; }
|
|
.sm-children.collapsed { display: none; }
|
|
.sm-label mark {
|
|
background: var(--mark-bg, rgba(255,200,50,.3));
|
|
color: inherit; border-radius: 2px; padding: 0 1px;
|
|
}
|
|
.sm-count { font-size: .78rem; opacity: .45; margin-top: .8rem; }
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
// ── Widget scaffold ───────────────────────────────────────────────────
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "sitemap-interactive";
|
|
wrapper.innerHTML = `
|
|
<div class="sm-toolbar">
|
|
<input class="sm-search" type="search" placeholder="Filter pages…" aria-label="Filter" />
|
|
<button class="sm-expand-all">⊞ Expand all</button>
|
|
<button class="sm-collapse-all">⊟ Collapse all</button>
|
|
</div>
|
|
<div class="sm-tree" role="tree"></div>
|
|
<p class="sm-count"></p>
|
|
`;
|
|
|
|
const treeEl = wrapper.querySelector(".sm-tree");
|
|
const searchEl = wrapper.querySelector(".sm-search");
|
|
const countEl = wrapper.querySelector(".sm-count");
|
|
|
|
// ── Tree builder ──────────────────────────────────────────────────────
|
|
function countLeaves(nodes) {
|
|
let n = 0;
|
|
for (const node of nodes) {
|
|
if (!node.children || !node.children.length) n++;
|
|
else n += countLeaves(node.children);
|
|
}
|
|
return n;
|
|
}
|
|
|
|
function buildTree(nodes) {
|
|
const ul = document.createElement("ul");
|
|
for (const node of nodes) {
|
|
const li = document.createElement("li");
|
|
const row = document.createElement("div");
|
|
row.className = "sm-node";
|
|
const hasChildren = node.children && node.children.length > 0;
|
|
|
|
let childrenEl;
|
|
if (hasChildren) {
|
|
const toggle = document.createElement("button");
|
|
toggle.className = "sm-toggle open";
|
|
toggle.innerHTML = "▶";
|
|
toggle.setAttribute("aria-expanded", "true");
|
|
childrenEl = document.createElement("div");
|
|
childrenEl.className = "sm-children";
|
|
childrenEl.appendChild(buildTree(node.children));
|
|
toggle.addEventListener("click", () => {
|
|
childrenEl.classList.toggle("collapsed");
|
|
toggle.classList.toggle("open");
|
|
toggle.setAttribute("aria-expanded",
|
|
String(!childrenEl.classList.contains("collapsed")));
|
|
});
|
|
row.appendChild(toggle);
|
|
} else {
|
|
const ph = document.createElement("span");
|
|
ph.className = "sm-toggle-placeholder";
|
|
row.appendChild(ph);
|
|
}
|
|
|
|
const icon = document.createElement("span");
|
|
icon.className = "sm-icon";
|
|
icon.textContent = hasChildren ? "📂" : "📄";
|
|
row.appendChild(icon);
|
|
|
|
const labelEl = document.createElement("span");
|
|
labelEl.className = "sm-label";
|
|
if (node.href) {
|
|
const a = document.createElement("a");
|
|
a.href = node.href;
|
|
a.textContent = node.label;
|
|
labelEl.appendChild(a);
|
|
} else {
|
|
const s = document.createElement("span");
|
|
s.textContent = node.label;
|
|
labelEl.appendChild(s);
|
|
}
|
|
row.appendChild(labelEl);
|
|
|
|
if (hasChildren) {
|
|
const badge = document.createElement("span");
|
|
badge.className = "sm-badge";
|
|
badge.textContent = countLeaves(node.children);
|
|
row.appendChild(badge);
|
|
}
|
|
|
|
if (node.date) {
|
|
const dateSpan = document.createElement("span");
|
|
dateSpan.className = "sm-date";
|
|
dateSpan.textContent = node.date;
|
|
row.appendChild(dateSpan);
|
|
}
|
|
|
|
if (node.tags && node.tags.length) {
|
|
const tagsEl = document.createElement("span");
|
|
tagsEl.className = "sm-tags";
|
|
node.tags.forEach((t) => {
|
|
const tag = document.createElement("span");
|
|
tag.className = "sm-tag";
|
|
tag.textContent = t;
|
|
tagsEl.appendChild(tag);
|
|
});
|
|
row.appendChild(tagsEl);
|
|
}
|
|
|
|
li.appendChild(row);
|
|
if (hasChildren) li.appendChild(childrenEl);
|
|
ul.appendChild(li);
|
|
}
|
|
return ul;
|
|
}
|
|
|
|
treeEl.appendChild(buildTree(tree));
|
|
|
|
// ── Search / filter ───────────────────────────────────────────────────
|
|
function escapeRe(s) {
|
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function filterTree(q) {
|
|
const query = q.trim().toLowerCase();
|
|
const re = query ? new RegExp(escapeRe(query), "gi") : null;
|
|
let visible = 0;
|
|
|
|
function walk(ul) {
|
|
let anyVisible = false;
|
|
for (const li of ul.children) {
|
|
const row = li.querySelector(":scope > .sm-node");
|
|
const childrenDiv = li.querySelector(":scope > .sm-children");
|
|
const labelEl = row.querySelector(".sm-label");
|
|
const target = labelEl.querySelector("a") || labelEl.querySelector("span");
|
|
const text = target.dataset.orig || target.textContent;
|
|
target.dataset.orig = text;
|
|
|
|
let selfMatch = false;
|
|
if (!query) {
|
|
target.innerHTML = "";
|
|
target.textContent = text;
|
|
selfMatch = true;
|
|
} else if (text.toLowerCase().includes(query)) {
|
|
target.innerHTML = text.replace(re, (m) => `<mark>${m}</mark>`);
|
|
selfMatch = true;
|
|
} else {
|
|
target.innerHTML = "";
|
|
target.textContent = text;
|
|
}
|
|
|
|
let childVisible = false;
|
|
if (childrenDiv) {
|
|
childVisible = walk(childrenDiv.querySelector("ul"));
|
|
if (query) {
|
|
childrenDiv.classList.toggle("collapsed", !childVisible && !selfMatch);
|
|
const toggle = row.querySelector(".sm-toggle");
|
|
if (toggle) toggle.classList.toggle("open", childVisible || selfMatch);
|
|
}
|
|
}
|
|
|
|
const show = !query || selfMatch || childVisible;
|
|
row.classList.toggle("sm-hidden", !show);
|
|
if (show) {
|
|
anyVisible = true;
|
|
if (!childrenDiv) visible++;
|
|
}
|
|
}
|
|
return anyVisible;
|
|
}
|
|
|
|
walk(treeEl.querySelector("ul"));
|
|
countEl.textContent = query
|
|
? `${visible} page${visible !== 1 ? "s" : ""} matching "${query}"`
|
|
: "";
|
|
}
|
|
|
|
searchEl.addEventListener("input", (e) => filterTree(e.target.value));
|
|
|
|
wrapper.querySelector(".sm-expand-all").addEventListener("click", () => {
|
|
treeEl.querySelectorAll(".sm-children").forEach((el) => el.classList.remove("collapsed"));
|
|
treeEl.querySelectorAll(".sm-toggle").forEach((el) => {
|
|
el.classList.add("open");
|
|
el.setAttribute("aria-expanded", "true");
|
|
});
|
|
});
|
|
wrapper.querySelector(".sm-collapse-all").addEventListener("click", () => {
|
|
treeEl.querySelectorAll(".sm-children").forEach((el) => el.classList.add("collapsed"));
|
|
treeEl.querySelectorAll(".sm-toggle").forEach((el) => {
|
|
el.classList.remove("open");
|
|
el.setAttribute("aria-expanded", "false");
|
|
});
|
|
});
|
|
|
|
// ── Inject into page ──────────────────────────────────────────────────
|
|
if (parsed.wrapOutline) {
|
|
// Hide original outline divs (direct children of content only)
|
|
contentDiv.querySelectorAll(":scope > .outline-2, :scope > .outline-3")
|
|
.forEach((el) => (el.style.display = "none"));
|
|
// Insert after title + optional intro <p>
|
|
const insertAfter = contentDiv.querySelector(":scope > p") || titleEl;
|
|
insertAfter.insertAdjacentElement("afterend", wrapper);
|
|
} else {
|
|
parsed.replaceTarget.replaceWith(wrapper);
|
|
}
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|