Spaces:
Running
Running
| --- | |
| import * as ArticleMod from '../content/article.mdx'; | |
| import Hero from '../components/Hero.astro'; | |
| import Footer from '../components/Footer.astro'; | |
| import ThemeToggle from '../components/ThemeToggle.astro'; | |
| import Seo from '../components/Seo.astro'; | |
| import TableOfContents from '../components/TableOfContents.astro'; | |
| // Default OG image served from public/ | |
| const ogDefaultUrl = '/thumb.jpg'; | |
| import 'katex/dist/katex.min.css'; | |
| import '../styles/global.css'; | |
| const articleFM = (ArticleMod as any).frontmatter ?? {}; | |
| const Article = (ArticleMod as any).default; | |
| 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 ?? ''; | |
| // Accept authors as string[] or array of objects { name, url, affiliations? } | |
| const rawAuthors = (articleFM as any)?.authors ?? []; | |
| type Affiliation = { id: number; name: string; url?: string }; | |
| type Author = { name: string; url?: string; affiliationIndices?: number[] }; | |
| // Normalize affiliations from frontmatter: supports strings or objects { id?, name, url? } | |
| const rawAffils = (articleFM as any)?.affiliations ?? (articleFM as any)?.affiliation ?? []; | |
| const normalizedAffiliations: Affiliation[] = (() => { | |
| const seen: Map<string, number> = new Map(); | |
| const list: Affiliation[] = []; | |
| const pushUnique = (name: string, url?: string) => { | |
| const key = `${String(name).trim()}|${url ? String(url).trim() : ''}`; | |
| if (seen.has(key)) return seen.get(key)!; | |
| const id = list.length + 1; | |
| list.push({ id, name: String(name).trim(), url: url ? String(url) : undefined }); | |
| seen.set(key, id); | |
| return id; | |
| }; | |
| const input = Array.isArray(rawAffils) ? rawAffils : (rawAffils ? [rawAffils] : []); | |
| for (const a of input) { | |
| if (typeof a === 'string') { | |
| pushUnique(a); | |
| } else if (a && typeof a === 'object') { | |
| const name = a.name ?? a.label ?? a.text ?? a.affiliation ?? ''; | |
| if (!String(name).trim()) continue; | |
| const url = a.url || a.link; | |
| // Respect provided numeric id for display stability if present and sequential; otherwise reassign | |
| pushUnique(String(name), url ? String(url) : undefined); | |
| } | |
| } | |
| return list; | |
| })(); | |
| // Helper: ensure an affiliation exists and return its id | |
| const ensureAffiliation = (val: any): number | undefined => { | |
| if (val == null) return undefined; | |
| if (typeof val === 'number' && Number.isFinite(val) && val > 0) { | |
| return Math.floor(val); | |
| } | |
| const name = typeof val === 'string' ? val : (val?.name ?? val?.label ?? val?.text ?? val?.affiliation); | |
| if (!name || !String(name).trim()) return undefined; | |
| const existing = normalizedAffiliations.find(a => a.name === String(name).trim()); | |
| if (existing) return existing.id; | |
| const id = normalizedAffiliations.length + 1; | |
| normalizedAffiliations.push({ id, name: String(name).trim(), url: val?.url || val?.link }); | |
| return id; | |
| }; | |
| // Normalize authors and map affiliations -> indices (Distill-like) | |
| const normalizedAuthors: Author[] = (Array.isArray(rawAuthors) ? rawAuthors : []) | |
| .map((a: any) => { | |
| if (typeof a === 'string') { | |
| return { name: a } as Author; | |
| } | |
| const name = String(a?.name || '').trim(); | |
| const url = a?.url || a?.link; | |
| let indices: number[] | undefined = undefined; | |
| const raw = a?.affiliations ?? a?.affiliation ?? a?.affils; | |
| if (raw != null) { | |
| const entries = Array.isArray(raw) ? raw : [raw]; | |
| const ids = entries.map(ensureAffiliation).filter((x): x is number => typeof x === 'number'); | |
| const unique = Array.from(new Set(ids)).sort((x, y) => x - y); | |
| if (unique.length) indices = unique; | |
| } | |
| return { name, url, affiliationIndices: indices } as Author; | |
| }) | |
| .filter((a: Author) => a.name && a.name.trim().length > 0); | |
| const authorNames: string[] = normalizedAuthors.map(a => a.name); | |
| const published = articleFM?.published ?? undefined; | |
| const tags = articleFM?.tags ?? []; | |
| // Prefer seoThumbImage from frontmatter if provided | |
| const fmOg = articleFM?.seoThumbImage as string | undefined; | |
| const imageAbs: string = fmOg && fmOg.startsWith('http') | |
| ? fmOg | |
| : (Astro.site ? new URL((fmOg ?? ogDefaultUrl), Astro.site).toString() : (fmOg ?? ogDefaultUrl)); | |
| // ---- 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 = authorNames.join(', '); | |
| const citationText = `${citationAuthorsText}${year ? ` (${year})` : ''}. "${titleFlat}".`; | |
| const authorsBib = authorNames.join(' and '); | |
| const keyAuthor = (authorNames[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 doi = (ArticleMod as any)?.frontmatter?.doi ? String((ArticleMod as any).frontmatter.doi) : undefined; | |
| const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBib}},\n ${year ? `year={${year}},\n ` : ''}${doi ? `doi={${doi}}` : ''}\n}`; | |
| const envCollapse = false; | |
| const tableOfContentAutoCollapse = Boolean( | |
| (articleFM as any)?.tableOfContentAutoCollapse ?? (articleFM as any)?.tableOfContentsAutoCollapse ?? envCollapse | |
| ); | |
| // Licence note (HTML allowed) | |
| const licence = (articleFM as any)?.licence ?? (articleFM as any)?.license ?? (articleFM as any)?.licenseNote; | |
| --- | |
| <html lang="en" data-theme="light" data-toc-auto-collapse={tableOfContentAutoCollapse ? '1' : '0'}> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <Seo title={docTitle} description={description} authors={authorNames} 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 is:inline src="/scripts/color-palettes.js"></script> | |
| <!-- TO MANAGE PROPERLY --> | |
| <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> | |
| <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/medium-zoom.min.js"></script> | |
| </head> | |
| <body> | |
| <ThemeToggle /> | |
| <Hero title={docTitleHtml} titleRaw={docTitle} description={subtitle} authors={normalizedAuthors as any} affiliations={normalizedAffiliations as any} affiliation={articleFM?.affiliation} published={articleFM?.published} doi={doi} /> | |
| <section class="content-grid"> | |
| <TableOfContents tableOfContentAutoCollapse={tableOfContentAutoCollapse} /> | |
| <main> | |
| <Article /> | |
| </main> | |
| </section> | |
| <Footer citationText={citationText} bibtex={bibtex} licence={licence} doi={doi} /> | |
| <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> | |
| // Delegate copy clicks for code blocks injected by rehypeCodeCopyAndLabel | |
| document.addEventListener('click', async (e) => { | |
| const target = e.target instanceof Element ? e.target : null; | |
| const btn = target ? target.closest('.code-copy') : null; | |
| if (!btn) return; | |
| const card = btn.closest('.code-card'); | |
| const pre = card && card.querySelector('pre'); | |
| if (!pre) return; | |
| const text = pre.textContent || ''; | |
| try { | |
| await navigator.clipboard.writeText(text.trim()); | |
| const old = btn.innerHTML; | |
| btn.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 16.2l-3.5-3.5-1.4 1.4L9 19 20.3 7.7l-1.4-1.4z"/></svg>'; | |
| setTimeout(() => (btn.innerHTML = old), 1200); | |
| } catch { | |
| btn.textContent = 'Error'; | |
| setTimeout(() => (btn.textContent = 'Copy'), 1200); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |