HomeMAL' // MANUFACTORUM
datacrypt
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");