319 lines
8.8 KiB
JavaScript
319 lines
8.8 KiB
JavaScript
// soleprint docs — sidebar + markdown renderer
|
||
// Logic adapted from mcrn.ar/design, reskinned for minimal-neon
|
||
|
||
const state = {
|
||
lang: "en",
|
||
topics: [],
|
||
contentCache: {},
|
||
activeTopic: null,
|
||
expandedGroup: null,
|
||
};
|
||
|
||
const dom = {
|
||
sidebar: () => document.getElementById("sidebar"),
|
||
content: () => document.getElementById("content"),
|
||
};
|
||
|
||
// --- Topic grouping ---
|
||
|
||
function buildGroups(topics) {
|
||
const groups = [];
|
||
for (const t of topics) {
|
||
if (!t.sub) {
|
||
groups.push({ parent: t, subs: [] });
|
||
} else if (groups.length > 0) {
|
||
groups[groups.length - 1].subs.push(t);
|
||
}
|
||
}
|
||
return groups;
|
||
}
|
||
|
||
function findParentGroup(topicId) {
|
||
for (const g of buildGroups(state.topics)) {
|
||
if (g.parent.id === topicId || g.subs.some((s) => s.id === topicId)) {
|
||
return g.parent.id;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// --- Sidebar ---
|
||
|
||
function renderSidebar() {
|
||
const groups = buildGroups(state.topics);
|
||
const items = [];
|
||
|
||
items.push(`<div class="sidebar-header"><a href="#intro"><h1>soleprint</h1><span>docs</span></a></div>`);
|
||
|
||
for (const g of groups) {
|
||
const isExpanded = state.expandedGroup === g.parent.id;
|
||
const hasSubs = g.subs.length > 0;
|
||
|
||
items.push(parentItem(g.parent, hasSubs, isExpanded));
|
||
|
||
if (isExpanded && hasSubs) {
|
||
for (const s of g.subs) {
|
||
items.push(subItem(s));
|
||
}
|
||
}
|
||
}
|
||
|
||
dom.sidebar().innerHTML = items.join("");
|
||
bindParentClicks();
|
||
}
|
||
|
||
function parentItem(topic, hasSubs, isExpanded) {
|
||
const active = state.activeTopic === topic.id ? " active" : "";
|
||
const chevron = hasSubs
|
||
? `<span class="sidebar-chevron${isExpanded ? " expanded" : ""}">›</span>`
|
||
: "";
|
||
const label = topic.title[state.lang] || topic.title.en;
|
||
return `<a class="sidebar-item${active}" href="#${topic.id}" data-group="${topic.id}">${chevron}${label}</a>`;
|
||
}
|
||
|
||
function subItem(topic) {
|
||
const active = state.activeTopic === topic.id ? " active" : "";
|
||
const label = topic.title[state.lang] || topic.title.en;
|
||
return `<a class="sidebar-item sidebar-sub${active}" href="#${topic.id}">${label}</a>`;
|
||
}
|
||
|
||
function bindParentClicks() {
|
||
dom.sidebar().querySelectorAll(".sidebar-item:not(.sidebar-sub)").forEach((el) => {
|
||
el.addEventListener("click", (e) => {
|
||
const groupId = el.dataset.group;
|
||
const group = buildGroups(state.topics).find((g) => g.parent.id === groupId);
|
||
|
||
if (group?.subs.length && state.expandedGroup === groupId && state.activeTopic === groupId) {
|
||
e.preventDefault();
|
||
state.expandedGroup = null;
|
||
renderSidebar();
|
||
} else {
|
||
state.expandedGroup = groupId;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// --- Content loading ---
|
||
|
||
async function loadTopic(id) {
|
||
const el = dom.content();
|
||
const cacheKey = `${state.lang}:${id}`;
|
||
|
||
if (state.contentCache[cacheKey]) {
|
||
el.innerHTML = state.contentCache[cacheKey];
|
||
} else {
|
||
el.innerHTML = '<p class="loading">Loading...</p>';
|
||
try {
|
||
const md = await fetchMarkdown(id);
|
||
const html = markdown.render(md);
|
||
state.contentCache[cacheKey] = html;
|
||
el.innerHTML = html;
|
||
} catch (e) {
|
||
el.innerHTML = `<p style="color:var(--artery-text);">Failed to load: ${e.message}</p>`;
|
||
}
|
||
}
|
||
|
||
window.scrollTo(0, 0);
|
||
el.scrollTop = 0;
|
||
}
|
||
|
||
async function fetchMarkdown(id) {
|
||
let resp = await fetch(`data/${state.lang}/${id}.md`);
|
||
if (!resp.ok && state.lang !== "en") {
|
||
resp = await fetch(`data/en/${id}.md`);
|
||
}
|
||
if (!resp.ok) throw new Error("Not found");
|
||
return resp.text();
|
||
}
|
||
|
||
// --- Markdown renderer ---
|
||
|
||
const markdown = {
|
||
render(md) {
|
||
const lines = md.split("\n");
|
||
const parts = [];
|
||
let i = 0;
|
||
|
||
while (i < lines.length) {
|
||
const trimmed = lines[i].trim();
|
||
if (!trimmed) { i++; continue; }
|
||
const [html, next] = this.parseLine(lines, i, trimmed);
|
||
if (html) parts.push(html);
|
||
i = next;
|
||
}
|
||
|
||
return parts.join("");
|
||
},
|
||
|
||
parseLine(lines, i, trimmed) {
|
||
for (const parser of this.parsers) {
|
||
if (parser.match(trimmed)) {
|
||
return parser.parse(lines, i, trimmed);
|
||
}
|
||
}
|
||
return [`<p>${this.inline(trimmed)}</p>`, i + 1];
|
||
},
|
||
|
||
parsers: [
|
||
{
|
||
name: "fenced-code",
|
||
match: (t) => t.startsWith("```"),
|
||
parse(lines, i, trimmed) {
|
||
const lang = trimmed.slice(3).trim();
|
||
const codeLines = [];
|
||
i++;
|
||
while (i < lines.length && !lines[i].trim().startsWith("```")) {
|
||
codeLines.push(markdown.escape(lines[i]));
|
||
i++;
|
||
}
|
||
return [`<pre><code>${codeLines.join("\n")}</code></pre>`, i + 1];
|
||
},
|
||
},
|
||
{
|
||
name: "hr",
|
||
match: (t) => /^---+$/.test(t),
|
||
parse(_, i) { return ["<hr>", i + 1]; },
|
||
},
|
||
{
|
||
name: "table",
|
||
match: (t) => t.startsWith("|"),
|
||
parse(lines, i) {
|
||
const rows = [];
|
||
while (i < lines.length && lines[i].trim().startsWith("|")) {
|
||
rows.push(lines[i].trim());
|
||
i++;
|
||
}
|
||
return [markdown.table(rows), i];
|
||
},
|
||
},
|
||
{
|
||
name: "h3",
|
||
match: (t) => t.startsWith("### "),
|
||
parse(_, i, t) { return [`<h3>${markdown.inline(t.slice(4))}</h3>`, i + 1]; },
|
||
},
|
||
{
|
||
name: "h2",
|
||
match: (t) => t.startsWith("## "),
|
||
parse(_, i, t) { return [`<h2>${markdown.inline(t.slice(3))}</h2>`, i + 1]; },
|
||
},
|
||
{
|
||
name: "h1",
|
||
match: (t) => t.startsWith("# "),
|
||
parse(_, i, t) { return [`<h1>${markdown.inline(t.slice(2))}</h1>`, i + 1]; },
|
||
},
|
||
{
|
||
name: "blockquote",
|
||
match: (t) => t.startsWith("> ") || t === ">",
|
||
parse(lines, i) {
|
||
const content = [];
|
||
while (i < lines.length) {
|
||
const ql = lines[i].trim();
|
||
if (ql.startsWith("> ")) content.push(ql.slice(2));
|
||
else if (ql === ">") content.push("");
|
||
else break;
|
||
i++;
|
||
}
|
||
const inner = content.map((l) => `<p>${markdown.inline(l)}</p>`).join("");
|
||
return [`<blockquote>${inner}</blockquote>`, i];
|
||
},
|
||
},
|
||
{
|
||
name: "image",
|
||
match: (t) => /^!\[.*\]\(.*\)$/.test(t),
|
||
parse(_, i, t) {
|
||
const m = t.match(/^!\[(.*?)\]\((.*?)\)$/);
|
||
if (m) {
|
||
const alt = m[1];
|
||
const src = m[2];
|
||
return [`<a href="viewer.html?src=${encodeURIComponent(src)}"><img src="${src}" alt="${alt}" title="Click to expand"></a>`, i + 1];
|
||
}
|
||
return [`<p>${t}</p>`, i + 1];
|
||
},
|
||
},
|
||
{
|
||
name: "list",
|
||
match: (t) => t.startsWith("- "),
|
||
parse(lines, i) {
|
||
const items = [];
|
||
while (i < lines.length && lines[i].trim().startsWith("- ")) {
|
||
items.push(lines[i].trim().slice(2));
|
||
i++;
|
||
}
|
||
const inner = items.map((item) => `<li>${markdown.inline(item)}</li>`).join("");
|
||
return [`<ul>${inner}</ul>`, i];
|
||
},
|
||
},
|
||
],
|
||
|
||
table(rows) {
|
||
if (rows.length < 2) return "";
|
||
const split = (line) => line.split("|").slice(1, -1).map((c) => c.trim());
|
||
|
||
const headers = split(rows[0]);
|
||
const body = rows.slice(2).map(split);
|
||
|
||
const thead = headers.some((h) => h)
|
||
? `<thead><tr>${headers.map((h) => `<th>${this.inline(h)}</th>`).join("")}</tr></thead>`
|
||
: "";
|
||
const tbody = body
|
||
.map((row) => `<tr>${row.map((c) => `<td>${this.inline(c)}</td>`).join("")}</tr>`)
|
||
.join("");
|
||
|
||
return `<table>${thead}<tbody>${tbody}</tbody></table>`;
|
||
},
|
||
|
||
inlineRules: [
|
||
{ pattern: /\*\*(.+?)\*\*/g, tag: "strong" },
|
||
{ pattern: /\*(.+?)\*/g, tag: "em" },
|
||
{ pattern: /`(.+?)`/g, tag: "code" },
|
||
{ pattern: /\[(.+?)\]\((.+?)\)/g, render: (_, text, href) => `<a href="${href}">${text}</a>` },
|
||
],
|
||
|
||
inline(text) {
|
||
if (!text) return "";
|
||
return this.inlineRules.reduce((result, rule) => {
|
||
const replacer = rule.render || ((_, content) => `<${rule.tag}>${content}</${rule.tag}>`);
|
||
return result.replace(rule.pattern, replacer);
|
||
}, text);
|
||
},
|
||
|
||
escape(str) {
|
||
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||
},
|
||
};
|
||
|
||
// --- Navigation ---
|
||
|
||
function navigate(topicId) {
|
||
state.activeTopic = topicId;
|
||
state.expandedGroup = findParentGroup(topicId);
|
||
renderSidebar();
|
||
loadTopic(topicId);
|
||
}
|
||
|
||
// --- Init ---
|
||
|
||
document.addEventListener("DOMContentLoaded", async () => {
|
||
try {
|
||
const resp = await fetch("data/topics.json");
|
||
state.topics = await resp.json();
|
||
|
||
const hash = location.hash.slice(1);
|
||
state.activeTopic = state.topics.find((t) => t.id === hash) ? hash : state.topics[0].id;
|
||
state.expandedGroup = findParentGroup(state.activeTopic);
|
||
|
||
renderSidebar();
|
||
await loadTopic(state.activeTopic);
|
||
} catch (e) {
|
||
dom.content().innerHTML = `<p style="color:var(--artery-text);">Failed to load topics: ${e.message}</p>`;
|
||
}
|
||
});
|
||
|
||
window.addEventListener("hashchange", () => {
|
||
const hash = location.hash.slice(1);
|
||
if (state.topics.find((t) => t.id === hash) && hash !== state.activeTopic) {
|
||
navigate(hash);
|
||
}
|
||
});
|