thibaud frere
update
c24ea90
raw
history blame
14.6 kB
---
import Article, { frontmatter as articleFM } from '../content/article.mdx';
import Meta from '../components/Meta.astro';
import Footer from '../components/Footer.astro';
import Header from '../components/Header.astro';
import ThemeToggle from '../components/ThemeToggle.astro';
import SeoHead from '../components/SeoHead.astro';
import ogDefault from '../assets/images/visual-vocabulary-poster.png';
import 'katex/dist/katex.min.css';
import '../styles/global.css';
const docTitle = articleFM?.title ?? 'Untitled article';
// Allow explicit line breaks in the title via "\n" or YAML newlines
const docTitleHtml = (articleFM?.title ?? 'Untitled article')
.replace(/\\n/g, '<br/>')
.replace(/\n/g, '<br/>');
const subtitle = articleFM?.subtitle ?? '';
const description = articleFM?.description ?? '';
const authors = articleFM?.authors ?? [];
const published = articleFM?.published ?? undefined;
const tags = articleFM?.tags ?? [];
// Prefer ogImage from frontmatter if provided
const fmOg = articleFM?.ogImage as string | undefined;
const imageAbs = fmOg && fmOg.startsWith('http')
? fmOg
: (Astro.site ? new URL((fmOg ?? ogDefault.src), Astro.site).toString() : (fmOg ?? ogDefault.src));
// ---- Build citation text & BibTeX from frontmatter ----
const rawTitle = articleFM?.title ?? 'Untitled article';
const titleFlat = String(rawTitle)
.replace(/\\n/g, ' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const extractYear = (val: string | undefined): number | undefined => {
if (!val) return undefined;
const d = new Date(val);
if (!Number.isNaN(d.getTime())) return d.getFullYear();
const m = String(val).match(/(19|20)\d{2}/);
return m ? Number(m[0]) : undefined;
};
const year = extractYear(published);
const citationAuthorsText = authors.join(', ');
const citationText = `${citationAuthorsText}${year ? ` (${year})` : ''}. "${titleFlat}".`;
const authorsBib = authors.join(' and ');
const keyAuthor = (authors[0] || 'article').split(/\s+/).slice(-1)[0].toLowerCase();
const keyTitle = titleFlat.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '').slice(0, 24);
const bibKey = `${keyAuthor}${year ?? ''}_${keyTitle}`;
const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBib}},\n ${year ? `year={${year}}` : ''}\n}`;
---
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{docTitle}</title>
<SeoHead title={docTitle} description={description} authors={authors} published={published} tags={tags} image={imageAbs} />
<script is:inline>
(() => {
try {
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = saved || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
} catch {}
})();
</script>
<script src="https://cdn.plot.ly/plotly-3.0.0.min.js" charset="utf-8"></script>
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
</head>
<body>
<ThemeToggle />
<Header title={docTitleHtml} description={subtitle}>
</Header>
<section class="article-header">
<Meta title={docTitle} authors={articleFM?.authors} affiliation={articleFM?.affiliation} published={articleFM?.published} />
</section>
<section class="content-grid">
<aside class="toc">
<div class="title">Table of Contents</div>
<div id="article-toc-placeholder"></div>
</aside>
<details class="toc-mobile">
<summary>Table of Contents</summary>
<div id="article-toc-mobile-placeholder"></div>
</details>
<main>
<Article />
<style is:inline>
/* Inline tweak for details blocks used in MDX */
details { background: var(--code-bg) !important; border: 1px solid var(--border-color) !important; border-radius: 6px; margin: 1em 0; padding: .5em .75em; }
</style>
</main>
</section>
<Footer citationText={citationText} bibtex={bibtex} />
<!-- Medium-like image zoom (lightbox) -->
<script src="https://cdn.jsdelivr.net/npm/medium-zoom@1.1.0/dist/medium-zoom.min.js"></script>
<script>
// Initialize zoom on img[data-zoomable]; wait for script & content; close on scroll like Medium
(() => {
let zoomInstance = null;
const ensureMediumZoomReady = (cb) => {
if (window.mediumZoom) return cb();
const retry = () => (window.mediumZoom ? cb() : setTimeout(retry, 30));
retry();
};
const collectTargets = () => Array.from(document.querySelectorAll('section.content-grid main img[data-zoomable]'));
const initOrUpdateZoom = () => {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const background = isDark ? 'rgba(0,0,0,.9)' : 'rgba(0,0,0,.85)';
const targets = collectTargets();
if (!targets.length) return;
if (!zoomInstance) {
zoomInstance = window.mediumZoom(targets, { background, margin: 24, scrollOffset: 0 });
let onScrollLike;
const attachCloseOnScroll = () => {
if (onScrollLike) return;
onScrollLike = () => { zoomInstance && zoomInstance.close(); };
window.addEventListener('wheel', onScrollLike, { passive: true });
window.addEventListener('touchmove', onScrollLike, { passive: true });
window.addEventListener('scroll', onScrollLike, { passive: true });
};
const detachCloseOnScroll = () => {
if (!onScrollLike) return;
window.removeEventListener('wheel', onScrollLike);
window.removeEventListener('touchmove', onScrollLike);
window.removeEventListener('scroll', onScrollLike);
onScrollLike = null;
};
zoomInstance.on('open', attachCloseOnScroll);
zoomInstance.on('close', detachCloseOnScroll);
const themeObserver = new MutationObserver(() => {
const dark = document.documentElement.getAttribute('data-theme') === 'dark';
zoomInstance && zoomInstance.update({ background: dark ? 'rgba(0,0,0,.9)' : 'rgba(0,0,0,.85)' });
});
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
} else {
zoomInstance.attach(targets);
zoomInstance.update({ background });
}
};
const bootstrap = () => ensureMediumZoomReady(() => {
initOrUpdateZoom();
setTimeout(initOrUpdateZoom, 0);
const main = document.querySelector('section.content-grid main');
if (main) {
const mo = new MutationObserver(() => initOrUpdateZoom());
mo.observe(main, { childList: true, subtree: true });
}
});
if (document.readyState === 'complete') bootstrap();
else window.addEventListener('load', bootstrap, { once: true });
})();
</script>
<script>
// Open external links in a new tab; keep internal anchors in-page
const setExternalTargets = () => {
const isExternal = (href) => {
try { const u = new URL(href, location.href); return u.origin !== location.origin; } catch { return false; }
};
document.querySelectorAll('a[href]').forEach(a => {
const href = a.getAttribute('href');
if (!href) return;
if (isExternal(href)) {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
} else {
a.removeAttribute('target');
}
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setExternalTargets, { once: true });
} else { setExternalTargets(); }
</script>
<script>
// Build TOC from article headings (h2/h3/h4) and render into the sticky aside
const buildTOC = () => {
const holder = document.getElementById('article-toc-placeholder');
const holderMobile = document.getElementById('article-toc-mobile-placeholder');
// Always rebuild TOC to avoid stale entries
if (holder) holder.innerHTML = '';
if (holderMobile) holderMobile.innerHTML = '';
const articleRoot = document.querySelector('section.content-grid main');
if (!articleRoot) return;
const headings = articleRoot.querySelectorAll('h2, h3, h4');
if (!headings.length) return;
// Filter out headings that should not appear in TOC
const normalize = (s) => String(s || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
const isTocLabel = (s) => /^(table\s+of\s+contents?)$|^toc$/i.test(String(s || '').replace(/[^a-zA-Z0-9]+/g, ' ').trim());
const shouldSkip = (h) => {
const t = h.textContent || '';
const id = String(h.id || '');
const slug = normalize(t).replace(/\s+/g, '_');
if (isTocLabel(t)) return true;
if (isTocLabel(id.replace(/[_-]+/g, ' '))) return true;
if (isTocLabel(slug.replace(/[_-]+/g, ' '))) return true;
return false;
};
const headingsArr = Array.from(headings).filter(h => !shouldSkip(h));
if (!headingsArr.length) return;
// Ensure unique ids for headings (deduplicate duplicates)
const usedIds = new Set<string>();
const slugify = (s: string) => String(s || '')
.toLowerCase()
.trim()
.replace(/\s+/g, '_')
.replace(/[^a-z0-9_\-]/g, '');
headingsArr.forEach((h) => {
let id = (h.id || '').trim();
if (!id) {
const base = slugify(h.textContent || '');
id = base || 'section';
}
let candidate = id;
let n = 2;
while (usedIds.has(candidate)) {
candidate = `${id}-${n++}`;
}
if (h.id !== candidate) h.id = candidate;
usedIds.add(candidate);
});
const nav = document.createElement('nav');
let ulStack = [document.createElement('ul')];
nav.appendChild(ulStack[0]);
const levelOf = (tag) => tag === 'H2' ? 2 : tag === 'H3' ? 3 : 4;
let prev = 2;
headingsArr.forEach((h) => {
const lvl = levelOf(h.tagName);
// adjust depth
while (lvl > prev) { const ul = document.createElement('ul'); ulStack[ulStack.length-1].lastElementChild?.appendChild(ul); ulStack.push(ul); prev++; }
while (lvl < prev) { ulStack.pop(); prev--; }
const li = document.createElement('li');
const a = document.createElement('a');
a.href = '#' + h.id; a.textContent = h.textContent; a.target = '_self';
li.appendChild(a);
ulStack[ulStack.length-1].appendChild(li);
});
if (holder) holder.appendChild(nav);
if (holderMobile) holderMobile.appendChild(nav.cloneNode(true));
// active link on scroll
const links = [
...(holder ? holder.querySelectorAll('a') : []),
...(holderMobile ? holderMobile.querySelectorAll('a') : [])
];
const onScroll = () => {
for (let i = headingsArr.length - 1; i >= 0; i--) {
const top = headingsArr[i].getBoundingClientRect().top;
if (top - 60 <= 0) {
links.forEach(l => l.classList.remove('active'));
const id = '#' + headingsArr[i].id;
const actives = Array.from(links).filter(l => l.getAttribute('href') === id);
actives.forEach(a => a.classList.add('active'));
break;
}
}
};
window.addEventListener('scroll', onScroll);
onScroll();
// Close mobile accordion when a link inside it is clicked
if (holderMobile) {
const details = holderMobile.closest('details');
holderMobile.addEventListener('click', (ev) => {
const target = ev.target as Element | null;
const anchor = target && 'closest' in target ? (target as Element).closest('a') : null;
if (anchor instanceof HTMLAnchorElement && details && (details as HTMLDetailsElement).open) {
(details as HTMLDetailsElement).open = false;
}
});
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', buildTOC, { once: true });
} else { buildTOC(); }
</script>
<script>
// Inject visible language badges for code blocks when data-language is missing
const addCodeLangChips = () => {
const blocks = document.querySelectorAll('section.content-grid pre > code');
blocks.forEach(code => {
const pre = code.parentElement;
if (!pre || pre.querySelector('.code-lang-chip')) return;
// Try several places to detect language
const getLang = () => {
const direct = code.getAttribute('data-language') || code.dataset?.language;
if (direct) return direct;
const codeClass = (code.className || '').match(/language-([a-z0-9+\-]+)/i);
if (codeClass) return codeClass[1];
const preData = pre.getAttribute('data-language') || pre.dataset?.language;
if (preData) return preData;
const wrapper = pre.closest('.astro-code');
if (wrapper) {
const wrapData = wrapper.getAttribute('data-language') || wrapper.dataset?.language;
if (wrapData) return wrapData;
const wrapClass = (wrapper.className || '').match(/language-([a-z0-9+\-]+)/i);
if (wrapClass) return wrapClass[1];
}
return 'text';
};
const lang = getLang().toUpperCase();
const chip = document.createElement('span');
chip.className = 'code-lang-chip';
chip.textContent = lang;
pre.classList.add('has-lang-chip');
pre.appendChild(chip);
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', addCodeLangChips, { once: true });
} else { addCodeLangChips(); }
</script>
</body>
</html>