Files
soleprint/docs/docs.js
2026-04-14 10:32:05 -03:00

319 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
},
};
// --- 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);
}
});