| --- |
| 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'; |
| |
| 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 ?? []; |
| |
| 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)); |
|
|
| |
| 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> |
| |
| 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} /> |
| |
| |
| <script src="https://cdn.jsdelivr.net/npm/medium-zoom@1.1.0/dist/medium-zoom.min.js"></script> |
| <script> |
| |
| (() => { |
| 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> |
| |
| 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> |
| |
| 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); |
| |
| if (!h.id) h.id = h.textContent.toLowerCase().replace(/\s+/g, '_'); |
| |
| 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); |
| |
| |
| 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> |
| |
| 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; |
| |
| 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> |
|
|
|
|
|
|