thibaud frere
update
fef84f3
raw
history blame
12.1 kB
---
import Article, { frontmatter as articleFM } from './article.mdx';
import Meta from '../components/Meta.astro';
import HtmlFragment from '../components/HtmlFragment.astro';
import Footer from '../components/Footer.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.scss';
const docTitle = articleFM?.title ?? 'Untitled article';
// Autoriser un retour à la ligne dans le titre via "\n" ou sauts de ligne YAML
const docTitleHtml = (articleFM?.title ?? 'Untitled article')
.replace(/\\n/g, '<br/>')
.replace(/\n/g, '<br/>');
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>
</head>
<body>
<ThemeToggle />
<section class="hero">
<h1 class="hero-title" set:html={docTitleHtml}></h1>
<div class="hero-banner">
<HtmlFragment src="banner.html" />
<p class="hero-desc">It's nice to have a cute interactive banner!</p>
</div>
</section>
<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="toc-placeholder"></div>
</aside>
<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>
// Init zoom sur img[data-zoomable] avec attente du script & contenu; 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('toc-placeholder');
if (!holder || holder.children.length) return;
const articleRoot = document.querySelector('section.content-grid main');
if (!articleRoot) return;
const headings = articleRoot.querySelectorAll('h2, h3, h4');
if (!headings.length) return;
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;
headings.forEach((h) => {
const lvl = levelOf(h.tagName);
// ensure id exists
if (!h.id) h.id = h.textContent.toLowerCase().replace(/\s+/g, '_');
// 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);
});
holder.appendChild(nav);
// active link on scroll
const links = holder.querySelectorAll('a');
const onScroll = () => {
for (let i = headings.length - 1; i >= 0; i--) {
const top = headings[i].getBoundingClientRect().top;
if (top - 60 <= 0) {
links.forEach(l => l.classList.remove('active'));
const id = '#' + headings[i].id;
const active = Array.from(links).find(l => l.getAttribute('href') === id);
active && active.classList.add('active');
break;
}
}
};
window.addEventListener('scroll', onScroll);
onScroll();
};
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>