blog / main.js
main.js10.3 KB
// main.js
// ============================================================
// Minimal blog engine with bilingual posts
// - Files: *.en.md / *.fr.md in /posts
// - English is default for the list view
// - List view: global language switch (#lang=en|fr), current underlined
// - Article view: show "FR · EN" ONLY if sibling exists (current underlined)
// - Supports tags, KaTeX, and highlight.js
// ============================================================
// ---------- Global state ----------
let currentLang = "en";
const view = document.getElementById("view");
let allPosts = [];
let masterFiles = [];
// ---------- Front-matter parser ----------
function parseMetadata(md) {
const m = md.match(/^---([\s\S]*?)---/);
const meta = {};
if (!m) return meta;
const lines = m[1].trim().split("\n");
for (const line of lines) {
const i = line.indexOf(":");
if (i === -1) continue;
const key = line.slice(0, i).trim();
const raw = line.slice(i + 1).trim();
if (!key) continue;
if (raw.startsWith("[") && raw.endsWith("]")) {
meta[key] = raw.slice(1, -1).split(",").map(s => s.trim()).filter(Boolean);
} else {
meta[key] = raw;
}
}
return meta;
}
// ---------- Rendering helpers ----------
function sanitizeHtml(html) {
if (!window.DOMPurify) return html;
return window.DOMPurify.sanitize(html, {
ADD_TAGS: ["iframe"],
ADD_ATTR: [
"src", "style", "loading", "title",
"sandbox", "allow", "allowfullscreen",
"referrerpolicy", "width", "height", "scrolling",
"name", "target"
],
});
}
function renderMathAndCode(rootEl) {
if (window.hljs) {
rootEl.querySelectorAll("pre code:not(.language-mermaid)").forEach(el => window.hljs.highlightElement(el));
}
if (window.renderMathInElement) {
window.renderMathInElement(rootEl, {
delimiters: [
{ left: "$$", right: "$$", display: true },
{ left: "$", right: "$", display: false }
],
throwOnError: false
});
}
if (window.mermaid) {
rootEl.querySelectorAll("pre code.language-mermaid").forEach(el => {
const code = el.textContent;
const div = document.createElement("div");
div.className = "mermaid";
div.textContent = code;
el.closest("pre").replaceWith(div);
});
window.mermaid.initialize({ startOnLoad: false, theme: "neutral" });
window.mermaid.run({ nodes: rootEl.querySelectorAll(".mermaid") }).then(() => {
rootEl.querySelectorAll(".mermaid").forEach(div => {
div.addEventListener("click", () => {
let overlay = document.querySelector(".mermaid-overlay");
if (!overlay) {
overlay = document.createElement("div");
overlay.className = "mermaid-overlay";
document.body.appendChild(overlay);
overlay.addEventListener("click", () => overlay.classList.remove("active"));
}
const svgEl = div.querySelector("svg");
overlay.innerHTML = "";
if (svgEl) {
const clone = svgEl.cloneNode(true);
clone.removeAttribute("style");
const origWidth = svgEl.getBoundingClientRect().width || parseFloat(svgEl.getAttribute("width") || "800");
const origHeight = svgEl.getBoundingClientRect().height || parseFloat(svgEl.getAttribute("height") || "600");
const scale = Math.max(1.5, (window.innerWidth * 1.15) / origWidth);
const wrapper = document.createElement("div");
wrapper.style.cssText = `width:${origWidth * scale}px;height:${origHeight * scale}px`;
clone.style.cssText = `transform-origin:0 0;transform:scale(${scale})`;
wrapper.appendChild(clone);
overlay.appendChild(wrapper);
}
overlay.classList.add("active");
});
});
});
}
}
// ---------- Utilities ----------
function allFilesHas(name) {
return masterFiles.includes(name);
}
function siblingFor(file) {
if (/\.en\.md$/i.test(file)) return file.replace(/\.en\.md$/i, ".fr.md");
if (/\.fr\.md$/i.test(file)) return file.replace(/\.fr\.md$/i, ".en.md");
return null;
}
// Robust check: use master list, fallback to HEAD/GET
function checkSiblingExists(sib) {
if (!sib) return Promise.resolve(false);
if (allFilesHas(sib)) return Promise.resolve(true);
// Try HEAD first; if unsupported, fall back to GET
return fetch(`posts/${sib}`, { method: "HEAD" })
.then(r => r.ok ? true : (r.status === 405 ? fetch(`posts/${sib}`, { cache: "no-store" }).then(rr => rr.ok) : false))
.catch(() => false);
}
// ---------- UI snippets ----------
function langSwitcherInline() {
const en = (currentLang === "en")
? `<span class="active">EN</span>`
: `<a href="#lang=en">EN</a>`;
const fr = (currentLang === "fr")
? `<span class="active">FR</span>`
: `<a href="#lang=fr">FR</a>`;
return `<span class="lang-toggle">${en} · ${fr}</span>`;
}
// ---------- List view ----------
function displayList(filterTag = null) {
view.innerHTML = "";
const top = document.createElement("div");
top.innerHTML = langSwitcherInline();
view.appendChild(top);
if (filterTag) {
const back = document.createElement("p");
back.innerHTML = `<a href="#">← View all posts</a>`;
view.appendChild(back);
}
const ul = document.createElement("ul");
ul.id = "post-list";
allPosts.forEach(({ file, meta }) => {
const tags = Array.isArray(meta.tags) ? meta.tags : (meta.tags ? [meta.tags] : []);
if (!filterTag || tags.includes(filterTag)) {
const _t = meta.title ?? meta.titre;
const title = Array.isArray(_t) ? _t[0] : (_t || file);
const tagLinks = tags.map(t => `<a href="#tag=${encodeURIComponent(t)}" class="tag">${t}</a>`).join(" ");
const d = Array.isArray(meta.date) ? meta.date[0] : meta.date;
const dateStr = d ? new Date(d).toISOString().slice(0, 10) : "";
const li = document.createElement("li");
li.innerHTML = `
<span class="post-title"><a href="#post=${encodeURIComponent(file)}" data-file="${file}">${title}</a></span>
<span class="post-meta">
<span class="post-tags">${tagLinks}</span>
<span class="post-date">${dateStr}</span>
</span>
`;
ul.appendChild(li);
}
});
view.appendChild(ul);
}
// ---------- Single post view ----------
function displayPost(file) {
view.innerHTML = "";
fetch(`posts/${file}`, {cache: 'no-store'})
.then(res => {
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.text();
})
.then(md => {
const meta = parseMetadata(md);
const title = Array.isArray(meta.title) ? meta.title[0] : (meta.title || "");
const content = md.replace(/^---[\s\S]*?---/, "").trim();
const rawHtml = (window.marked?.marked || window.marked).parse(content);
const titleHtml = (title && !meta.notitle) ? `<h1>${title}</h1>` : "";
const html = titleHtml + sanitizeHtml(rawHtml);
const isEN = /\.en\.md$/i.test(file);
const isFR = /\.fr\.md$/i.test(file);
const sib = siblingFor(file);
return checkSiblingExists(sib).then(hasSib => {
let langSwitch = "";
if (hasSib) {
const frPart = isFR
? `<span class="active">FR</span>`
: `<a href="#post=${encodeURIComponent(sib)}">FR</a>`;
const enPart = isEN
? `<span class="active">EN</span>`
: `<a href="#post=${encodeURIComponent(sib)}">EN</a>`;
langSwitch = `<span class="lang-switch">${frPart} · ${enPart}</span>`;
}
const toolbarClass = meta.notitle ? "toolbar toolbar-fullwidth" : "toolbar";
view.innerHTML = `
<p class="${toolbarClass}">
<a href="#">← Back</a>
${langSwitch}
</p>
<article class="markdown-body">${html}</article>
`;
renderMathAndCode(view);
});
})
.catch(err => {
view.innerHTML = `<p>Error loading post: ${sanitizeHtml(String(err.message))}</p>`;
});
}
// ---------- Loading & filtering ----------
function loadPostsFor(lang) {
currentLang = lang;
allPosts = [];
return fetch("posts/index.json")
.then(res => {
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
})
.then(files => {
masterFiles = Array.isArray(files) ? files : [];
const filtered = masterFiles.filter(f => f.endsWith(`.${currentLang}.md`));
return Promise.all(filtered.map(file =>
fetch(`posts/${file}`)
.then(r => r.text())
.then(md => {
const meta = parseMetadata(md);
const d = Array.isArray(meta.date) ? meta.date[0] : meta.date;
const dateObj = new Date(d || "1970-01-01");
allPosts.push({ file, meta, dateObj });
})
));
})
.then(() => {
allPosts.sort((a, b) => b.dateObj - a.dateObj);
routeByHash();
})
.catch(err => {
view.innerHTML = `<p>Error loading index: ${sanitizeHtml(String(err.message))}</p>`;
});
}
// ---------- Router ----------
function routeByHash() {
const h = location.hash || "";
if (h.startsWith("#lang=")) {
const lang = h.slice(6);
if ((lang === "en" || lang === "fr") && lang !== currentLang) {
loadPostsFor(lang);
return;
}
}
if (h.startsWith("#tag=")) {
const tag = decodeURIComponent(h.slice(5));
displayList(tag);
return;
}
if (h.startsWith("#post=")) {
const file = decodeURIComponent(h.slice(6));
displayPost(file);
return;
}
displayList();
}
// ---------- Events ----------
view.addEventListener("click", e => {
const a = e.target.closest("a");
if (!a) return;
const href = a.getAttribute("href") || "";
if (href.startsWith("#")) {
e.preventDefault();
location.hash = href;
}
});
window.addEventListener("hashchange", routeByHash);
// ---------- Iframe auto-resize via postMessage ----------
// Match by contentWindow === e.source so we don't depend on id surviving DOMPurify.
window.addEventListener("message", function(e) {
if (e.data && e.data.type === "divePlannerResize") {
var frames = document.querySelectorAll("iframe");
for (var i = 0; i < frames.length; i++) {
try {
if (frames[i].contentWindow === e.source) {
frames[i].style.height = e.data.height + "px";
break;
}
} catch (_) {}
}
}
});
// ---------- Init ----------
loadPostsFor("en");