<% // @license MIT // @copyright 2026 Mickaël Canouil // @author Mickaël Canouil
const SELF_FAMILY = “Canouil”; const SELF_GIVEN = “Mickaël”;
const TYPES = { article: { label: “Article”, singular: “article”, plural: “articles” }, preprint: { label: “Preprint”, singular: “preprint”, plural: “preprints” }, chapter: { label: “Chapter”, singular: “chapter”, plural: “chapters” }, conf: { label: “Conf.”, singular: “conference paper”, plural: “conference papers” }, };
const NUMBER_WORDS = [“zero”, “one”, “two”, “three”, “four”, “five”, “six”, “seven”, “eight”, “nine”];
const AUTHOR_LIST_LIMIT = 30;
function spellCount(n) { return n < NUMBER_WORDS.length ? NUMBER_WORDS[n] : String(n); }
function escapeHtml(s) { return String(s == null ? “” : s) .replace(/&/g, “&”) .replace(/</g, “<”) .replace(/>/g, “>”) .replace(/“/g,”"“) .replace(/’/g,”'“); }
function safeUrl(s) { const url = String(s == null ? “” : s).trim(); if (!url) return ““; if (/^(?:https?:|mailto:|/|#)/i.test(url)) return url; return”“; }
function authorName(a) { const family = a.family || ““; const particle = a[”dropping-particle”] || a[”non-dropping-particle”] ||”“; const given = a.given ||”“; const initials = given .split(/[-]+/) .filter(Boolean) .map((p) => p.charAt(0).toUpperCase() +”.”) .join(” “); const fullFamily = particle ? ${particle} ${family} : family; return { family, given, fullFamily, initials }; }
function decodeAuthors(b64) { if (!b64) return []; try { return JSON.parse(Buffer.from(b64, “base64”).toString(“utf8”)); } catch (e) { return []; } }
function authorHtml(a) { if (a.literal) { return <span class="pub-author--literal">${escapeHtml(a.literal)}</span>; } const { family, given, fullFamily, initials } = authorName(a); if (!fullFamily && !initials) return ““; const display = initials ? ${fullFamily}, ${initials} : fullFamily; const isSelf = family === SELF_FAMILY && given.startsWith(SELF_GIVEN); return isSelf ? <strong class="pub-author--self">${escapeHtml(display)}</strong> : escapeHtml(display); }
function renderAuthors(authors) { if (!authors || authors.length === 0) return ““; const total = authors.length; if (total <= AUTHOR_LIST_LIMIT) { return authors.map(authorHtml).filter(Boolean).join(”, “); }
const lastIdx = total - 1; const selfIdx = authors.findIndex((a) => (a.family || ““) === SELF_FAMILY && (a.given ||”“).startsWith(SELF_GIVEN) );
const keep = new Set([0, 1, 2, lastIdx]); if (selfIdx >= 0) keep.add(selfIdx); const sorted = […keep].sort((a, b) => a - b);
const parts = []; let prev = -1; for (const i of sorted) { if (prev >= 0 && i > prev + 1) parts.push(‘’); const html = authorHtml(authors[i]); if (html) parts.push(html); prev = i; } return parts.join(“,”); }
function getYear(item) { if (item.year) return String(item.year); if (item.issued) return String(item.issued).split(“-”)[0]; if (item.date) return String(item.date).split(“-”)[0]; return “Unknown”; }
function stripStars(s) { return String(s == null ? “” : s).replace(/*/g, ““); }
function titleCase(s) { return String(s == null ? “” : s).replace(/(^|[-([{:;])([a-z])/g, (_, sep, c) => sep + c.toUpperCase()); }
function venueText(item) { const journal = stripStars(item[“journal-title”] || item[“container-title”] || ““); const meta = []; if (item.volume) meta.push(vol. ${item.volume}); if (item.issue) meta.push(no. ${item.issue}); if (item.page) meta.push(pp. ${item.page}); if (item[”publisher-place”]) meta.push(item[”publisher-place”]); return { journal, meta: meta.join(”, “) }; }
function formatDate(item) { if (item[“pub-date-label”]) return String(item[“pub-date-label”]); return getYear(item); }
function entryHtml(item) { const bibtype = item.bibtype || “article”; const typeLabel = (TYPES[bibtype] && TYPES[bibtype].label) || “Article”; const venue = venueText(item); const dateLabel = formatDate(item); const isFirst = String(item.first || ““).includes(”first”); const isLast = String(item.last || ““).includes(”last”); const titleText = titleCase(stripStars(item.title || ““)); const linkTarget = safeUrl(item.path || item.url); const doi = item.doi; const doiHref = doi ? safeUrl(https://doi.org/${doi}) :”“; const url = safeUrl(item.url); const pdfUrl = safeUrl(item[“pdf-url”]); const codeUrl = safeUrl(item[“code-url”]); const slidesUrl = safeUrl(item[“slides-url”]); const bibtex = item.bibtex;
const head = [ <span class="pub-badge pub-badge--${bibtype}">${escapeHtml(typeLabel)}</span>, ]; if (isFirst) head.push(<span class="pub-mark pub-mark--first" title="As first or co-first author">★ As first or co-first author</span>); if (isLast) head.push(<span class="pub-mark pub-mark--last" title="As last or co-last author">★ As last or co-last author</span>); if (item.position) head.push(<span class="pub-position" title="Author position">${escapeHtml(item.position)}</span>); if (dateLabel) head.push(<time class="pub-date">${escapeHtml(dateLabel)}</time>);
const titleHtml = linkTarget ? <a href="${escapeHtml(linkTarget)}" rel="noopener noreferrer" target="_blank">${escapeHtml(titleText)}</a> : escapeHtml(titleText);
const venueHtml = venue.journal ? <p class="pub-venue"><em>${escapeHtml(venue.journal)}</em>${venue.meta ?, : ""}</p> : ““;
const linkChip = (href, label) => <a class="pub-chip" href="${escapeHtml(href)}" rel="noopener noreferrer" target="_blank">${label}</a>; const chips = [ [doiHref, “DOI”], [url && url !== doiHref ? url : ““,”Article”], [pdfUrl, “PDF”], [codeUrl, “Code”], [slidesUrl, “Slides”], ] .filter(([href]) => href) .map(([href, label]) => linkChip(href, label)); if (bibtex) chips.push(<button type="button" class="pub-chip pub-chip--bibtex" data-bibtex="${escapeHtml(bibtex)}" aria-label="Copy BibTeX entry">BibTeX</button>);
const authors = renderAuthors(decodeAuthors(item[“pub-author”]));
return [ <article class="pub-entry" data-bibtype="${escapeHtml(bibtype)}">, <div class="pub-entry-head">${head.join("")}</div>, <p class="pub-authors">${authors}</p>, <h3 class="pub-title no-anchor">${titleHtml}</h3>, venueHtml ? ${venueHtml} : ““, <div class="pub-actions">${chips.join("")}</div>, </article>, ].filter(Boolean).join(“”); }
const groups = new Map(); for (const item of items) { const y = getYear(item); if (!groups.has(y)) groups.set(y, []); groups.get(y).push(item); } const yearOrder = […groups.keys()].sort((a, b) => b.localeCompare(a)); %>