Spaces:
Running
Running
thibaud frere
commited on
Commit
·
a4b9560
1
Parent(s):
ebb053a
big refactor
Browse files- app/.astro/astro/content.d.ts +20 -6
- app/astro.config.mjs +11 -139
- app/plugins/rehype/code-copy-and-label.mjs +129 -0
- app/plugins/rehype/post-citation.mjs +441 -0
- app/plugins/rehype/restore-at-in-code.mjs +22 -0
- app/plugins/rehype/wrap-tables.mjs +43 -0
- app/plugins/remark/ignore-citations-in-code.mjs +21 -0
- app/src/components/Accordion.astro +25 -4
- app/src/components/Footer.astro +182 -16
- app/src/components/Hero.astro +146 -40
- app/src/components/HtmlEmbed.astro +34 -5
- app/src/components/Note.astro +17 -11
- app/src/components/ResponsiveImage.astro +46 -0
- app/src/components/Sidenote.astro +28 -3
- app/src/components/{TableOfContent.astro → TableOfContents.astro} +136 -10
- app/src/components/ThemeToggle.astro +7 -0
- app/src/content/article.mdx +25 -5
- app/src/content/bibliography.bib +0 -12
- app/src/content/chapters/{available-blocks.mdx → components.mdx} +41 -187
- app/src/content/chapters/debug-components.mdx +37 -0
- app/src/content/chapters/getting-started.mdx +2 -0
- app/src/content/chapters/introduction.mdx +13 -11
- app/src/content/chapters/markdown.mdx +285 -0
- app/src/content/chapters/writing-your-content.mdx +41 -96
- app/src/pages/index.astro +84 -23
- app/src/styles/_base.css +4 -229
- app/src/styles/_layout.css +21 -76
- app/src/styles/_print.css +66 -0
- app/src/styles/_reset.css +13 -0
- app/src/styles/_variables.css +62 -25
- app/src/styles/components/_button.css +58 -0
- app/src/styles/components/_code.css +113 -35
- app/src/styles/components/_footer.css +0 -56
- app/src/styles/components/_poltly.css +0 -44
- app/src/styles/components/_table.css +95 -0
- app/src/styles/components/_tag.css +14 -0
- app/src/styles/global.css +20 -49
app/.astro/astro/content.d.ts
CHANGED
|
@@ -152,16 +152,23 @@ declare module 'astro:content' {
|
|
| 152 |
|
| 153 |
type ContentEntryMap = {
|
| 154 |
"chapters": {
|
| 155 |
-
"
|
| 156 |
-
id: "
|
| 157 |
-
slug: "
|
| 158 |
body: string;
|
| 159 |
collection: "chapters";
|
| 160 |
data: any
|
| 161 |
} & { render(): Render[".mdx"] };
|
| 162 |
-
"
|
| 163 |
-
id: "
|
| 164 |
-
slug: "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
body: string;
|
| 166 |
collection: "chapters";
|
| 167 |
data: any
|
|
@@ -180,6 +187,13 @@ declare module 'astro:content' {
|
|
| 180 |
collection: "chapters";
|
| 181 |
data: any
|
| 182 |
} & { render(): Render[".mdx"] };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
"writing-your-content.mdx": {
|
| 184 |
id: "writing-your-content.mdx";
|
| 185 |
slug: "writing-your-content";
|
|
|
|
| 152 |
|
| 153 |
type ContentEntryMap = {
|
| 154 |
"chapters": {
|
| 155 |
+
"best-pratices.mdx": {
|
| 156 |
+
id: "best-pratices.mdx";
|
| 157 |
+
slug: "best-pratices";
|
| 158 |
body: string;
|
| 159 |
collection: "chapters";
|
| 160 |
data: any
|
| 161 |
} & { render(): Render[".mdx"] };
|
| 162 |
+
"components.mdx": {
|
| 163 |
+
id: "components.mdx";
|
| 164 |
+
slug: "components";
|
| 165 |
+
body: string;
|
| 166 |
+
collection: "chapters";
|
| 167 |
+
data: any
|
| 168 |
+
} & { render(): Render[".mdx"] };
|
| 169 |
+
"debug-components.mdx": {
|
| 170 |
+
id: "debug-components.mdx";
|
| 171 |
+
slug: "debug-components";
|
| 172 |
body: string;
|
| 173 |
collection: "chapters";
|
| 174 |
data: any
|
|
|
|
| 187 |
collection: "chapters";
|
| 188 |
data: any
|
| 189 |
} & { render(): Render[".mdx"] };
|
| 190 |
+
"markdown.mdx": {
|
| 191 |
+
id: "markdown.mdx";
|
| 192 |
+
slug: "markdown";
|
| 193 |
+
body: string;
|
| 194 |
+
collection: "chapters";
|
| 195 |
+
data: any
|
| 196 |
+
} & { render(): Render[".mdx"] };
|
| 197 |
"writing-your-content.mdx": {
|
| 198 |
id: "writing-your-content.mdx";
|
| 199 |
slug: "writing-your-content";
|
app/astro.config.mjs
CHANGED
|
@@ -8,137 +8,14 @@ import remarkFootnotes from 'remark-footnotes';
|
|
| 8 |
import rehypeSlug from 'rehype-slug';
|
| 9 |
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
| 10 |
import rehypeCitation from 'rehype-citation';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
// Built-in Shiki (dual themes) — no rehype-pretty-code
|
| 12 |
|
| 13 |
-
//
|
| 14 |
-
function rehypeCodeCopyAndLabel() {
|
| 15 |
-
return (tree) => {
|
| 16 |
-
// Walk the tree; lightweight visitor to find <pre><code>
|
| 17 |
-
const visit = (node, parent) => {
|
| 18 |
-
if (!node || typeof node !== 'object') return;
|
| 19 |
-
const children = Array.isArray(node.children) ? node.children : [];
|
| 20 |
-
if (node.tagName === 'pre' && children.some(c => c.tagName === 'code')) {
|
| 21 |
-
// Find code child and guess language
|
| 22 |
-
const code = children.find(c => c.tagName === 'code');
|
| 23 |
-
const collectClasses = (val) => Array.isArray(val) ? val.map(String) : (typeof val === 'string' ? String(val).split(/\s+/) : []);
|
| 24 |
-
const fromClass = (names) => {
|
| 25 |
-
const hit = names.find((n) => /^language-/.test(String(n)));
|
| 26 |
-
return hit ? String(hit).replace(/^language-/, '') : '';
|
| 27 |
-
};
|
| 28 |
-
const codeClasses = collectClasses(code?.properties?.className);
|
| 29 |
-
const preClasses = collectClasses(node?.properties?.className);
|
| 30 |
-
const candidates = [
|
| 31 |
-
code?.properties?.['data-language'],
|
| 32 |
-
fromClass(codeClasses),
|
| 33 |
-
node?.properties?.['data-language'],
|
| 34 |
-
fromClass(preClasses),
|
| 35 |
-
];
|
| 36 |
-
let lang = candidates.find(Boolean) || '';
|
| 37 |
-
const lower = String(lang).toLowerCase();
|
| 38 |
-
const toExt = (s) => {
|
| 39 |
-
switch (String(s).toLowerCase()) {
|
| 40 |
-
case 'typescript': case 'ts': return 'ts';
|
| 41 |
-
case 'tsx': return 'tsx';
|
| 42 |
-
case 'javascript': case 'js': case 'node': return 'js';
|
| 43 |
-
case 'jsx': return 'jsx';
|
| 44 |
-
case 'python': case 'py': return 'py';
|
| 45 |
-
case 'bash': case 'shell': case 'sh': return 'sh';
|
| 46 |
-
case 'markdown': case 'md': return 'md';
|
| 47 |
-
case 'yaml': case 'yml': return 'yml';
|
| 48 |
-
case 'html': return 'html';
|
| 49 |
-
case 'css': return 'css';
|
| 50 |
-
case 'json': return 'json';
|
| 51 |
-
default: return lower || '';
|
| 52 |
-
}
|
| 53 |
-
};
|
| 54 |
-
const ext = toExt(lower);
|
| 55 |
-
const displayLang = ext ? String(ext).toUpperCase() : '';
|
| 56 |
-
// Determine if single-line block: prefer Shiki lines, then text content
|
| 57 |
-
const countLinesFromShiki = () => {
|
| 58 |
-
const isLineEl = (el) => el && el.type === 'element' && el.tagName === 'span' && Array.isArray(el.properties?.className) && el.properties.className.includes('line');
|
| 59 |
-
const hasNonWhitespaceText = (node) => {
|
| 60 |
-
if (!node) return false;
|
| 61 |
-
if (node.type === 'text') return /\S/.test(String(node.value || ''));
|
| 62 |
-
const kids = Array.isArray(node.children) ? node.children : [];
|
| 63 |
-
return kids.some(hasNonWhitespaceText);
|
| 64 |
-
};
|
| 65 |
-
const collectLines = (node, acc) => {
|
| 66 |
-
if (!node || typeof node !== 'object') return;
|
| 67 |
-
if (isLineEl(node)) acc.push(node);
|
| 68 |
-
const kids = Array.isArray(node.children) ? node.children : [];
|
| 69 |
-
kids.forEach((k) => collectLines(k, acc));
|
| 70 |
-
};
|
| 71 |
-
const lines = [];
|
| 72 |
-
collectLines(code, lines);
|
| 73 |
-
const nonEmpty = lines.filter((ln) => hasNonWhitespaceText(ln)).length;
|
| 74 |
-
return nonEmpty || 0;
|
| 75 |
-
};
|
| 76 |
-
const countLinesFromText = () => {
|
| 77 |
-
// Parse raw text content of the <code> node including nested spans
|
| 78 |
-
const extractText = (node) => {
|
| 79 |
-
if (!node) return '';
|
| 80 |
-
if (node.type === 'text') return String(node.value || '');
|
| 81 |
-
const kids = Array.isArray(node.children) ? node.children : [];
|
| 82 |
-
return kids.map(extractText).join('');
|
| 83 |
-
};
|
| 84 |
-
const raw = extractText(code);
|
| 85 |
-
if (!raw || !/\S/.test(raw)) return 0;
|
| 86 |
-
return raw.split('\n').filter(line => /\S/.test(line)).length;
|
| 87 |
-
};
|
| 88 |
-
const lines = countLinesFromShiki() || countLinesFromText();
|
| 89 |
-
const isSingleLine = lines <= 1;
|
| 90 |
-
// Also treat code blocks shorter than a threshold as single-line (defensive)
|
| 91 |
-
if (!isSingleLine) {
|
| 92 |
-
const approxChars = (() => {
|
| 93 |
-
const extract = (n) => Array.isArray(n?.children) ? n.children.map(extract).join('') : (n?.type === 'text' ? String(n.value||'') : '');
|
| 94 |
-
return extract(code).length;
|
| 95 |
-
})();
|
| 96 |
-
if (approxChars < 6) {
|
| 97 |
-
// e.g., "npm i" alone
|
| 98 |
-
// downgrade to single-line behavior
|
| 99 |
-
node.__forceSingle = true;
|
| 100 |
-
}
|
| 101 |
-
}
|
| 102 |
-
// Ensure CSS-only label works: set data-language on <code> and <pre>, and wrapper
|
| 103 |
-
code.properties = code.properties || {};
|
| 104 |
-
if (ext) code.properties['data-language'] = ext;
|
| 105 |
-
node.properties = node.properties || {};
|
| 106 |
-
if (ext) node.properties['data-language'] = ext;
|
| 107 |
-
// Replace <pre> with wrapper div.code-card containing button + pre
|
| 108 |
-
const wrapper = {
|
| 109 |
-
type: 'element',
|
| 110 |
-
tagName: 'div',
|
| 111 |
-
properties: { className: ['code-card'].concat((isSingleLine || node.__forceSingle) ? ['no-copy'] : []), 'data-language': ext },
|
| 112 |
-
children: (isSingleLine || node.__forceSingle) ? [ node ] : [
|
| 113 |
-
{
|
| 114 |
-
type: 'element',
|
| 115 |
-
tagName: 'button',
|
| 116 |
-
properties: { className: ['code-copy', 'button--ghost'], type: 'button', 'aria-label': 'Copy code' },
|
| 117 |
-
children: [
|
| 118 |
-
{
|
| 119 |
-
type: 'element',
|
| 120 |
-
tagName: 'svg',
|
| 121 |
-
properties: { viewBox: '0 0 24 24', 'aria-hidden': 'true', focusable: 'false' },
|
| 122 |
-
children: [
|
| 123 |
-
{ type: 'element', tagName: 'path', properties: { d: 'M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z' }, children: [] }
|
| 124 |
-
]
|
| 125 |
-
}
|
| 126 |
-
]
|
| 127 |
-
},
|
| 128 |
-
node
|
| 129 |
-
]
|
| 130 |
-
};
|
| 131 |
-
if (parent && Array.isArray(parent.children)) {
|
| 132 |
-
const idx = parent.children.indexOf(node);
|
| 133 |
-
if (idx !== -1) parent.children[idx] = wrapper;
|
| 134 |
-
}
|
| 135 |
-
return; // don't visit nested
|
| 136 |
-
}
|
| 137 |
-
children.forEach((c) => visit(c, node));
|
| 138 |
-
};
|
| 139 |
-
visit(tree, null);
|
| 140 |
-
};
|
| 141 |
-
}
|
| 142 |
|
| 143 |
export default defineConfig({
|
| 144 |
output: 'static',
|
|
@@ -151,15 +28,6 @@ export default defineConfig({
|
|
| 151 |
devToolbar: {
|
| 152 |
enabled: false
|
| 153 |
},
|
| 154 |
-
// Expose a public flag to control TOC auto-collapse behavior at runtime
|
| 155 |
-
// This can be overridden via environment variable PUBLIC_TOC_AUTO_COLLAPSE
|
| 156 |
-
// (Vite define provides a default fallback when env is not set.)
|
| 157 |
-
vite: {
|
| 158 |
-
define: {
|
| 159 |
-
'import.meta.env.PUBLIC_TOC_AUTO_COLLAPSE': JSON.stringify(false),
|
| 160 |
-
'import.meta.env.PUBLIC_TABLE_OF_CONTENT_AUTO_COLLAPSE': JSON.stringify(false)
|
| 161 |
-
}
|
| 162 |
-
},
|
| 163 |
markdown: {
|
| 164 |
shikiConfig: {
|
| 165 |
themes: {
|
|
@@ -174,6 +42,7 @@ export default defineConfig({
|
|
| 174 |
}
|
| 175 |
},
|
| 176 |
remarkPlugins: [
|
|
|
|
| 177 |
remarkMath,
|
| 178 |
[remarkFootnotes, { inlineNotes: true }]
|
| 179 |
],
|
|
@@ -185,7 +54,10 @@ export default defineConfig({
|
|
| 185 |
bibliography: 'src/content/bibliography.bib',
|
| 186 |
linkCitations: true
|
| 187 |
}],
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
| 189 |
]
|
| 190 |
}
|
| 191 |
});
|
|
|
|
| 8 |
import rehypeSlug from 'rehype-slug';
|
| 9 |
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
| 10 |
import rehypeCitation from 'rehype-citation';
|
| 11 |
+
import rehypeCodeCopyAndLabel from './plugins/rehype/code-copy-and-label.mjs';
|
| 12 |
+
import rehypeReferencesAndFootnotes from './plugins/rehype/post-citation.mjs';
|
| 13 |
+
import remarkIgnoreCitationsInCode from './plugins/remark/ignore-citations-in-code.mjs';
|
| 14 |
+
import rehypeRestoreAtInCode from './plugins/rehype/restore-at-in-code.mjs';
|
| 15 |
+
import rehypeWrapTables from './plugins/rehype/wrap-tables.mjs';
|
| 16 |
// Built-in Shiki (dual themes) — no rehype-pretty-code
|
| 17 |
|
| 18 |
+
// Plugins moved to app/plugins/*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
export default defineConfig({
|
| 21 |
output: 'static',
|
|
|
|
| 28 |
devToolbar: {
|
| 29 |
enabled: false
|
| 30 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
markdown: {
|
| 32 |
shikiConfig: {
|
| 33 |
themes: {
|
|
|
|
| 42 |
}
|
| 43 |
},
|
| 44 |
remarkPlugins: [
|
| 45 |
+
remarkIgnoreCitationsInCode,
|
| 46 |
remarkMath,
|
| 47 |
[remarkFootnotes, { inlineNotes: true }]
|
| 48 |
],
|
|
|
|
| 54 |
bibliography: 'src/content/bibliography.bib',
|
| 55 |
linkCitations: true
|
| 56 |
}],
|
| 57 |
+
rehypeReferencesAndFootnotes,
|
| 58 |
+
rehypeRestoreAtInCode,
|
| 59 |
+
rehypeCodeCopyAndLabel,
|
| 60 |
+
rehypeWrapTables
|
| 61 |
]
|
| 62 |
}
|
| 63 |
});
|
app/plugins/rehype/code-copy-and-label.mjs
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Minimal rehype plugin to wrap code blocks with a copy button and a language label
|
| 2 |
+
// Exported as a standalone module to keep astro.config.mjs lean
|
| 3 |
+
export default function rehypeCodeCopyAndLabel() {
|
| 4 |
+
return (tree) => {
|
| 5 |
+
// Walk the tree; lightweight visitor to find <pre><code>
|
| 6 |
+
const visit = (node, parent) => {
|
| 7 |
+
if (!node || typeof node !== 'object') return;
|
| 8 |
+
const children = Array.isArray(node.children) ? node.children : [];
|
| 9 |
+
if (node.tagName === 'pre' && children.some(c => c.tagName === 'code')) {
|
| 10 |
+
// Find code child and guess language
|
| 11 |
+
const code = children.find(c => c.tagName === 'code');
|
| 12 |
+
const collectClasses = (val) => Array.isArray(val) ? val.map(String) : (typeof val === 'string' ? String(val).split(/\s+/) : []);
|
| 13 |
+
const fromClass = (names) => {
|
| 14 |
+
const hit = names.find((n) => /^language-/.test(String(n)));
|
| 15 |
+
return hit ? String(hit).replace(/^language-/, '') : '';
|
| 16 |
+
};
|
| 17 |
+
const codeClasses = collectClasses(code?.properties?.className);
|
| 18 |
+
const preClasses = collectClasses(node?.properties?.className);
|
| 19 |
+
const candidates = [
|
| 20 |
+
code?.properties?.['data-language'],
|
| 21 |
+
fromClass(codeClasses),
|
| 22 |
+
node?.properties?.['data-language'],
|
| 23 |
+
fromClass(preClasses),
|
| 24 |
+
];
|
| 25 |
+
let lang = candidates.find(Boolean) || '';
|
| 26 |
+
const lower = String(lang).toLowerCase();
|
| 27 |
+
const toExt = (s) => {
|
| 28 |
+
switch (String(s).toLowerCase()) {
|
| 29 |
+
case 'typescript': case 'ts': return 'ts';
|
| 30 |
+
case 'tsx': return 'tsx';
|
| 31 |
+
case 'javascript': case 'js': case 'node': return 'js';
|
| 32 |
+
case 'jsx': return 'jsx';
|
| 33 |
+
case 'python': case 'py': return 'py';
|
| 34 |
+
case 'bash': case 'shell': case 'sh': return 'sh';
|
| 35 |
+
case 'markdown': case 'md': return 'md';
|
| 36 |
+
case 'yaml': case 'yml': return 'yml';
|
| 37 |
+
case 'html': return 'html';
|
| 38 |
+
case 'css': return 'css';
|
| 39 |
+
case 'json': return 'json';
|
| 40 |
+
default: return lower || '';
|
| 41 |
+
}
|
| 42 |
+
};
|
| 43 |
+
const ext = toExt(lower);
|
| 44 |
+
// Determine if single-line block: prefer Shiki lines, then text content
|
| 45 |
+
const countLinesFromShiki = () => {
|
| 46 |
+
const isLineEl = (el) => el && el.type === 'element' && el.tagName === 'span' && Array.isArray(el.properties?.className) && el.properties.className.includes('line');
|
| 47 |
+
const hasNonWhitespaceText = (node) => {
|
| 48 |
+
if (!node) return false;
|
| 49 |
+
if (node.type === 'text') return /\S/.test(String(node.value || ''));
|
| 50 |
+
const kids = Array.isArray(node.children) ? node.children : [];
|
| 51 |
+
return kids.some(hasNonWhitespaceText);
|
| 52 |
+
};
|
| 53 |
+
const collectLines = (node, acc) => {
|
| 54 |
+
if (!node || typeof node !== 'object') return;
|
| 55 |
+
if (isLineEl(node)) acc.push(node);
|
| 56 |
+
const kids = Array.isArray(node.children) ? node.children : [];
|
| 57 |
+
kids.forEach((k) => collectLines(k, acc));
|
| 58 |
+
};
|
| 59 |
+
const lines = [];
|
| 60 |
+
collectLines(code, lines);
|
| 61 |
+
const nonEmpty = lines.filter((ln) => hasNonWhitespaceText(ln)).length;
|
| 62 |
+
return nonEmpty || 0;
|
| 63 |
+
};
|
| 64 |
+
const countLinesFromText = () => {
|
| 65 |
+
// Parse raw text content of the <code> node including nested spans
|
| 66 |
+
const extractText = (node) => {
|
| 67 |
+
if (!node) return '';
|
| 68 |
+
if (node.type === 'text') return String(node.value || '');
|
| 69 |
+
const kids = Array.isArray(node.children) ? node.children : [];
|
| 70 |
+
return kids.map(extractText).join('');
|
| 71 |
+
};
|
| 72 |
+
const raw = extractText(code);
|
| 73 |
+
if (!raw || !/\S/.test(raw)) return 0;
|
| 74 |
+
return raw.split('\n').filter(line => /\S/.test(line)).length;
|
| 75 |
+
};
|
| 76 |
+
const lines = countLinesFromShiki() || countLinesFromText();
|
| 77 |
+
const isSingleLine = lines <= 1;
|
| 78 |
+
// Also treat code blocks shorter than a threshold as single-line (defensive)
|
| 79 |
+
if (!isSingleLine) {
|
| 80 |
+
const approxChars = (() => {
|
| 81 |
+
const extract = (n) => Array.isArray(n?.children) ? n.children.map(extract).join('') : (n?.type === 'text' ? String(n.value||'') : '');
|
| 82 |
+
return extract(code).length;
|
| 83 |
+
})();
|
| 84 |
+
if (approxChars < 6) {
|
| 85 |
+
node.__forceSingle = true;
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
// Ensure CSS-only label works: set data-language on <code> and <pre>, and wrapper
|
| 89 |
+
code.properties = code.properties || {};
|
| 90 |
+
if (ext) code.properties['data-language'] = ext;
|
| 91 |
+
node.properties = node.properties || {};
|
| 92 |
+
if (ext) node.properties['data-language'] = ext;
|
| 93 |
+
// Replace <pre> with wrapper div.code-card containing button + pre
|
| 94 |
+
const wrapper = {
|
| 95 |
+
type: 'element',
|
| 96 |
+
tagName: 'div',
|
| 97 |
+
properties: { className: ['code-card'].concat((isSingleLine || node.__forceSingle) ? ['no-copy'] : []), 'data-language': ext },
|
| 98 |
+
children: (isSingleLine || node.__forceSingle) ? [ node ] : [
|
| 99 |
+
{
|
| 100 |
+
type: 'element',
|
| 101 |
+
tagName: 'button',
|
| 102 |
+
properties: { className: ['code-copy', 'button--ghost'], type: 'button', 'aria-label': 'Copy code' },
|
| 103 |
+
children: [
|
| 104 |
+
{
|
| 105 |
+
type: 'element',
|
| 106 |
+
tagName: 'svg',
|
| 107 |
+
properties: { viewBox: '0 0 24 24', 'aria-hidden': 'true', focusable: 'false' },
|
| 108 |
+
children: [
|
| 109 |
+
{ type: 'element', tagName: 'path', properties: { d: 'M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z' }, children: [] }
|
| 110 |
+
]
|
| 111 |
+
}
|
| 112 |
+
]
|
| 113 |
+
},
|
| 114 |
+
node
|
| 115 |
+
]
|
| 116 |
+
};
|
| 117 |
+
if (parent && Array.isArray(parent.children)) {
|
| 118 |
+
const idx = parent.children.indexOf(node);
|
| 119 |
+
if (idx !== -1) parent.children[idx] = wrapper;
|
| 120 |
+
}
|
| 121 |
+
return; // don't visit nested
|
| 122 |
+
}
|
| 123 |
+
children.forEach((c) => visit(c, node));
|
| 124 |
+
};
|
| 125 |
+
visit(tree, null);
|
| 126 |
+
};
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
|
app/plugins/rehype/post-citation.mjs
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// rehype plugin to post-process citations and footnotes at build-time
|
| 2 |
+
// - Normalizes the bibliography into <ol class="references"> with <li id="...">
|
| 3 |
+
// - Linkifies DOI/URL occurrences inside references
|
| 4 |
+
// - Appends back-reference links (↩ back: 1, 2, ...) from each reference to in-text citation anchors
|
| 5 |
+
// - Cleans up footnotes block (.footnotes)
|
| 6 |
+
|
| 7 |
+
export default function rehypeReferencesAndFootnotes() {
|
| 8 |
+
return (tree) => {
|
| 9 |
+
const isElement = (n) => n && typeof n === 'object' && n.type === 'element';
|
| 10 |
+
const getChildren = (n) => (Array.isArray(n?.children) ? n.children : []);
|
| 11 |
+
|
| 12 |
+
const walk = (node, parent, fn) => {
|
| 13 |
+
if (!node || typeof node !== 'object') return;
|
| 14 |
+
fn && fn(node, parent);
|
| 15 |
+
const kids = getChildren(node);
|
| 16 |
+
for (const child of kids) walk(child, node, fn);
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
const ensureArray = (v) => (Array.isArray(v) ? v : v != null ? [v] : []);
|
| 20 |
+
|
| 21 |
+
const hasClass = (el, name) => {
|
| 22 |
+
const cn = ensureArray(el?.properties?.className).map(String);
|
| 23 |
+
return cn.includes(name);
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
const setAttr = (el, key, val) => {
|
| 27 |
+
el.properties = el.properties || {};
|
| 28 |
+
if (val == null) delete el.properties[key];
|
| 29 |
+
else el.properties[key] = val;
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
const getAttr = (el, key) => (el?.properties ? el.properties[key] : undefined);
|
| 33 |
+
|
| 34 |
+
// Shared helpers for backlinks + backrefs block
|
| 35 |
+
const collectBacklinksForIdSet = (idSet, anchorPrefix) => {
|
| 36 |
+
const idToBacklinks = new Map();
|
| 37 |
+
const idToAnchorNodes = new Map();
|
| 38 |
+
if (!idSet || idSet.size === 0) return { idToBacklinks, idToAnchorNodes };
|
| 39 |
+
walk(tree, null, (node) => {
|
| 40 |
+
if (!isElement(node) || node.tagName !== 'a') return;
|
| 41 |
+
const href = String(getAttr(node, 'href') || '');
|
| 42 |
+
if (!href.startsWith('#')) return;
|
| 43 |
+
const id = href.slice(1);
|
| 44 |
+
if (!idSet.has(id)) return;
|
| 45 |
+
// Ensure a stable id
|
| 46 |
+
let anchorId = String(getAttr(node, 'id') || '');
|
| 47 |
+
if (!anchorId) {
|
| 48 |
+
const list = idToBacklinks.get(id) || [];
|
| 49 |
+
anchorId = `${anchorPrefix}-${id}-${list.length + 1}`;
|
| 50 |
+
setAttr(node, 'id', anchorId);
|
| 51 |
+
}
|
| 52 |
+
const list = idToBacklinks.get(id) || [];
|
| 53 |
+
list.push(anchorId);
|
| 54 |
+
idToBacklinks.set(id, list);
|
| 55 |
+
const nodes = idToAnchorNodes.get(id) || [];
|
| 56 |
+
nodes.push(node);
|
| 57 |
+
idToAnchorNodes.set(id, nodes);
|
| 58 |
+
});
|
| 59 |
+
return { idToBacklinks, idToAnchorNodes };
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
const createBackIcon = () => ({
|
| 63 |
+
type: 'element',
|
| 64 |
+
tagName: 'svg',
|
| 65 |
+
properties: {
|
| 66 |
+
className: ['back-icon'],
|
| 67 |
+
width: 12,
|
| 68 |
+
height: 12,
|
| 69 |
+
viewBox: '0 0 24 24',
|
| 70 |
+
fill: 'none',
|
| 71 |
+
stroke: 'currentColor',
|
| 72 |
+
'stroke-width': 2,
|
| 73 |
+
'stroke-linecap': 'round',
|
| 74 |
+
'stroke-linejoin': 'round',
|
| 75 |
+
'aria-hidden': 'true',
|
| 76 |
+
focusable: 'false'
|
| 77 |
+
},
|
| 78 |
+
children: [
|
| 79 |
+
{ type: 'element', tagName: 'line', properties: { x1: 12, y1: 19, x2: 12, y2: 5 }, children: [] },
|
| 80 |
+
{ type: 'element', tagName: 'polyline', properties: { points: '5 12 12 5 19 12' }, children: [] }
|
| 81 |
+
]
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
const appendBackrefsBlock = (listElement, idToBacklinks, ariaLabel) => {
|
| 85 |
+
if (!listElement || !idToBacklinks || idToBacklinks.size === 0) return;
|
| 86 |
+
for (const li of getChildren(listElement)) {
|
| 87 |
+
if (!isElement(li) || li.tagName !== 'li') continue;
|
| 88 |
+
const id = String(getAttr(li, 'id') || '');
|
| 89 |
+
if (!id) continue;
|
| 90 |
+
const keys = idToBacklinks.get(id);
|
| 91 |
+
if (!keys || !keys.length) continue;
|
| 92 |
+
// Remove pre-existing .backrefs in this li to avoid duplicates
|
| 93 |
+
li.children = getChildren(li).filter((n) => !(isElement(n) && n.tagName === 'small' && hasClass(n, 'backrefs')));
|
| 94 |
+
const small = {
|
| 95 |
+
type: 'element',
|
| 96 |
+
tagName: 'small',
|
| 97 |
+
properties: { className: ['backrefs'] },
|
| 98 |
+
children: []
|
| 99 |
+
};
|
| 100 |
+
if (keys.length === 1) {
|
| 101 |
+
// Single backlink: just the icon wrapped in the anchor
|
| 102 |
+
const a = {
|
| 103 |
+
type: 'element',
|
| 104 |
+
tagName: 'a',
|
| 105 |
+
properties: { href: `#${keys[0]}`, 'aria-label': ariaLabel },
|
| 106 |
+
children: [ createBackIcon() ]
|
| 107 |
+
};
|
| 108 |
+
small.children.push(a);
|
| 109 |
+
} else {
|
| 110 |
+
// Multiple backlinks: icon + label + numbered links
|
| 111 |
+
small.children.push(createBackIcon());
|
| 112 |
+
small.children.push({ type: 'text', value: ' back: ' });
|
| 113 |
+
keys.forEach((backId, idx) => {
|
| 114 |
+
small.children.push({
|
| 115 |
+
type: 'element',
|
| 116 |
+
tagName: 'a',
|
| 117 |
+
properties: { href: `#${backId}`, 'aria-label': ariaLabel },
|
| 118 |
+
children: [ { type: 'text', value: String(idx + 1) } ]
|
| 119 |
+
});
|
| 120 |
+
if (idx < keys.length - 1) small.children.push({ type: 'text', value: ', ' });
|
| 121 |
+
});
|
| 122 |
+
}
|
| 123 |
+
li.children.push(small);
|
| 124 |
+
}
|
| 125 |
+
};
|
| 126 |
+
// Remove default back-reference anchors generated by remark-footnotes inside a footnote item
|
| 127 |
+
const getTextContent = (el) => {
|
| 128 |
+
if (!el) return '';
|
| 129 |
+
const stack = [el];
|
| 130 |
+
let out = '';
|
| 131 |
+
while (stack.length) {
|
| 132 |
+
const cur = stack.pop();
|
| 133 |
+
if (!cur) continue;
|
| 134 |
+
if (cur.type === 'text') out += String(cur.value || '');
|
| 135 |
+
const kids = getChildren(cur);
|
| 136 |
+
for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
|
| 137 |
+
}
|
| 138 |
+
return out;
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
const removeFootnoteBackrefAnchors = (el) => {
|
| 142 |
+
if (!isElement(el)) return;
|
| 143 |
+
const kids = getChildren(el);
|
| 144 |
+
for (let i = kids.length - 1; i >= 0; i--) {
|
| 145 |
+
const child = kids[i];
|
| 146 |
+
if (isElement(child)) {
|
| 147 |
+
if (
|
| 148 |
+
child.tagName === 'a' && (
|
| 149 |
+
getAttr(child, 'data-footnote-backref') != null ||
|
| 150 |
+
hasClass(child, 'footnote-backref') ||
|
| 151 |
+
String(getAttr(child, 'role') || '').toLowerCase() === 'doc-backlink' ||
|
| 152 |
+
String(getAttr(child, 'aria-label') || '').toLowerCase().includes('back to content') ||
|
| 153 |
+
String(getAttr(child, 'href') || '').startsWith('#fnref') ||
|
| 154 |
+
// Fallback: text-based detection like "↩" or "↩2"
|
| 155 |
+
/^\s*↩\s*\d*\s*$/u.test(getTextContent(child))
|
| 156 |
+
)
|
| 157 |
+
) {
|
| 158 |
+
// Remove the anchor
|
| 159 |
+
el.children.splice(i, 1);
|
| 160 |
+
continue;
|
| 161 |
+
}
|
| 162 |
+
// Recurse into element
|
| 163 |
+
removeFootnoteBackrefAnchors(child);
|
| 164 |
+
// If a wrapper like <sup> or <span> became empty, remove it
|
| 165 |
+
const becameKids = getChildren(child);
|
| 166 |
+
if ((child.tagName === 'sup' || child.tagName === 'span') && (!becameKids || becameKids.length === 0)) {
|
| 167 |
+
el.children.splice(i, 1);
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
};
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
const normDoiHref = (href) => {
|
| 175 |
+
if (!href) return href;
|
| 176 |
+
const DUP = /https?:\/\/(?:dx\.)?doi\.org\/(?:https?:\/\/(?:dx\.)?doi\.org\/)+/gi;
|
| 177 |
+
const ONE = /https?:\/\/(?:dx\.)?doi\.org\/(10\.[^\s<>"']+)/i;
|
| 178 |
+
href = String(href).replace(DUP, 'https://doi.org/');
|
| 179 |
+
const m = href.match(ONE);
|
| 180 |
+
return m ? `https://doi.org/${m[1]}` : href;
|
| 181 |
+
};
|
| 182 |
+
|
| 183 |
+
const DOI_BARE = /\b10\.[0-9]{4,9}\/[\-._;()\/:A-Z0-9]+\b/gi;
|
| 184 |
+
const URL_GEN = /\bhttps?:\/\/[^\s<>()"']+/gi;
|
| 185 |
+
|
| 186 |
+
const linkifyTextNode = (textNode) => {
|
| 187 |
+
const text = String(textNode.value || '');
|
| 188 |
+
let last = 0;
|
| 189 |
+
const parts = [];
|
| 190 |
+
const pushText = (s) => { if (s) parts.push({ type: 'text', value: s }); };
|
| 191 |
+
|
| 192 |
+
const matches = [];
|
| 193 |
+
// Collect URL matches
|
| 194 |
+
let m;
|
| 195 |
+
URL_GEN.lastIndex = 0;
|
| 196 |
+
while ((m = URL_GEN.exec(text)) !== null) {
|
| 197 |
+
matches.push({ type: 'url', start: m.index, end: URL_GEN.lastIndex, raw: m[0] });
|
| 198 |
+
}
|
| 199 |
+
// Collect DOI matches
|
| 200 |
+
DOI_BARE.lastIndex = 0;
|
| 201 |
+
while ((m = DOI_BARE.exec(text)) !== null) {
|
| 202 |
+
matches.push({ type: 'doi', start: m.index, end: DOI_BARE.lastIndex, raw: m[0] });
|
| 203 |
+
}
|
| 204 |
+
matches.sort((a, b) => a.start - b.start);
|
| 205 |
+
|
| 206 |
+
for (const match of matches) {
|
| 207 |
+
if (match.start < last) continue; // overlapping
|
| 208 |
+
pushText(text.slice(last, match.start));
|
| 209 |
+
if (match.type === 'url') {
|
| 210 |
+
const href = normDoiHref(match.raw);
|
| 211 |
+
const doiOne = href.match(/https?:\/\/(?:dx\.)?doi\.org\/(10\.[^\s<>"']+)/i);
|
| 212 |
+
const a = {
|
| 213 |
+
type: 'element',
|
| 214 |
+
tagName: 'a',
|
| 215 |
+
properties: { href, target: '_blank', rel: 'noopener noreferrer' },
|
| 216 |
+
children: [{ type: 'text', value: doiOne ? doiOne[1] : href }]
|
| 217 |
+
};
|
| 218 |
+
parts.push(a);
|
| 219 |
+
} else {
|
| 220 |
+
const href = `https://doi.org/${match.raw}`;
|
| 221 |
+
const a = {
|
| 222 |
+
type: 'element',
|
| 223 |
+
tagName: 'a',
|
| 224 |
+
properties: { href, target: '_blank', rel: 'noopener noreferrer' },
|
| 225 |
+
children: [{ type: 'text', value: match.raw }]
|
| 226 |
+
};
|
| 227 |
+
parts.push(a);
|
| 228 |
+
}
|
| 229 |
+
last = match.end;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
pushText(text.slice(last));
|
| 233 |
+
return parts;
|
| 234 |
+
};
|
| 235 |
+
|
| 236 |
+
const linkifyInElement = (el) => {
|
| 237 |
+
const kids = getChildren(el);
|
| 238 |
+
for (let i = 0; i < kids.length; i++) {
|
| 239 |
+
const child = kids[i];
|
| 240 |
+
if (!child) continue;
|
| 241 |
+
if (child.type === 'text') {
|
| 242 |
+
const replacement = linkifyTextNode(child);
|
| 243 |
+
if (replacement.length === 1 && replacement[0].type === 'text') continue;
|
| 244 |
+
// Replace the single text node with multiple nodes
|
| 245 |
+
el.children.splice(i, 1, ...replacement);
|
| 246 |
+
i += replacement.length - 1;
|
| 247 |
+
} else if (isElement(child)) {
|
| 248 |
+
if (child.tagName === 'a') {
|
| 249 |
+
const href = normDoiHref(getAttr(child, 'href'));
|
| 250 |
+
setAttr(child, 'href', href);
|
| 251 |
+
const m = String(href || '').match(/https?:\/\/(?:dx\.)?doi\.org\/(10\.[^\s<>"']+)/i);
|
| 252 |
+
if (m && (!child.children || child.children.length === 0)) {
|
| 253 |
+
child.children = [{ type: 'text', value: m[1] }];
|
| 254 |
+
}
|
| 255 |
+
continue;
|
| 256 |
+
}
|
| 257 |
+
linkifyInElement(child);
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
// Deduplicate adjacent identical anchors
|
| 261 |
+
for (let i = 1; i < el.children.length; i++) {
|
| 262 |
+
const prev = el.children[i - 1];
|
| 263 |
+
const curr = el.children[i];
|
| 264 |
+
if (isElement(prev) && isElement(curr) && prev.tagName === 'a' && curr.tagName === 'a') {
|
| 265 |
+
const key = `${getAttr(prev, 'href') || ''}|${(prev.children?.[0]?.value) || ''}`;
|
| 266 |
+
const key2 = `${getAttr(curr, 'href') || ''}|${(curr.children?.[0]?.value) || ''}`;
|
| 267 |
+
if (key === key2) {
|
| 268 |
+
el.children.splice(i, 1);
|
| 269 |
+
i--;
|
| 270 |
+
}
|
| 271 |
+
}
|
| 272 |
+
}
|
| 273 |
+
};
|
| 274 |
+
|
| 275 |
+
// Find references container and normalize its list
|
| 276 |
+
const findReferencesRoot = () => {
|
| 277 |
+
let found = null;
|
| 278 |
+
walk(tree, null, (node) => {
|
| 279 |
+
if (found) return;
|
| 280 |
+
if (!isElement(node)) return;
|
| 281 |
+
const id = getAttr(node, 'id');
|
| 282 |
+
if (id === 'references' || hasClass(node, 'references') || hasClass(node, 'bibliography')) {
|
| 283 |
+
found = node;
|
| 284 |
+
}
|
| 285 |
+
});
|
| 286 |
+
return found;
|
| 287 |
+
};
|
| 288 |
+
|
| 289 |
+
const toOrderedList = (container) => {
|
| 290 |
+
// If there is already an <ol>, use it; otherwise convert common structures
|
| 291 |
+
let ol = getChildren(container).find((c) => isElement(c) && c.tagName === 'ol');
|
| 292 |
+
if (!ol) {
|
| 293 |
+
ol = { type: 'element', tagName: 'ol', properties: { className: ['references'] }, children: [] };
|
| 294 |
+
const candidates = getChildren(container).filter((n) => isElement(n));
|
| 295 |
+
if (candidates.length) {
|
| 296 |
+
for (const node of candidates) {
|
| 297 |
+
if (hasClass(node, 'csl-entry') || node.tagName === 'li' || node.tagName === 'p' || node.tagName === 'div') {
|
| 298 |
+
const li = { type: 'element', tagName: 'li', properties: {}, children: getChildren(node) };
|
| 299 |
+
if (getAttr(node, 'id')) setAttr(li, 'id', getAttr(node, 'id'));
|
| 300 |
+
ol.children.push(li);
|
| 301 |
+
}
|
| 302 |
+
}
|
| 303 |
+
}
|
| 304 |
+
// Replace container children by the new ol
|
| 305 |
+
container.children = [ol];
|
| 306 |
+
}
|
| 307 |
+
if (!hasClass(ol, 'references')) {
|
| 308 |
+
const cls = ensureArray(ol.properties?.className).map(String);
|
| 309 |
+
if (!cls.includes('references')) cls.push('references');
|
| 310 |
+
ol.properties = ol.properties || {};
|
| 311 |
+
ol.properties.className = cls;
|
| 312 |
+
}
|
| 313 |
+
return ol;
|
| 314 |
+
};
|
| 315 |
+
|
| 316 |
+
const refsRoot = findReferencesRoot();
|
| 317 |
+
let refsOl = null;
|
| 318 |
+
const refIdSet = new Set();
|
| 319 |
+
const refIdToExternalHref = new Map();
|
| 320 |
+
|
| 321 |
+
if (refsRoot) {
|
| 322 |
+
refsOl = toOrderedList(refsRoot);
|
| 323 |
+
// Collect item ids and linkify their content
|
| 324 |
+
for (const li of getChildren(refsOl)) {
|
| 325 |
+
if (!isElement(li) || li.tagName !== 'li') continue;
|
| 326 |
+
if (!getAttr(li, 'id')) {
|
| 327 |
+
// Try to find a nested element with id to promote
|
| 328 |
+
const nestedWithId = getChildren(li).find((n) => isElement(n) && getAttr(n, 'id'));
|
| 329 |
+
if (nestedWithId) setAttr(li, 'id', getAttr(nestedWithId, 'id'));
|
| 330 |
+
}
|
| 331 |
+
const id = getAttr(li, 'id');
|
| 332 |
+
if (id) refIdSet.add(String(id));
|
| 333 |
+
linkifyInElement(li);
|
| 334 |
+
// Record first external link href (e.g., DOI/URL) if present
|
| 335 |
+
if (id) {
|
| 336 |
+
let externalHref = null;
|
| 337 |
+
const stack = [li];
|
| 338 |
+
while (stack.length) {
|
| 339 |
+
const cur = stack.pop();
|
| 340 |
+
const kids = getChildren(cur);
|
| 341 |
+
for (const k of kids) {
|
| 342 |
+
if (isElement(k) && k.tagName === 'a') {
|
| 343 |
+
const href = String(getAttr(k, 'href') || '');
|
| 344 |
+
if (/^https?:\/\//i.test(href)) {
|
| 345 |
+
externalHref = href;
|
| 346 |
+
break;
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
if (isElement(k)) stack.push(k);
|
| 350 |
+
}
|
| 351 |
+
if (externalHref) break;
|
| 352 |
+
}
|
| 353 |
+
if (externalHref) refIdToExternalHref.set(String(id), externalHref);
|
| 354 |
+
}
|
| 355 |
+
}
|
| 356 |
+
setAttr(refsRoot, 'data-built-refs', '1');
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
// Collect in-text anchors that point to references ids
|
| 360 |
+
const { idToBacklinks: refIdToBacklinks, idToAnchorNodes: refIdToCitationAnchors } = collectBacklinksForIdSet(refIdSet, 'refctx');
|
| 361 |
+
|
| 362 |
+
// Append backlinks into references list items
|
| 363 |
+
appendBackrefsBlock(refsOl, refIdToBacklinks, 'Back to citation');
|
| 364 |
+
|
| 365 |
+
// Rewrite in-text citation anchors to external link when available
|
| 366 |
+
if (refIdToCitationAnchors.size > 0) {
|
| 367 |
+
for (const [id, anchors] of refIdToCitationAnchors.entries()) {
|
| 368 |
+
const ext = refIdToExternalHref.get(id);
|
| 369 |
+
if (!ext) continue;
|
| 370 |
+
for (const a of anchors) {
|
| 371 |
+
setAttr(a, 'data-ref-id', id);
|
| 372 |
+
setAttr(a, 'href', ext);
|
| 373 |
+
const existingTarget = getAttr(a, 'target');
|
| 374 |
+
if (!existingTarget) setAttr(a, 'target', '_blank');
|
| 375 |
+
const rel = String(getAttr(a, 'rel') || '');
|
| 376 |
+
const relSet = new Set(rel ? rel.split(/\s+/) : []);
|
| 377 |
+
relSet.add('noopener');
|
| 378 |
+
relSet.add('noreferrer');
|
| 379 |
+
setAttr(a, 'rel', Array.from(relSet).join(' '));
|
| 380 |
+
}
|
| 381 |
+
}
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
// Footnotes cleanup + backrefs harmonized with references
|
| 385 |
+
const cleanupFootnotes = () => {
|
| 386 |
+
let root = null;
|
| 387 |
+
walk(tree, null, (node) => {
|
| 388 |
+
if (!isElement(node)) return;
|
| 389 |
+
if (hasClass(node, 'footnotes')) root = node;
|
| 390 |
+
});
|
| 391 |
+
if (!root) return { root: null, ol: null, idSet: new Set() };
|
| 392 |
+
// Remove <hr> direct children
|
| 393 |
+
root.children = getChildren(root).filter((n) => !(isElement(n) && n.tagName === 'hr'));
|
| 394 |
+
// Ensure an <ol>
|
| 395 |
+
let ol = getChildren(root).find((c) => isElement(c) && c.tagName === 'ol');
|
| 396 |
+
if (!ol) {
|
| 397 |
+
ol = { type: 'element', tagName: 'ol', properties: {}, children: [] };
|
| 398 |
+
const items = getChildren(root).filter((n) => isElement(n) && (n.tagName === 'li' || hasClass(n, 'footnote') || n.tagName === 'p' || n.tagName === 'div'));
|
| 399 |
+
if (items.length) {
|
| 400 |
+
for (const it of items) {
|
| 401 |
+
const li = { type: 'element', tagName: 'li', properties: {}, children: getChildren(it) };
|
| 402 |
+
// Promote nested id if present (e.g., <p id="fn-1">)
|
| 403 |
+
const nestedWithId = getChildren(it).find((n) => isElement(n) && getAttr(n, 'id'));
|
| 404 |
+
if (nestedWithId) setAttr(li, 'id', getAttr(nestedWithId, 'id'));
|
| 405 |
+
ol.children.push(li);
|
| 406 |
+
}
|
| 407 |
+
}
|
| 408 |
+
root.children = [ol];
|
| 409 |
+
}
|
| 410 |
+
// For existing structures, try to promote ids from children when missing
|
| 411 |
+
for (const li of getChildren(ol)) {
|
| 412 |
+
if (!isElement(li) || li.tagName !== 'li') continue;
|
| 413 |
+
if (!getAttr(li, 'id')) {
|
| 414 |
+
const nestedWithId = getChildren(li).find((n) => isElement(n) && getAttr(n, 'id'));
|
| 415 |
+
if (nestedWithId) setAttr(li, 'id', getAttr(nestedWithId, 'id'));
|
| 416 |
+
}
|
| 417 |
+
// Remove default footnote backrefs anywhere inside (to avoid duplication)
|
| 418 |
+
removeFootnoteBackrefAnchors(li);
|
| 419 |
+
}
|
| 420 |
+
setAttr(root, 'data-built-footnotes', '1');
|
| 421 |
+
// Collect id set
|
| 422 |
+
const idSet = new Set();
|
| 423 |
+
for (const li of getChildren(ol)) {
|
| 424 |
+
if (!isElement(li) || li.tagName !== 'li') continue;
|
| 425 |
+
const id = getAttr(li, 'id');
|
| 426 |
+
if (id) idSet.add(String(id));
|
| 427 |
+
}
|
| 428 |
+
return { root, ol, idSet };
|
| 429 |
+
};
|
| 430 |
+
|
| 431 |
+
const { root: footRoot, ol: footOl, idSet: footIdSet } = cleanupFootnotes();
|
| 432 |
+
|
| 433 |
+
// Collect in-text anchors pointing to footnotes
|
| 434 |
+
const { idToBacklinks: footIdToBacklinks } = collectBacklinksForIdSet(footIdSet, 'footctx');
|
| 435 |
+
|
| 436 |
+
// Append backlinks into footnote list items (identical pattern to references)
|
| 437 |
+
appendBackrefsBlock(footOl, footIdToBacklinks, 'Back to footnote call');
|
| 438 |
+
};
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
|
app/plugins/rehype/restore-at-in-code.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Rehype plugin to restore '@' inside code nodes after rehype-citation ran
|
| 2 |
+
export default function rehypeRestoreAtInCode() {
|
| 3 |
+
return (tree) => {
|
| 4 |
+
const restoreInNode = (node) => {
|
| 5 |
+
if (!node || typeof node !== 'object') return;
|
| 6 |
+
const isText = node.type === 'text';
|
| 7 |
+
if (isText && typeof node.value === 'string' && node.value.includes('__AT_SENTINEL__')) {
|
| 8 |
+
node.value = node.value.replace(/__AT_SENTINEL__/g, '@');
|
| 9 |
+
}
|
| 10 |
+
const isCodeEl = node.type === 'element' && node.tagName === 'code';
|
| 11 |
+
const children = Array.isArray(node.children) ? node.children : [];
|
| 12 |
+
if (isCodeEl && children.length) {
|
| 13 |
+
children.forEach(restoreInNode);
|
| 14 |
+
return;
|
| 15 |
+
}
|
| 16 |
+
children.forEach(restoreInNode);
|
| 17 |
+
};
|
| 18 |
+
restoreInNode(tree);
|
| 19 |
+
};
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
|
app/plugins/rehype/wrap-tables.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// rehype plugin: wrap bare <table> elements in a <div class="table-scroll"> container
|
| 2 |
+
// so that tables stay width:100% while enabling horizontal scroll when content overflows
|
| 3 |
+
|
| 4 |
+
export default function rehypeWrapTables() {
|
| 5 |
+
return (tree) => {
|
| 6 |
+
const isElement = (n) => n && typeof n === 'object' && n.type === 'element';
|
| 7 |
+
const getChildren = (n) => (Array.isArray(n?.children) ? n.children : []);
|
| 8 |
+
|
| 9 |
+
const walk = (node, parent, fn) => {
|
| 10 |
+
if (!node || typeof node !== 'object') return;
|
| 11 |
+
fn && fn(node, parent);
|
| 12 |
+
const kids = getChildren(node);
|
| 13 |
+
for (const child of kids) walk(child, node, fn);
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
const ensureArray = (v) => (Array.isArray(v) ? v : v != null ? [v] : []);
|
| 17 |
+
const hasClass = (el, name) => ensureArray(el?.properties?.className).map(String).includes(name);
|
| 18 |
+
|
| 19 |
+
const wrapTable = (tableNode, parent) => {
|
| 20 |
+
if (!parent || !Array.isArray(parent.children)) return;
|
| 21 |
+
// Don't double-wrap if already inside .table-scroll
|
| 22 |
+
if (parent.tagName === 'div' && hasClass(parent, 'table-scroll')) return;
|
| 23 |
+
|
| 24 |
+
const wrapper = {
|
| 25 |
+
type: 'element',
|
| 26 |
+
tagName: 'div',
|
| 27 |
+
properties: { className: ['table-scroll'] },
|
| 28 |
+
children: [tableNode]
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
const idx = parent.children.indexOf(tableNode);
|
| 32 |
+
if (idx >= 0) parent.children.splice(idx, 1, wrapper);
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
walk(tree, null, (node, parent) => {
|
| 36 |
+
if (!isElement(node)) return;
|
| 37 |
+
if (node.tagName !== 'table') return;
|
| 38 |
+
wrapTable(node, parent);
|
| 39 |
+
});
|
| 40 |
+
};
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
|
app/plugins/remark/ignore-citations-in-code.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Remark plugin to ignore citations inside code (block and inline)
|
| 2 |
+
export default function remarkIgnoreCitationsInCode() {
|
| 3 |
+
return (tree) => {
|
| 4 |
+
const visit = (node) => {
|
| 5 |
+
if (!node || typeof node !== 'object') return;
|
| 6 |
+
const type = node.type;
|
| 7 |
+
if (type === 'code' || type === 'inlineCode') {
|
| 8 |
+
if (typeof node.value === 'string' && node.value.includes('@')) {
|
| 9 |
+
// Use a sentinel to avoid rehype-citation, will be restored later in rehype
|
| 10 |
+
node.value = node.value.replace(/@/g, '__AT_SENTINEL__');
|
| 11 |
+
}
|
| 12 |
+
return; // do not traverse into code
|
| 13 |
+
}
|
| 14 |
+
const children = Array.isArray(node.children) ? node.children : [];
|
| 15 |
+
children.forEach(visit);
|
| 16 |
+
};
|
| 17 |
+
visit(tree);
|
| 18 |
+
};
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
|
app/src/components/Accordion.astro
CHANGED
|
@@ -76,6 +76,8 @@ const wrapperClass = ["accordion", className].filter(Boolean).join(" ");
|
|
| 76 |
|
| 77 |
<style>
|
| 78 |
.accordion {
|
|
|
|
|
|
|
| 79 |
border: 1px solid var(--border-color);
|
| 80 |
border-radius: 10px;
|
| 81 |
background: var(--surface-bg);
|
|
@@ -87,12 +89,13 @@ const wrapperClass = ["accordion", className].filter(Boolean).join(" ");
|
|
| 87 |
}
|
| 88 |
|
| 89 |
.accordion__summary {
|
|
|
|
| 90 |
list-style: none;
|
| 91 |
display: flex;
|
| 92 |
align-items: center;
|
| 93 |
justify-content: space-between;
|
| 94 |
gap: 4px;
|
| 95 |
-
padding:
|
| 96 |
cursor: pointer;
|
| 97 |
color: var(--text-color);
|
| 98 |
user-select: none;
|
|
@@ -131,7 +134,8 @@ const wrapperClass = ["accordion", className].filter(Boolean).join(" ");
|
|
| 131 |
}
|
| 132 |
|
| 133 |
.accordion__content {
|
| 134 |
-
|
|
|
|
| 135 |
}
|
| 136 |
|
| 137 |
/* Ensure the very last slotted element has no bottom spacing */
|
|
@@ -140,15 +144,30 @@ const wrapperClass = ["accordion", className].filter(Boolean).join(" ");
|
|
| 140 |
margin-bottom: 0 !important;
|
| 141 |
}
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
/* Separator between header and content when open (edge-to-edge) */
|
| 144 |
.accordion[open] .accordion__content-wrapper::before {
|
| 145 |
content: "";
|
| 146 |
position: absolute;
|
| 147 |
left: 0;
|
| 148 |
right: 0;
|
| 149 |
-
top:
|
| 150 |
height: 1px;
|
| 151 |
-
background: var(--
|
| 152 |
pointer-events: none;
|
| 153 |
}
|
| 154 |
|
|
@@ -158,6 +177,8 @@ const wrapperClass = ["accordion", className].filter(Boolean).join(" ");
|
|
| 158 |
outline-offset: 3px;
|
| 159 |
border-radius: 8px;
|
| 160 |
}
|
|
|
|
|
|
|
| 161 |
</style>
|
| 162 |
|
| 163 |
|
|
|
|
| 76 |
|
| 77 |
<style>
|
| 78 |
.accordion {
|
| 79 |
+
margin: 0 0 var(--spacing-4);
|
| 80 |
+
padding: 0;
|
| 81 |
border: 1px solid var(--border-color);
|
| 82 |
border-radius: 10px;
|
| 83 |
background: var(--surface-bg);
|
|
|
|
| 89 |
}
|
| 90 |
|
| 91 |
.accordion__summary {
|
| 92 |
+
margin: 0;
|
| 93 |
list-style: none;
|
| 94 |
display: flex;
|
| 95 |
align-items: center;
|
| 96 |
justify-content: space-between;
|
| 97 |
gap: 4px;
|
| 98 |
+
padding: 10px 8px;
|
| 99 |
cursor: pointer;
|
| 100 |
color: var(--text-color);
|
| 101 |
user-select: none;
|
|
|
|
| 134 |
}
|
| 135 |
|
| 136 |
.accordion__content {
|
| 137 |
+
margin: 0;
|
| 138 |
+
padding: 0;
|
| 139 |
}
|
| 140 |
|
| 141 |
/* Ensure the very last slotted element has no bottom spacing */
|
|
|
|
| 144 |
margin-bottom: 0 !important;
|
| 145 |
}
|
| 146 |
|
| 147 |
+
/* Ensure the very first slotted element has no top spacing */
|
| 148 |
+
.accordion .accordion__content > :global(*:first-child) {
|
| 149 |
+
margin-top: 0 !important;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
/* Content padding: default for direct children, opt-out for code/tables */
|
| 153 |
+
.accordion .accordion__content > :global(*) {
|
| 154 |
+
padding: 8px;
|
| 155 |
+
}
|
| 156 |
+
.accordion .accordion__content > :global(.table-scroll),
|
| 157 |
+
.accordion .accordion__content > :global(pre),
|
| 158 |
+
.accordion .accordion__content > :global(.code-card) {
|
| 159 |
+
padding: 0;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
/* Separator between header and content when open (edge-to-edge) */
|
| 163 |
.accordion[open] .accordion__content-wrapper::before {
|
| 164 |
content: "";
|
| 165 |
position: absolute;
|
| 166 |
left: 0;
|
| 167 |
right: 0;
|
| 168 |
+
top: 0px; /* space below header */
|
| 169 |
height: 1px;
|
| 170 |
+
background: var(--border-color);
|
| 171 |
pointer-events: none;
|
| 172 |
}
|
| 173 |
|
|
|
|
| 177 |
outline-offset: 3px;
|
| 178 |
border-radius: 8px;
|
| 179 |
}
|
| 180 |
+
|
| 181 |
+
|
| 182 |
</style>
|
| 183 |
|
| 184 |
|
app/src/components/Footer.astro
CHANGED
|
@@ -2,10 +2,12 @@
|
|
| 2 |
interface Props {
|
| 3 |
citationText: string;
|
| 4 |
bibtex: string;
|
|
|
|
|
|
|
| 5 |
}
|
| 6 |
-
const { citationText, bibtex } = Astro.props as Props;
|
| 7 |
---
|
| 8 |
-
<footer class="
|
| 9 |
<div class="footer-inner">
|
| 10 |
<section class="citation-block">
|
| 11 |
<h3>Citation</h3>
|
|
@@ -15,6 +17,18 @@ const { citationText, bibtex } = Astro.props as Props;
|
|
| 15 |
<p>BibTeX citation</p>
|
| 16 |
<pre class="citation long">{bibtex}</pre>
|
| 17 |
</section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
<section class="references-block">
|
| 19 |
<slot />
|
| 20 |
</section>
|
|
@@ -24,7 +38,7 @@ const { citationText, bibtex } = Astro.props as Props;
|
|
| 24 |
|
| 25 |
<script is:inline>
|
| 26 |
(() => {
|
| 27 |
-
const getFooter = () => document.currentScript?.closest('footer') || document.querySelector('footer.
|
| 28 |
const footer = getFooter();
|
| 29 |
if (!footer) return;
|
| 30 |
const target = footer.querySelector('.references-block');
|
|
@@ -32,14 +46,6 @@ const { citationText, bibtex } = Astro.props as Props;
|
|
| 32 |
|
| 33 |
const contentRoot = document.querySelector('section.content-grid main') || document.querySelector('main') || document.body;
|
| 34 |
|
| 35 |
-
const findFirstOutsideFooter = (selectors) => {
|
| 36 |
-
for (const sel of selectors) {
|
| 37 |
-
const el = contentRoot.querySelector(sel);
|
| 38 |
-
if (el && !footer.contains(el)) return el;
|
| 39 |
-
}
|
| 40 |
-
return null;
|
| 41 |
-
};
|
| 42 |
-
|
| 43 |
const ensureHeading = (text) => {
|
| 44 |
const exists = Array.from(target.children).some((c) => c.tagName === 'H3' && c.textContent.trim().toLowerCase() === text.toLowerCase());
|
| 45 |
if (!exists) {
|
|
@@ -51,10 +57,6 @@ const { citationText, bibtex } = Astro.props as Props;
|
|
| 51 |
|
| 52 |
const moveIntoFooter = (element, headingText) => {
|
| 53 |
if (!element) return false;
|
| 54 |
-
if (element.classList.contains('footnotes')) {
|
| 55 |
-
const hr = element.querySelector('hr');
|
| 56 |
-
if (hr) hr.remove();
|
| 57 |
-
}
|
| 58 |
// Remove an eventual heading already included inside the block (avoid duplicates)
|
| 59 |
const firstHeading = element.querySelector(':scope > h1, :scope > h2, :scope > h3');
|
| 60 |
if (firstHeading) {
|
|
@@ -68,10 +70,18 @@ const { citationText, bibtex } = Astro.props as Props;
|
|
| 68 |
target.appendChild(element);
|
| 69 |
return true;
|
| 70 |
};
|
| 71 |
-
|
| 72 |
const run = () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
const referencesEl = findFirstOutsideFooter(['#references', '.references', '.bibliography']);
|
| 74 |
const footnotesEl = findFirstOutsideFooter(['.footnotes']);
|
|
|
|
| 75 |
const movedRefs = moveIntoFooter(referencesEl, 'References');
|
| 76 |
const movedNotes = moveIntoFooter(footnotesEl, 'Footnotes');
|
| 77 |
return movedRefs || movedNotes;
|
|
@@ -94,3 +104,159 @@ const { citationText, bibtex } = Astro.props as Props;
|
|
| 94 |
</script>
|
| 95 |
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
interface Props {
|
| 3 |
citationText: string;
|
| 4 |
bibtex: string;
|
| 5 |
+
licence?: string;
|
| 6 |
+
doi?: string;
|
| 7 |
}
|
| 8 |
+
const { citationText, bibtex, licence, doi } = Astro.props as Props;
|
| 9 |
---
|
| 10 |
+
<footer class="footer">
|
| 11 |
<div class="footer-inner">
|
| 12 |
<section class="citation-block">
|
| 13 |
<h3>Citation</h3>
|
|
|
|
| 17 |
<p>BibTeX citation</p>
|
| 18 |
<pre class="citation long">{bibtex}</pre>
|
| 19 |
</section>
|
| 20 |
+
{doi && (
|
| 21 |
+
<section class="doi-block">
|
| 22 |
+
<h3>DOI</h3>
|
| 23 |
+
<p><a href={`https://doi.org/${doi}`} target="_blank" rel="noopener noreferrer">{doi}</a></p>
|
| 24 |
+
</section>
|
| 25 |
+
)}
|
| 26 |
+
{licence && (
|
| 27 |
+
<section class="reuse-block">
|
| 28 |
+
<h3>Reuse</h3>
|
| 29 |
+
<p set:html={licence}></p>
|
| 30 |
+
</section>
|
| 31 |
+
)}
|
| 32 |
<section class="references-block">
|
| 33 |
<slot />
|
| 34 |
</section>
|
|
|
|
| 38 |
|
| 39 |
<script is:inline>
|
| 40 |
(() => {
|
| 41 |
+
const getFooter = () => document.currentScript?.closest('footer') || document.querySelector('footer.footer');
|
| 42 |
const footer = getFooter();
|
| 43 |
if (!footer) return;
|
| 44 |
const target = footer.querySelector('.references-block');
|
|
|
|
| 46 |
|
| 47 |
const contentRoot = document.querySelector('section.content-grid main') || document.querySelector('main') || document.body;
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
const ensureHeading = (text) => {
|
| 50 |
const exists = Array.from(target.children).some((c) => c.tagName === 'H3' && c.textContent.trim().toLowerCase() === text.toLowerCase());
|
| 51 |
if (!exists) {
|
|
|
|
| 57 |
|
| 58 |
const moveIntoFooter = (element, headingText) => {
|
| 59 |
if (!element) return false;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
// Remove an eventual heading already included inside the block (avoid duplicates)
|
| 61 |
const firstHeading = element.querySelector(':scope > h1, :scope > h2, :scope > h3');
|
| 62 |
if (firstHeading) {
|
|
|
|
| 70 |
target.appendChild(element);
|
| 71 |
return true;
|
| 72 |
};
|
|
|
|
| 73 |
const run = () => {
|
| 74 |
+
const findFirstOutsideFooter = (selectors) => {
|
| 75 |
+
for (const sel of selectors) {
|
| 76 |
+
const el = contentRoot.querySelector(sel);
|
| 77 |
+
if (el && !footer.contains(el)) return el;
|
| 78 |
+
}
|
| 79 |
+
return null;
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
const referencesEl = findFirstOutsideFooter(['#references', '.references', '.bibliography']);
|
| 83 |
const footnotesEl = findFirstOutsideFooter(['.footnotes']);
|
| 84 |
+
|
| 85 |
const movedRefs = moveIntoFooter(referencesEl, 'References');
|
| 86 |
const movedNotes = moveIntoFooter(footnotesEl, 'Footnotes');
|
| 87 |
return movedRefs || movedNotes;
|
|
|
|
| 104 |
</script>
|
| 105 |
|
| 106 |
|
| 107 |
+
<style is:global>
|
| 108 |
+
.footer {
|
| 109 |
+
contain: layout style;
|
| 110 |
+
font-size: 0.8em;
|
| 111 |
+
line-height: 1.7em;
|
| 112 |
+
margin-top: 60px;
|
| 113 |
+
margin-bottom: 0;
|
| 114 |
+
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
| 115 |
+
color: rgba(0, 0, 0, 0.5);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.footer-inner {
|
| 119 |
+
max-width: 1280px;
|
| 120 |
+
margin: 0 auto;
|
| 121 |
+
padding: 60px 16px 48px;
|
| 122 |
+
display: grid;
|
| 123 |
+
grid-template-columns: 220px minmax(0, 680px) 260px;
|
| 124 |
+
gap: 32px;
|
| 125 |
+
align-items: start;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
/* Use the parent grid (3 columns like .content-grid) */
|
| 129 |
+
.citation-block,
|
| 130 |
+
.references-block,
|
| 131 |
+
.reuse-block,
|
| 132 |
+
.doi-block {
|
| 133 |
+
display: contents;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
.citation-block > h3,
|
| 137 |
+
.references-block > h3,
|
| 138 |
+
.reuse-block > h3,
|
| 139 |
+
.doi-block > h3 {
|
| 140 |
+
grid-column: 1;
|
| 141 |
+
font-size: 15px;
|
| 142 |
+
margin: 0;
|
| 143 |
+
text-align: right;
|
| 144 |
+
padding-right: 30px;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.citation-block > :not(h3),
|
| 148 |
+
.references-block > :not(h3),
|
| 149 |
+
.reuse-block > :not(h3),
|
| 150 |
+
.doi-block > :not(h3) {
|
| 151 |
+
grid-column: 2;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
@media (max-width: var(--bp-content-collapse)) {
|
| 155 |
+
.footer-inner {
|
| 156 |
+
grid-template-columns: 1fr;
|
| 157 |
+
gap: 16px;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.footer-inner > h3 {
|
| 161 |
+
grid-column: auto;
|
| 162 |
+
margin-top: 16px;
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.citation-block h3 {
|
| 167 |
+
margin: 0 0 8px;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.citation-block h4 {
|
| 171 |
+
margin: 16px 0 8px;
|
| 172 |
+
font-size: 14px;
|
| 173 |
+
text-transform: uppercase;
|
| 174 |
+
color: var(--muted-color);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.citation-block p,
|
| 178 |
+
.reuse-block p,
|
| 179 |
+
.doi-block p,
|
| 180 |
+
.footnotes ol,
|
| 181 |
+
.footnotes ol p,
|
| 182 |
+
.references {
|
| 183 |
+
margin-top: 0;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
/* Distill-like appendix citation styling */
|
| 187 |
+
.citation {
|
| 188 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
| 189 |
+
font-size: 11px;
|
| 190 |
+
line-height: 15px;
|
| 191 |
+
border-left: 1px solid rgba(0, 0, 0, 0.1);
|
| 192 |
+
padding-left: 18px;
|
| 193 |
+
border: 1px solid rgba(0,0,0,0.1);
|
| 194 |
+
background: rgba(0, 0, 0, 0.02);
|
| 195 |
+
padding: 10px 18px;
|
| 196 |
+
border-radius: 3px;
|
| 197 |
+
color: rgba(150, 150, 150, 1);
|
| 198 |
+
overflow: hidden;
|
| 199 |
+
margin-top: -12px;
|
| 200 |
+
white-space: pre-wrap;
|
| 201 |
+
word-wrap: break-word;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
.citation a {
|
| 205 |
+
color: rgba(0, 0, 0, 0.6);
|
| 206 |
+
text-decoration: underline;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
.citation.short {
|
| 210 |
+
margin-top: -4px;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
.references-block h3 {
|
| 214 |
+
margin: 0;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
/* Distill-like list styling for references/footnotes */
|
| 218 |
+
.references-block ol {
|
| 219 |
+
padding: 0 0 0 15px;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
@media (min-width: 768px) {
|
| 223 |
+
.references-block ol {
|
| 224 |
+
padding: 0 0 0 30px;
|
| 225 |
+
margin-left: -30px;
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.references-block li {
|
| 230 |
+
margin-bottom: 1em;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
.references-block a {
|
| 234 |
+
color: var(--text-color);
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
[data-theme="dark"] .footer { border-top-color: rgba(255,255,255,.15); color: rgba(200,200,200,.8); }
|
| 238 |
+
[data-theme="dark"] .citation { background: rgba(255,255,255,0.04); border-color: rgba(255,255,255,.15); color: rgba(200,200,200,1); }
|
| 239 |
+
[data-theme="dark"] .citation a { color: rgba(255,255,255,0.75); }
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
@media (max-width: var(--bp-content-collapse)) {
|
| 243 |
+
.footer-inner {
|
| 244 |
+
display: block;
|
| 245 |
+
padding: 40px 16px;
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
/* Footer links: use primary color consistently */
|
| 250 |
+
.footer a {
|
| 251 |
+
color: var(--primary-color);
|
| 252 |
+
border-bottom: 1px solid var(--link-underline);
|
| 253 |
+
text-decoration: none;
|
| 254 |
+
}
|
| 255 |
+
.footer a:hover {
|
| 256 |
+
color: var(--primary-color-hover);
|
| 257 |
+
border-bottom-color: var(--link-underline-hover);
|
| 258 |
+
}
|
| 259 |
+
[data-theme="dark"] .footer a {
|
| 260 |
+
color: var(--primary-color);
|
| 261 |
+
}
|
| 262 |
+
</style>
|
app/src/components/Hero.astro
CHANGED
|
@@ -5,12 +5,32 @@ interface Props {
|
|
| 5 |
title: string; // may contain HTML (e.g., <br/>)
|
| 6 |
titleRaw?: string; // plain title for slug/PDF (optional)
|
| 7 |
description?: string;
|
| 8 |
-
authors?: string[]
|
| 9 |
-
|
|
|
|
| 10 |
published?: string;
|
|
|
|
| 11 |
}
|
| 12 |
|
| 13 |
-
const { title, titleRaw, description, authors = [], affiliation, published } = Astro.props as Props;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
function stripHtml(text: string): string {
|
| 16 |
return String(text || '').replace(/<[^>]*>/g, '');
|
|
@@ -37,15 +57,36 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
|
|
| 37 |
</div>
|
| 38 |
</section>
|
| 39 |
|
| 40 |
-
<header class="meta">
|
| 41 |
<div class="meta-container">
|
| 42 |
-
{
|
| 43 |
<div class="meta-container-cell">
|
| 44 |
-
<h3>Author
|
| 45 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
</div>
|
| 47 |
)}
|
| 48 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
<div class="meta-container-cell">
|
| 50 |
<h3>Affiliation</h3>
|
| 51 |
<p>{affiliation}</p>
|
|
@@ -57,49 +98,114 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
|
|
| 57 |
<p>{published}</p>
|
| 58 |
</div>
|
| 59 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
<div class="meta-container-cell meta-container-cell--pdf">
|
| 61 |
<h3>PDF</h3>
|
| 62 |
-
<p
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
</div>
|
| 64 |
</div>
|
| 65 |
</header>
|
| 66 |
|
| 67 |
-
<script>
|
| 68 |
-
// Attach a handler to trigger a programmatic download
|
| 69 |
-
(() => {
|
| 70 |
-
const ready = () => {
|
| 71 |
-
const btn = document.getElementById('download-pdf-btn');
|
| 72 |
-
if (!btn) return;
|
| 73 |
-
btn.addEventListener('click', () => {
|
| 74 |
-
const a = document.createElement('a');
|
| 75 |
-
const pdf = btn.getAttribute('data-pdf-filename') || 'article.pdf';
|
| 76 |
-
a.href = `/${pdf}`;
|
| 77 |
-
a.setAttribute('download', pdf);
|
| 78 |
-
document.body.appendChild(a);
|
| 79 |
-
a.click();
|
| 80 |
-
a.remove();
|
| 81 |
-
});
|
| 82 |
-
};
|
| 83 |
-
if (document.readyState === 'loading') {
|
| 84 |
-
document.addEventListener('DOMContentLoaded', ready, { once: true });
|
| 85 |
-
} else { ready(); }
|
| 86 |
-
})();
|
| 87 |
-
</script>
|
| 88 |
|
| 89 |
<style>
|
| 90 |
/* Hero (full-width) */
|
| 91 |
-
.hero {
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
/* Meta (byline-like header) */
|
| 97 |
-
.meta {
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
</style>
|
| 104 |
|
| 105 |
|
|
|
|
| 5 |
title: string; // may contain HTML (e.g., <br/>)
|
| 6 |
titleRaw?: string; // plain title for slug/PDF (optional)
|
| 7 |
description?: string;
|
| 8 |
+
authors?: Array<string | { name: string; url?: string; affiliationIndices?: number[] }>;
|
| 9 |
+
affiliations?: Array<{ id: number; name: string; url?: string }>;
|
| 10 |
+
affiliation?: string; // legacy single affiliation
|
| 11 |
published?: string;
|
| 12 |
+
doi?: string;
|
| 13 |
}
|
| 14 |
|
| 15 |
+
const { title, titleRaw, description, authors = [], affiliations = [], affiliation, published, doi } = Astro.props as Props;
|
| 16 |
+
|
| 17 |
+
type Author = { name: string; url?: string; affiliationIndices?: number[] };
|
| 18 |
+
|
| 19 |
+
function normalizeAuthors(input: Array<string | { name?: string; url?: string; link?: string; affiliationIndices?: number[] }>): Author[] {
|
| 20 |
+
return (Array.isArray(input) ? input : [])
|
| 21 |
+
.map((a) => {
|
| 22 |
+
if (typeof a === 'string') {
|
| 23 |
+
return { name: a } as Author;
|
| 24 |
+
}
|
| 25 |
+
const name = (a?.name ?? '').toString();
|
| 26 |
+
const url = (a?.url ?? a?.link) as string | undefined;
|
| 27 |
+
const affiliationIndices = Array.isArray((a as any)?.affiliationIndices) ? (a as any).affiliationIndices : undefined;
|
| 28 |
+
return { name, url, affiliationIndices } as Author;
|
| 29 |
+
})
|
| 30 |
+
.filter((a) => a.name && a.name.trim().length > 0);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const normalizedAuthors: Author[] = normalizeAuthors(authors as any);
|
| 34 |
|
| 35 |
function stripHtml(text: string): string {
|
| 36 |
return String(text || '').replace(/<[^>]*>/g, '');
|
|
|
|
| 57 |
</div>
|
| 58 |
</section>
|
| 59 |
|
| 60 |
+
<header class="meta" aria-label="Article meta information">
|
| 61 |
<div class="meta-container">
|
| 62 |
+
{normalizedAuthors.length > 0 && (
|
| 63 |
<div class="meta-container-cell">
|
| 64 |
+
<h3>Author{normalizedAuthors.length > 1 ? 's' : ''}</h3>
|
| 65 |
+
<ul class="authors">
|
| 66 |
+
{normalizedAuthors.map((a, i) => {
|
| 67 |
+
const supers = Array.isArray(a.affiliationIndices) && a.affiliationIndices.length
|
| 68 |
+
? <sup>{a.affiliationIndices.join(',')}</sup>
|
| 69 |
+
: null;
|
| 70 |
+
return (
|
| 71 |
+
<li>
|
| 72 |
+
{a.url ? <a href={a.url}>{a.name}</a> : a.name}{supers}
|
| 73 |
+
</li>
|
| 74 |
+
);
|
| 75 |
+
})}
|
| 76 |
+
</ul>
|
| 77 |
</div>
|
| 78 |
)}
|
| 79 |
+
{(Array.isArray(affiliations) && affiliations.length > 0) && (
|
| 80 |
+
<div class="meta-container-cell">
|
| 81 |
+
<h3>Affiliation{affiliations.length > 1 ? 's' : ''}</h3>
|
| 82 |
+
<ol class="affiliations">
|
| 83 |
+
{affiliations.map((af) => (
|
| 84 |
+
<li value={af.id}>{af.url ? <a href={af.url} target="_blank" rel="noopener noreferrer">{af.name}</a> : af.name}</li>
|
| 85 |
+
))}
|
| 86 |
+
</ol>
|
| 87 |
+
</div>
|
| 88 |
+
)}
|
| 89 |
+
{(!affiliations || affiliations.length === 0) && affiliation && (
|
| 90 |
<div class="meta-container-cell">
|
| 91 |
<h3>Affiliation</h3>
|
| 92 |
<p>{affiliation}</p>
|
|
|
|
| 98 |
<p>{published}</p>
|
| 99 |
</div>
|
| 100 |
)}
|
| 101 |
+
<!-- {doi && (
|
| 102 |
+
<div class="meta-container-cell">
|
| 103 |
+
<h3>DOI</h3>
|
| 104 |
+
<p><a href={`https://doi.org/${doi}`} target="_blank" rel="noopener noreferrer">{doi}</a></p>
|
| 105 |
+
</div>
|
| 106 |
+
)} -->
|
| 107 |
<div class="meta-container-cell meta-container-cell--pdf">
|
| 108 |
<h3>PDF</h3>
|
| 109 |
+
<p>
|
| 110 |
+
<a class="button" href={`/${pdfFilename}`} download={pdfFilename} aria-label={`Download PDF ${pdfFilename}`}>
|
| 111 |
+
Download PDF
|
| 112 |
+
</a>
|
| 113 |
+
</p>
|
| 114 |
</div>
|
| 115 |
</div>
|
| 116 |
</header>
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
<style>
|
| 120 |
/* Hero (full-width) */
|
| 121 |
+
.hero {
|
| 122 |
+
width: 100%;
|
| 123 |
+
padding: 48px 16px 16px;
|
| 124 |
+
text-align: center;
|
| 125 |
+
}
|
| 126 |
+
.hero-title {
|
| 127 |
+
font-size: clamp(28px, 4vw, 48px);
|
| 128 |
+
font-weight: 800;
|
| 129 |
+
line-height: 1.1;
|
| 130 |
+
margin: 0 0 8px;
|
| 131 |
+
max-width: 100%;
|
| 132 |
+
margin: auto;
|
| 133 |
+
}
|
| 134 |
+
.hero-banner {
|
| 135 |
+
max-width: 980px;
|
| 136 |
+
margin: 0 auto;
|
| 137 |
+
}
|
| 138 |
+
.hero-desc {
|
| 139 |
+
color: var(--muted-color);
|
| 140 |
+
font-style: italic;
|
| 141 |
+
margin: 0 0 16px 0;
|
| 142 |
+
}
|
| 143 |
|
| 144 |
/* Meta (byline-like header) */
|
| 145 |
+
.meta {
|
| 146 |
+
border-top: 1px solid var(--border-color);
|
| 147 |
+
border-bottom: 1px solid var(--border-color);
|
| 148 |
+
padding: 1rem 0;
|
| 149 |
+
font-size: 0.9rem;
|
| 150 |
+
}
|
| 151 |
+
.meta-container {
|
| 152 |
+
max-width: 760px;
|
| 153 |
+
display: flex;
|
| 154 |
+
flex-direction: row;
|
| 155 |
+
justify-content: space-between;
|
| 156 |
+
margin: 0 auto;
|
| 157 |
+
padding: 0 var(--content-padding-x);
|
| 158 |
+
gap: 8px;
|
| 159 |
+
}
|
| 160 |
+
/* Subtle underline for links in meta; keep buttons without underline */
|
| 161 |
+
.meta-container a {
|
| 162 |
+
color: var(--primary-color);
|
| 163 |
+
text-decoration: underline;
|
| 164 |
+
text-underline-offset: 2px;
|
| 165 |
+
text-decoration-thickness: 0.06em;
|
| 166 |
+
text-decoration-color: var(--link-underline);
|
| 167 |
+
transition: text-decoration-color .15s ease-in-out;
|
| 168 |
+
}
|
| 169 |
+
.meta-container a:hover {
|
| 170 |
+
text-decoration-color: var(--link-underline-hover);
|
| 171 |
+
}
|
| 172 |
+
.meta-container a.button,
|
| 173 |
+
.meta-container .button {
|
| 174 |
+
text-decoration: none;
|
| 175 |
+
}
|
| 176 |
+
.meta-container-cell {
|
| 177 |
+
display: flex;
|
| 178 |
+
flex-direction: column;
|
| 179 |
+
gap: 8px;
|
| 180 |
+
}
|
| 181 |
+
.meta-container-cell h3 {
|
| 182 |
+
margin: 0;
|
| 183 |
+
font-size: 12px;
|
| 184 |
+
font-weight: 400;
|
| 185 |
+
color: var(--muted-color);
|
| 186 |
+
text-transform: uppercase;
|
| 187 |
+
letter-spacing: .02em;
|
| 188 |
+
}
|
| 189 |
+
.meta-container-cell p {
|
| 190 |
+
margin: 0;
|
| 191 |
+
}
|
| 192 |
+
.authors {
|
| 193 |
+
margin: 0;
|
| 194 |
+
list-style-type: none;
|
| 195 |
+
padding-left: 0;
|
| 196 |
+
}
|
| 197 |
+
.affiliations {
|
| 198 |
+
margin: 0;
|
| 199 |
+
padding-left: 1.25em;
|
| 200 |
+
}
|
| 201 |
+
.affiliations li {
|
| 202 |
+
margin: 0;
|
| 203 |
+
}
|
| 204 |
+
@media print {
|
| 205 |
+
.meta-container-cell--pdf {
|
| 206 |
+
display: none !important;
|
| 207 |
+
}
|
| 208 |
+
}
|
| 209 |
</style>
|
| 210 |
|
| 211 |
|
app/src/components/HtmlEmbed.astro
CHANGED
|
@@ -68,8 +68,8 @@ const mountId = `frag-${Math.random().toString(36).slice(2)}`;
|
|
| 68 |
else execute();
|
| 69 |
</script>
|
| 70 |
|
| 71 |
-
<style>
|
| 72 |
-
.html-embed { margin: 0; overflow: hidden; }
|
| 73 |
.html-embed__title {
|
| 74 |
text-align: left;
|
| 75 |
font-weight: 600;
|
|
@@ -94,9 +94,38 @@ const mountId = `frag-${Math.random().toString(36).slice(2)}`;
|
|
| 94 |
color: var(--muted-color);
|
| 95 |
margin: 6px 0 0 0;
|
| 96 |
}
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
@media print {
|
| 101 |
.html-embed, .html-embed__card { max-width: 100% !important; width: 100% !important; margin-left: 0 !important; margin-right: 0 !important; }
|
| 102 |
.html-embed__card { padding: 6px; }
|
|
|
|
| 68 |
else execute();
|
| 69 |
</script>
|
| 70 |
|
| 71 |
+
<style is:global>
|
| 72 |
+
.html-embed { margin: 0 0 var(--block-spacing-y); overflow: hidden; }
|
| 73 |
.html-embed__title {
|
| 74 |
text-align: left;
|
| 75 |
font-weight: 600;
|
|
|
|
| 94 |
color: var(--muted-color);
|
| 95 |
margin: 6px 0 0 0;
|
| 96 |
}
|
| 97 |
+
/* Plotly – fragments & controls */
|
| 98 |
+
.html-embed__card svg text { fill: var(--text-color) !important; }
|
| 99 |
+
.html-embed__card label { color: var(--text-color) !important; }
|
| 100 |
+
.plotly-graph-div { width: 100% !important; min-height: 320px; }
|
| 101 |
+
@media (max-width: 768px) { .plotly-graph-div { min-height: 260px; } }
|
| 102 |
+
[id^="plot-"] { display: flex; flex-direction: column; align-items: center; gap: 15px; }
|
| 103 |
+
.plotly_caption { font-style: italic; margin-top: 10px; }
|
| 104 |
+
.plotly_controls { display: flex; flex-wrap: wrap; justify-content: center; gap: 30px; }
|
| 105 |
+
.plotly_input_container { display: flex; align-items: center; flex-direction: column; gap: 10px; }
|
| 106 |
+
.plotly_input_container > select { padding: 2px 4px; line-height: 1.5em; text-align: center; border-radius: 4px; font-size: 12px; background-color: var(--neutral-200); outline: none; border: 1px solid var(--neutral-300); }
|
| 107 |
+
.plotly_slider { display: flex; align-items: center; gap: 10px; }
|
| 108 |
+
.plotly_slider > input[type="range"] { -webkit-appearance: none; appearance: none; height: 2px; background: var(--neutral-400); border-radius: 5px; outline: none; }
|
| 109 |
+
.plotly_slider > input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: var(--primary-color); cursor: pointer; }
|
| 110 |
+
.plotly_slider > input[type="range"]::-moz-range-thumb { width: 18px; height: 18px; border-radius: 50%; background: var(--primary-color); cursor: pointer; }
|
| 111 |
+
.plotly_slider > span { font-size: 14px; line-height: 1.6em; min-width: 16px; }
|
| 112 |
+
/* Dark mode overrides for Plotly readability */
|
| 113 |
+
[data-theme="dark"] .html-embed__card:not(.is-frameless) { background: #12151b; border-color: rgba(255,255,255,.15); }
|
| 114 |
+
[data-theme="dark"] .html-embed__card .xaxislayer-above text,
|
| 115 |
+
[data-theme="dark"] .html-embed__card .yaxislayer-above text,
|
| 116 |
+
[data-theme="dark"] .html-embed__card .infolayer text,
|
| 117 |
+
[data-theme="dark"] .html-embed__card .legend text,
|
| 118 |
+
[data-theme="dark"] .html-embed__card .annotation text,
|
| 119 |
+
[data-theme="dark"] .html-embed__card .colorbar text,
|
| 120 |
+
[data-theme="dark"] .html-embed__card .hoverlayer text { fill: #fff !important; }
|
| 121 |
+
[data-theme="dark"] .html-embed__card .xaxislayer-above path,
|
| 122 |
+
[data-theme="dark"] .html-embed__card .yaxislayer-above path,
|
| 123 |
+
[data-theme="dark"] .html-embed__card .xlines-above,
|
| 124 |
+
[data-theme="dark"] .html-embed__card .ylines-above { stroke: rgba(255,255,255,.35) !important; }
|
| 125 |
+
[data-theme="dark"] .html-embed__card .gridlayer path { stroke: rgba(255,255,255,.15) !important; }
|
| 126 |
+
[data-theme="dark"] .html-embed__card .legend rect.bg { fill: rgba(0,0,0,.25) !important; stroke: rgba(255,255,255,.2) !important; }
|
| 127 |
+
[data-theme="dark"] .html-embed__card .hoverlayer .bg { fill: rgba(0,0,0,.8) !important; stroke: rgba(255,255,255,.2) !important; }
|
| 128 |
+
[data-theme="dark"] .html-embed__card .colorbar .cbbg { fill: rgba(0,0,0,.25) !important; stroke: rgba(255,255,255,.2) !important; }
|
| 129 |
@media print {
|
| 130 |
.html-embed, .html-embed__card { max-width: 100% !important; width: 100% !important; margin-left: 0 !important; margin-right: 0 !important; }
|
| 131 |
.html-embed__card { padding: 6px; }
|
app/src/components/Note.astro
CHANGED
|
@@ -10,21 +10,27 @@ const wrapperClass = ["note", `note--${variant}`, className].filter(Boolean).joi
|
|
| 10 |
const hasHeader = (emoji && String(emoji).length > 0) || (title && String(title).length > 0);
|
| 11 |
---
|
| 12 |
<div class={wrapperClass} {...props}>
|
| 13 |
-
|
| 14 |
-
{emoji && <
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</div>
|
| 20 |
</div>
|
| 21 |
|
| 22 |
<style>
|
| 23 |
-
.note { background: var(--surface-bg); border-left: 2px solid var(--border-color); border-radius: 4px; padding: 10px 14px; margin:
|
| 24 |
-
.
|
| 25 |
-
.
|
| 26 |
-
.
|
| 27 |
-
.
|
|
|
|
|
|
|
| 28 |
/* Ensure the very last slotted element has no bottom margin */
|
| 29 |
.note .note__content > :global(*:last-child) { margin-bottom: 0 !important; }
|
| 30 |
|
|
|
|
| 10 |
const hasHeader = (emoji && String(emoji).length > 0) || (title && String(title).length > 0);
|
| 11 |
---
|
| 12 |
<div class={wrapperClass} {...props}>
|
| 13 |
+
<div class="note__layout">
|
| 14 |
+
{emoji && <div class="note__icon" aria-hidden="true">
|
| 15 |
+
<span class="note__emoji">{emoji}</span>
|
| 16 |
+
</div>}
|
| 17 |
+
<div class="note__body">
|
| 18 |
+
{title && <div class="note__title">{title}</div>}
|
| 19 |
+
<div class="note__content">
|
| 20 |
+
<slot />
|
| 21 |
+
</div>
|
| 22 |
+
</div>
|
| 23 |
</div>
|
| 24 |
</div>
|
| 25 |
|
| 26 |
<style>
|
| 27 |
+
.note { background: var(--surface-bg); border-left: 2px solid var(--border-color); border-radius: 4px; padding: 10px 14px; margin: var(--block-spacing-y) 0; }
|
| 28 |
+
.note__layout { display: flex; align-items: center; gap: 10px; }
|
| 29 |
+
.note__icon { flex: 0 0 auto; line-height: 1; }
|
| 30 |
+
.note__emoji { font-size: 32px; line-height: 1; display: block; }
|
| 31 |
+
.note__body { flex: 1 1 auto; min-width: 0; }
|
| 32 |
+
.note__title { font-size: 13px; letter-spacing: .2px; font-weight: 600; color: var(--text-color); margin-bottom: 4px; text-align: left; }
|
| 33 |
+
.note__content { color: var(--text-color); font-size: 0.95rem; text-align: left; }
|
| 34 |
/* Ensure the very last slotted element has no bottom margin */
|
| 35 |
.note .note__content > :global(*:last-child) { margin-bottom: 0 !important; }
|
| 36 |
|
app/src/components/ResponsiveImage.astro
CHANGED
|
@@ -165,3 +165,49 @@ const dataDownloadable = (downloadable === true || (imgProps as any)['data-downl
|
|
| 165 |
</script>
|
| 166 |
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
</script>
|
| 166 |
|
| 167 |
|
| 168 |
+
<style>
|
| 169 |
+
|
| 170 |
+
figure { margin: var(--block-spacing-y) 0; }
|
| 171 |
+
figcaption { text-align: left; font-size: 0.9rem; color: var(--muted-color); margin-top: 6px; }
|
| 172 |
+
.image-credit { display: block; margin-top: 4px; font-size: 12px; color: var(--muted-color); }
|
| 173 |
+
.image-credit a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
|
| 174 |
+
|
| 175 |
+
/* Zoomable overlay container (if used by any lightbox implementation) */
|
| 176 |
+
[data-zoom-overlay],
|
| 177 |
+
.zoom-overlay {
|
| 178 |
+
position: fixed;
|
| 179 |
+
inset: 0;
|
| 180 |
+
z-index: var(--z-overlay);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
/* Download link inside figures */
|
| 184 |
+
figure .download-link { position: relative; z-index: var(--z-elevated); }
|
| 185 |
+
|
| 186 |
+
/* Opt-in zoomable images */
|
| 187 |
+
img[data-zoomable] { cursor: zoom-in; }
|
| 188 |
+
.medium-zoom--opened img[data-zoomable] { cursor: zoom-out; }
|
| 189 |
+
|
| 190 |
+
/* Download button for img[data-downloadable] */
|
| 191 |
+
figure.has-dl-btn { position: relative; }
|
| 192 |
+
.dl-host { position: relative; }
|
| 193 |
+
.img-dl-wrap { position: relative; display: inline-block; }
|
| 194 |
+
.img-dl-btn {
|
| 195 |
+
position: absolute;
|
| 196 |
+
right: 8px;
|
| 197 |
+
bottom: 8px;
|
| 198 |
+
align-items: center;
|
| 199 |
+
justify-content: center;
|
| 200 |
+
width: 30px;
|
| 201 |
+
height: 30px;
|
| 202 |
+
border-radius: 6px;
|
| 203 |
+
color: white;
|
| 204 |
+
text-decoration: none;
|
| 205 |
+
border: 1px solid rgba(255,255,255,0.25);
|
| 206 |
+
z-index: var(--z-elevated);
|
| 207 |
+
display: none;
|
| 208 |
+
}
|
| 209 |
+
.img-dl-btn svg { width: 18px; height: 18px; fill: currentColor; }
|
| 210 |
+
.img-dl-wrap:hover .img-dl-btn { display: inline-flex; }
|
| 211 |
+
[data-theme="dark"] .img-dl-btn { background: rgba(255,255,255,0.15); color: white; border-color: rgba(255,255,255,0.25); }
|
| 212 |
+
[data-theme="dark"] .img-dl-btn:hover { background: rgba(255,255,255,0.25); }
|
| 213 |
+
</style>
|
app/src/components/Sidenote.astro
CHANGED
|
@@ -1,12 +1,37 @@
|
|
| 1 |
---
|
| 2 |
---
|
| 3 |
-
<div class="
|
| 4 |
-
<div class="
|
| 5 |
<slot />
|
| 6 |
</div>
|
| 7 |
-
<aside class="
|
| 8 |
<slot name="aside" />
|
| 9 |
</aside>
|
| 10 |
</div>
|
| 11 |
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
---
|
| 3 |
+
<div class="aside">
|
| 4 |
+
<div class="aside__main">
|
| 5 |
<slot />
|
| 6 |
</div>
|
| 7 |
+
<aside class="aside__aside">
|
| 8 |
<slot name="aside" />
|
| 9 |
</aside>
|
| 10 |
</div>
|
| 11 |
|
| 12 |
|
| 13 |
+
<style is:global>
|
| 14 |
+
.aside {
|
| 15 |
+
position: relative;
|
| 16 |
+
margin: 12px 0;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
.aside__aside {
|
| 20 |
+
position: absolute;
|
| 21 |
+
top: 0;
|
| 22 |
+
right: -260px; /* push into the right grid column (width 260 + gap 32) */
|
| 23 |
+
width: 260px;
|
| 24 |
+
border-radius: 8px;
|
| 25 |
+
padding: 0 30px;
|
| 26 |
+
font-size: 0.9rem;
|
| 27 |
+
color: var(--muted-color);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
@media (max-width: var(--bp-content-collapse)) {
|
| 31 |
+
.aside__aside {
|
| 32 |
+
position: static;
|
| 33 |
+
width: auto;
|
| 34 |
+
margin-top: 8px;
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
</style>
|
app/src/components/{TableOfContent.astro → TableOfContents.astro}
RENAMED
|
@@ -2,12 +2,11 @@
|
|
| 2 |
export interface Props { tableOfContentAutoCollapse?: boolean }
|
| 3 |
const { tableOfContentAutoCollapse = false } = Astro.props as Props;
|
| 4 |
---
|
| 5 |
-
<aside class="
|
| 6 |
<div class="title">Table of Contents</div>
|
| 7 |
<div id="article-toc-placeholder"></div>
|
| 8 |
-
|
| 9 |
</aside>
|
| 10 |
-
<details class="
|
| 11 |
<summary>Table of Contents</summary>
|
| 12 |
<div id="article-toc-mobile-placeholder"></div>
|
| 13 |
</details>
|
|
@@ -98,7 +97,7 @@ const { tableOfContentAutoCollapse = false } = Astro.props as Props;
|
|
| 98 |
...(holder ? holder.querySelectorAll('a') : []),
|
| 99 |
...(holderMobile ? holderMobile.querySelectorAll('a') : [])
|
| 100 |
];
|
| 101 |
-
const autoCollapse = (document.querySelector('aside.
|
| 102 |
|
| 103 |
// Inject styles for collapsible & animation
|
| 104 |
const ensureStyles = () => {
|
|
@@ -106,10 +105,10 @@ const { tableOfContentAutoCollapse = false } = Astro.props as Props;
|
|
| 106 |
const style = document.createElement('style');
|
| 107 |
style.id = 'toc-collapse-style';
|
| 108 |
style.textContent = `
|
| 109 |
-
aside.
|
| 110 |
-
details.
|
| 111 |
-
aside.
|
| 112 |
-
details.
|
| 113 |
`;
|
| 114 |
document.head.appendChild(style);
|
| 115 |
};
|
|
@@ -125,8 +124,8 @@ const { tableOfContentAutoCollapse = false } = Astro.props as Props;
|
|
| 125 |
const setNavCollapsible = () => {
|
| 126 |
const sideNav = holder ? holder.querySelector('nav') : null;
|
| 127 |
const mobileNav = holderMobile ? holderMobile.querySelector('nav') : null;
|
| 128 |
-
if (sideNav) sideNav.classList.add('
|
| 129 |
-
if (mobileNav) mobileNav.classList.add('
|
| 130 |
};
|
| 131 |
|
| 132 |
const measure = (el) => {
|
|
@@ -205,6 +204,9 @@ const { tableOfContentAutoCollapse = false } = Astro.props as Props;
|
|
| 205 |
if (activeIdx !== prevActiveIdx) setCollapsedState(activeIdx);
|
| 206 |
};
|
| 207 |
|
|
|
|
|
|
|
|
|
|
| 208 |
window.addEventListener('scroll', onScroll);
|
| 209 |
// Initialize state
|
| 210 |
onScroll();
|
|
@@ -227,4 +229,128 @@ const { tableOfContentAutoCollapse = false } = Astro.props as Props;
|
|
| 227 |
} else { buildTOC(); }
|
| 228 |
</script>
|
| 229 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
export interface Props { tableOfContentAutoCollapse?: boolean }
|
| 3 |
const { tableOfContentAutoCollapse = false } = Astro.props as Props;
|
| 4 |
---
|
| 5 |
+
<aside class="table-of-contents" data-auto-collapse={tableOfContentAutoCollapse ? '1' : '0'}>
|
| 6 |
<div class="title">Table of Contents</div>
|
| 7 |
<div id="article-toc-placeholder"></div>
|
|
|
|
| 8 |
</aside>
|
| 9 |
+
<details class="table-of-contents-mobile">
|
| 10 |
<summary>Table of Contents</summary>
|
| 11 |
<div id="article-toc-mobile-placeholder"></div>
|
| 12 |
</details>
|
|
|
|
| 97 |
...(holder ? holder.querySelectorAll('a') : []),
|
| 98 |
...(holderMobile ? holderMobile.querySelectorAll('a') : [])
|
| 99 |
];
|
| 100 |
+
const autoCollapse = (document.querySelector('aside.table-of-contents')?.getAttribute('data-auto-collapse') === '1');
|
| 101 |
|
| 102 |
// Inject styles for collapsible & animation
|
| 103 |
const ensureStyles = () => {
|
|
|
|
| 105 |
const style = document.createElement('style');
|
| 106 |
style.id = 'toc-collapse-style';
|
| 107 |
style.textContent = `
|
| 108 |
+
aside.table-of-contents nav.table-of-contents-collapsible > ul > li > ul,
|
| 109 |
+
details.table-of-contents-mobile nav.table-of-contents-collapsible > ul > li > ul { overflow: hidden; transition: height 200ms ease; }
|
| 110 |
+
aside.table-of-contents nav.table-of-contents-collapsible > ul > li.collapsed > ul,
|
| 111 |
+
details.table-of-contents-mobile nav.table-of-contents-collapsible > ul > li.collapsed > ul { display: block; }
|
| 112 |
`;
|
| 113 |
document.head.appendChild(style);
|
| 114 |
};
|
|
|
|
| 124 |
const setNavCollapsible = () => {
|
| 125 |
const sideNav = holder ? holder.querySelector('nav') : null;
|
| 126 |
const mobileNav = holderMobile ? holderMobile.querySelector('nav') : null;
|
| 127 |
+
if (sideNav) sideNav.classList.add('table-of-contents-collapsible');
|
| 128 |
+
if (mobileNav) mobileNav.classList.add('table-of-contents-collapsible');
|
| 129 |
};
|
| 130 |
|
| 131 |
const measure = (el) => {
|
|
|
|
| 204 |
if (activeIdx !== prevActiveIdx) setCollapsedState(activeIdx);
|
| 205 |
};
|
| 206 |
|
| 207 |
+
// If auto-collapse, collapse immediately (expand first section) before any scroll
|
| 208 |
+
if (autoCollapse) setCollapsedState(0);
|
| 209 |
+
|
| 210 |
window.addEventListener('scroll', onScroll);
|
| 211 |
// Initialize state
|
| 212 |
onScroll();
|
|
|
|
| 229 |
} else { buildTOC(); }
|
| 230 |
</script>
|
| 231 |
|
| 232 |
+
<style is:global>
|
| 233 |
+
/* Sticky aside */
|
| 234 |
+
.table-of-contents {
|
| 235 |
+
position: sticky;
|
| 236 |
+
top: 32px;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
.table-of-contents nav {
|
| 240 |
+
border-left: 1px solid var(--border-color);
|
| 241 |
+
padding-left: 16px;
|
| 242 |
+
font-size: 13px;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
.table-of-contents .title {
|
| 246 |
+
font-weight: 600;
|
| 247 |
+
font-size: 14px;
|
| 248 |
+
margin-bottom: 8px;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
/* Look & feel */
|
| 252 |
+
.table-of-contents nav ul {
|
| 253 |
+
margin: 0 0 6px;
|
| 254 |
+
padding-left: 1em;
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
.table-of-contents nav li {
|
| 258 |
+
list-style: none;
|
| 259 |
+
margin: .25em 0;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
.table-of-contents nav a,
|
| 263 |
+
.table-of-contents nav a:link,
|
| 264 |
+
.table-of-contents nav a:visited {
|
| 265 |
+
color: var(--text-color);
|
| 266 |
+
text-decoration: none;
|
| 267 |
+
border-bottom: none;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
.table-of-contents nav > ul > li > a {
|
| 271 |
+
font-weight: 700;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
.table-of-contents nav a:hover {
|
| 275 |
+
text-decoration: underline solid var(--muted-color);
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
.table-of-contents nav a.active {
|
| 279 |
+
text-decoration: underline;
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
/* Mobile accordion */
|
| 283 |
+
.table-of-contents-mobile {
|
| 284 |
+
display: none;
|
| 285 |
+
margin: 8px 0 16px;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
.table-of-contents-mobile > summary {
|
| 289 |
+
cursor: pointer;
|
| 290 |
+
list-style: none;
|
| 291 |
+
padding: 8px 12px;
|
| 292 |
+
border: 1px solid var(--border-color);
|
| 293 |
+
border-radius: 8px;
|
| 294 |
+
color: var(--text-color);
|
| 295 |
+
font-weight: 600;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
.table-of-contents-mobile[open] > summary {
|
| 299 |
+
border-bottom-left-radius: 0;
|
| 300 |
+
border-bottom-right-radius: 0;
|
| 301 |
+
}
|
| 302 |
|
| 303 |
+
.table-of-contents-mobile nav {
|
| 304 |
+
border-left: none;
|
| 305 |
+
padding: 10px 12px;
|
| 306 |
+
font-size: 14px;
|
| 307 |
+
border: 1px solid var(--border-color);
|
| 308 |
+
border-top: none;
|
| 309 |
+
border-bottom-left-radius: 8px;
|
| 310 |
+
border-bottom-right-radius: 8px;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.table-of-contents-mobile nav ul {
|
| 314 |
+
margin: 0 0 6px;
|
| 315 |
+
padding-left: 1em;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
.table-of-contents-mobile nav li {
|
| 319 |
+
list-style: none;
|
| 320 |
+
margin: .25em 0;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
.table-of-contents-mobile nav a,
|
| 324 |
+
.table-of-contents-mobile nav a:link,
|
| 325 |
+
.table-of-contents-mobile nav a:visited {
|
| 326 |
+
color: var(--text-color);
|
| 327 |
+
text-decoration: none;
|
| 328 |
+
border-bottom: none;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
.table-of-contents-mobile nav > ul > li > a {
|
| 332 |
+
font-weight: 700;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
.table-of-contents-mobile nav a:hover {
|
| 336 |
+
text-decoration: underline solid var(--muted-color);
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
.table-of-contents-mobile nav a.active {
|
| 340 |
+
text-decoration: underline;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
[data-theme="dark"] .table-of-contents nav { border-left-color: rgba(255,255,255,.15); }
|
| 344 |
+
|
| 345 |
+
/* Responsive: hide sticky TOC, show mobile */
|
| 346 |
+
@media (max-width: var(--bp-content-collapse)) {
|
| 347 |
+
.table-of-contents {
|
| 348 |
+
position: static;
|
| 349 |
+
display: none;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.table-of-contents-mobile {
|
| 353 |
+
display: block;
|
| 354 |
+
}
|
| 355 |
+
}
|
| 356 |
+
</style>
|
app/src/components/ThemeToggle.astro
CHANGED
|
@@ -44,3 +44,10 @@
|
|
| 44 |
</button>
|
| 45 |
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
</button>
|
| 45 |
|
| 46 |
|
| 47 |
+
<style>
|
| 48 |
+
#theme-toggle { display: inline-flex; align-items: center; gap: 8px; border: none; background: transparent; padding: 6px 10px; border-radius: 8px; cursor: pointer; margin: 12px 16px; color: var(--text-color) !important; }
|
| 49 |
+
#theme-toggle .icon.dark { display: none; }
|
| 50 |
+
[data-theme="dark"] #theme-toggle .icon.light { display: none; }
|
| 51 |
+
[data-theme="dark"] #theme-toggle .icon.dark { display: inline; }
|
| 52 |
+
#theme-toggle .icon { filter: none !important; }
|
| 53 |
+
</style>
|
app/src/content/article.mdx
CHANGED
|
@@ -4,20 +4,37 @@ title: "Bringing paper to life:\n A modern template for\n scientific writing
|
|
| 4 |
subtitle: "A modern, MDX-first research article template with math, citations and interactive figures."
|
| 5 |
description: "A modern, MDX-first research article template with math, citations and interactive figures."
|
| 6 |
authors:
|
| 7 |
-
- "Thibaud Frere"
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
published: "Sep. 01, 2025"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
tags:
|
| 11 |
- research
|
| 12 |
- template
|
| 13 |
-
|
| 14 |
---
|
| 15 |
|
| 16 |
import Introduction from "./chapters/introduction.mdx";
|
| 17 |
import BestPractices from "./chapters/best-pratices.mdx";
|
| 18 |
import WritingYourContent from "./chapters/writing-your-content.mdx";
|
| 19 |
-
import AvailableBlocks from "./chapters/
|
| 20 |
import GettingStarted from "./chapters/getting-started.mdx";
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
<Introduction />
|
| 23 |
|
|
@@ -25,7 +42,10 @@ import GettingStarted from "./chapters/getting-started.mdx";
|
|
| 25 |
|
| 26 |
<WritingYourContent />
|
| 27 |
|
| 28 |
-
<
|
|
|
|
|
|
|
| 29 |
|
| 30 |
<BestPractices />
|
| 31 |
|
|
|
|
|
|
| 4 |
subtitle: "A modern, MDX-first research article template with math, citations and interactive figures."
|
| 5 |
description: "A modern, MDX-first research article template with math, citations and interactive figures."
|
| 6 |
authors:
|
| 7 |
+
- name: "Thibaud Frere"
|
| 8 |
+
url: "https://huggingface.co/tfrere"
|
| 9 |
+
affiliations: [1]
|
| 10 |
+
- name: "Alice Example"
|
| 11 |
+
url: "https://example.com/~alice"
|
| 12 |
+
affiliations: [1, 2]
|
| 13 |
+
- name: "Bob Researcher"
|
| 14 |
+
affiliations:
|
| 15 |
+
- name: "Hugging Face"
|
| 16 |
+
url: "https://huggingface.co"
|
| 17 |
+
- name: "Example University"
|
| 18 |
+
url: "https://example.edu"
|
| 19 |
published: "Sep. 01, 2025"
|
| 20 |
+
doi: 10.1234/abcd.efgh
|
| 21 |
+
licence: >
|
| 22 |
+
Diagrams and text are licensed under <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener noreferrer">CC‑BY 4.0</a> with the source available on <a href="https://huggingface.co/spaces/tfrere/research-article-template" target="_blank" rel="noopener noreferrer">Hugging Face</a>, unless noted otherwise.
|
| 23 |
+
Figures reused from other sources are excluded and marked in their captions (“Figure from …”).
|
| 24 |
tags:
|
| 25 |
- research
|
| 26 |
- template
|
| 27 |
+
tableOfContentsAutoCollapse: true
|
| 28 |
---
|
| 29 |
|
| 30 |
import Introduction from "./chapters/introduction.mdx";
|
| 31 |
import BestPractices from "./chapters/best-pratices.mdx";
|
| 32 |
import WritingYourContent from "./chapters/writing-your-content.mdx";
|
| 33 |
+
import AvailableBlocks from "./chapters/markdown.mdx";
|
| 34 |
import GettingStarted from "./chapters/getting-started.mdx";
|
| 35 |
+
import Markdown from "./chapters/markdown.mdx";
|
| 36 |
+
import Components from "./chapters/components.mdx";
|
| 37 |
+
import DebugComponents from "./chapters/debug-components.mdx";
|
| 38 |
|
| 39 |
<Introduction />
|
| 40 |
|
|
|
|
| 42 |
|
| 43 |
<WritingYourContent />
|
| 44 |
|
| 45 |
+
<Markdown />
|
| 46 |
+
|
| 47 |
+
<Components />
|
| 48 |
|
| 49 |
<BestPractices />
|
| 50 |
|
| 51 |
+
<DebugComponents />
|
app/src/content/bibliography.bib
CHANGED
|
@@ -6,18 +6,6 @@
|
|
| 6 |
year={2017}
|
| 7 |
}
|
| 8 |
|
| 9 |
-
@article{example2023,
|
| 10 |
-
title={Example Paper Title},
|
| 11 |
-
author={Example, A. and Another, A.},
|
| 12 |
-
journal={Journal of Examples},
|
| 13 |
-
volume={1},
|
| 14 |
-
number={1},
|
| 15 |
-
pages={1--10},
|
| 16 |
-
year={2023},
|
| 17 |
-
publisher={Example Publisher}
|
| 18 |
-
}
|
| 19 |
-
|
| 20 |
-
|
| 21 |
@book{mckinney2017python,
|
| 22 |
title={Python for Data Analysis},
|
| 23 |
author={McKinney, Wes},
|
|
|
|
| 6 |
year={2017}
|
| 7 |
}
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
@book{mckinney2017python,
|
| 10 |
title={Python for Data Analysis},
|
| 11 |
author={McKinney, Wes},
|
app/src/content/chapters/{available-blocks.mdx → components.mdx}
RENAMED
|
@@ -9,102 +9,21 @@ import FullWidth from '../../components/FullWidth.astro';
|
|
| 9 |
import Accordion from '../../components/Accordion.astro';
|
| 10 |
import ResponsiveImage from '../../components/ResponsiveImage.astro';
|
| 11 |
|
| 12 |
-
##
|
| 13 |
|
| 14 |
**All** the following **components** are available in the **article.mdx** file. You can also create your own **components** by creating a new file in the `/components` folder.
|
|
|
|
| 15 |
|
| 16 |
<br/>
|
| 17 |
<div className="button-group">
|
| 18 |
-
<a className="button" href="#math">Math</a>
|
| 19 |
-
<a className="button" href="#code-blocks">Code</a>
|
| 20 |
-
<a className="button" href="#citations-and-notes">Citations & notes</a>
|
| 21 |
<a className="button" href="#responsiveimage">ResponsiveImage</a>
|
| 22 |
-
<a className="button" href="#mermaid-diagrams">Mermaid</a>
|
| 23 |
<a className="button" href="#placement">Placement</a>
|
| 24 |
<a className="button" href="#accordion">Accordion</a>
|
| 25 |
<a className="button" href="#note">Note</a>
|
| 26 |
-
<a className="button" href="#
|
| 27 |
-
<a className="button" href="#
|
| 28 |
-
<a className="button" href="#htmlembeds">HtmlEmbeds</a>
|
| 29 |
</div>
|
| 30 |
|
| 31 |
-
### Math
|
| 32 |
-
|
| 33 |
-
KaTeX is used for math rendering.
|
| 34 |
-
|
| 35 |
-
**Inline**
|
| 36 |
-
|
| 37 |
-
This is an inline math equation: $x^2 + y^2 = z^2$.
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
<small className="muted">Example</small>
|
| 41 |
-
```mdx
|
| 42 |
-
$x^2 + y^2 = z^2$
|
| 43 |
-
```
|
| 44 |
-
|
| 45 |
-
**Block**
|
| 46 |
-
|
| 47 |
-
$$
|
| 48 |
-
\mathrm{Attention}(Q,K,V)=\mathrm{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V
|
| 49 |
-
$$
|
| 50 |
-
|
| 51 |
-
<small className="muted">Example</small>
|
| 52 |
-
```mdx
|
| 53 |
-
$$
|
| 54 |
-
\mathrm{Attention}(Q,K,V)=\mathrm{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V
|
| 55 |
-
$$
|
| 56 |
-
```
|
| 57 |
-
|
| 58 |
-
### Code
|
| 59 |
-
|
| 60 |
-
Use fenced code blocks with a language for syntax highlighting.
|
| 61 |
-
|
| 62 |
-
```python
|
| 63 |
-
def greet(name: str) -> None:
|
| 64 |
-
print(f"Hello, {name}!")
|
| 65 |
-
|
| 66 |
-
greet("Astro")
|
| 67 |
-
```
|
| 68 |
-
|
| 69 |
-
<small className="muted">Example</small>
|
| 70 |
-
````mdx
|
| 71 |
-
```python
|
| 72 |
-
def greet(name: str) -> None:
|
| 73 |
-
print(f"Hello, {name}!")
|
| 74 |
-
|
| 75 |
-
greet("Astro")
|
| 76 |
-
```
|
| 77 |
-
````
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
### Citation and footnote
|
| 81 |
-
|
| 82 |
-
**Citations** use the `@` syntax (e.g., `[@vaswani2017attention]` or `@vaswani2017attention` in narrative form) and are **automatically** collected to render the **bibliography** at the end of the article. The citation keys come from `app/src/content/bibliography.bib`.
|
| 83 |
-
|
| 84 |
-
**Footnotes** use an identifier like `[^f1]` and a definition anywhere in the document, e.g., `[^f1]: Your explanation`. They are **numbered** and **listed automatically** at the end of the article.
|
| 85 |
-
|
| 86 |
-
Here are a few variations using the same bibliography:
|
| 87 |
-
|
| 88 |
-
1) **In-text citation** with brackets: [@example2023].
|
| 89 |
-
|
| 90 |
-
2) **Narrative citation**: As shown by @vaswani2017attention, transformers enable efficient sequence modeling.
|
| 91 |
-
|
| 92 |
-
3) **Multiple citations** and a **footnote** together: see [@vaswani2017attention; @example2023] for related work. Also note this footnote[^f1].
|
| 93 |
-
|
| 94 |
-
[^f1]: Footnote attached to the sentence above.
|
| 95 |
-
|
| 96 |
-
<small className="muted">Example</small>
|
| 97 |
-
```mdx
|
| 98 |
-
1) In-text citation with brackets: [@example2023].
|
| 99 |
-
|
| 100 |
-
2) Narrative citation: As shown by @vaswani2017attention, transformers enable efficient sequence modeling.
|
| 101 |
-
|
| 102 |
-
3) Multiple citations and a footnote together: see [@vaswani2017attention; @example2023] for related work. Also note this footnote[^f1].
|
| 103 |
-
|
| 104 |
-
[^f1]: Footnote attached to the sentence above.
|
| 105 |
-
```
|
| 106 |
-
|
| 107 |
-
|
| 108 |
### ResponsiveImage
|
| 109 |
|
| 110 |
**Responsive images** automatically generate an optimized `srcset` and `sizes` so the browser downloads the most appropriate file for the current viewport and DPR. You can also request multiple output formats (e.g., **AVIF**, **WebP**, fallback **PNG/JPEG**) and control **lazy loading/decoding** for better **performance**.
|
|
@@ -145,95 +64,58 @@ import myImage from './assets/images/placeholder.jpg'
|
|
| 145 |
```
|
| 146 |
|
| 147 |
|
|
|
|
| 148 |
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
Native mermaid diagrams are supported. You can use the <a target="_blank" href="https://mermaid.live/edit#pako:eNpVjUFPg0AQhf_KZk6a0AYsCywHE0u1lyZ66EnoYQMDSyy7ZFlSK_DfXWiMOqd58773ZoBcFQgxlGd1yQXXhhx3mSR2ntJE6LozDe9OZLV6HPdoSKMkXkeyvdsr0gnVtrWs7m_8doZIMhxmDIkRtfyYblay5F8ljmSXHnhrVHv66xwvaiTPaf0mbP1_R2i0qZe05HHJVznXJOF6QcCBStcFxEb36ECDuuGzhGF2MzACG8wgtmuBJe_PJoNMTjbWcvmuVPOT1KqvBNj6c2dV3xbc4K7mlea_CMoCdaJ6aSCm3lIB8QCfED94dM2o77ssjFzK3MiBq2WCNWUeiza-H26YvU8OfC0_3XVII9eLQuYFIaVBGEzfyTJ22g"> live editor</a> to create your diagram and copy the code to your article.
|
| 152 |
|
|
|
|
| 153 |
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
}
|
| 165 |
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
|
|
|
| 170 |
|
| 171 |
-
MODEL {
|
| 172 |
-
string id
|
| 173 |
-
string framework
|
| 174 |
-
}
|
| 175 |
|
| 176 |
-
|
| 177 |
-
string id
|
| 178 |
-
date startedAt
|
| 179 |
-
}
|
| 180 |
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
}
|
| 185 |
-
```
|
| 186 |
|
| 187 |
<small className="muted">Example</small>
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
erDiagram
|
| 191 |
-
DATASET ||--o{ SAMPLE : contains
|
| 192 |
-
RUN }o--o{ SAMPLE : uses
|
| 193 |
-
RUN ||--|| MODEL : trains
|
| 194 |
-
RUN ||--o{ METRIC : logs
|
| 195 |
-
|
| 196 |
-
DATASET {
|
| 197 |
-
string id
|
| 198 |
-
string name
|
| 199 |
-
}
|
| 200 |
-
|
| 201 |
-
SAMPLE {
|
| 202 |
-
string id
|
| 203 |
-
string uri
|
| 204 |
-
}
|
| 205 |
-
|
| 206 |
-
MODEL {
|
| 207 |
-
string id
|
| 208 |
-
string framework
|
| 209 |
-
}
|
| 210 |
-
|
| 211 |
-
RUN {
|
| 212 |
-
string id
|
| 213 |
-
date startedAt
|
| 214 |
-
}
|
| 215 |
-
|
| 216 |
-
METRIC {
|
| 217 |
-
string name
|
| 218 |
-
float value
|
| 219 |
-
}
|
| 220 |
-
```
|
| 221 |
-
````
|
| 222 |
-
|
| 223 |
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
| 225 |
|
| 226 |
-
|
| 227 |
|
| 228 |
-
|
|
|
|
|
|
|
| 229 |
|
| 230 |
<small className="muted">Example</small>
|
| 231 |
```mdx
|
| 232 |
-
|
| 233 |
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
```
|
| 238 |
|
| 239 |
|
|
@@ -287,7 +169,8 @@ Small contextual callout for tips, caveats, or emphasis.
|
|
| 287 |
| `title` | No | string | Short title displayed in header |
|
| 288 |
| `emoji` | No | string | Emoji displayed before the title |
|
| 289 |
| `class` | No | string | Extra classes for custom styling |
|
| 290 |
-
| `variant`| No | 'info' | 'success' | 'danger' | Visual intent of the note
|
|
|
|
| 291 |
|
| 292 |
<Note title="Heads‑up" emoji="💡" variant="info">
|
| 293 |
Use notes to surface context without breaking reading flow.
|
|
@@ -327,35 +210,6 @@ import Note from '../../components/Note.astro'
|
|
| 327 |
```
|
| 328 |
|
| 329 |
|
| 330 |
-
### Minimal table
|
| 331 |
-
|
| 332 |
-
| Method | Score |
|
| 333 |
-
|---|---|
|
| 334 |
-
| A | 0.78 |
|
| 335 |
-
| B | 0.86 |
|
| 336 |
-
|
| 337 |
-
<small className="muted">Example</small>
|
| 338 |
-
```mdx
|
| 339 |
-
| Method | Score |
|
| 340 |
-
| --- | --- |
|
| 341 |
-
| A | 0.78 |
|
| 342 |
-
| B | 0.86 |
|
| 343 |
-
```
|
| 344 |
-
|
| 345 |
-
### Audio
|
| 346 |
-
|
| 347 |
-
<audio controls src={audioDemo}/>
|
| 348 |
-
<br/>
|
| 349 |
-
<small className="muted">Example</small>
|
| 350 |
-
```mdx
|
| 351 |
-
import audioDemo from './assets/audio/audio-example.wav'
|
| 352 |
-
|
| 353 |
-
<audio controls src={audioDemo}/>
|
| 354 |
-
```
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
### HtmlEmbed
|
| 360 |
|
| 361 |
The main purpose of the ```HtmlEmbed``` component is to **embed** a **Plotly** or **D3.js** chart in your article. **Libraries** are already imported in the template.
|
|
|
|
| 9 |
import Accordion from '../../components/Accordion.astro';
|
| 10 |
import ResponsiveImage from '../../components/ResponsiveImage.astro';
|
| 11 |
|
| 12 |
+
## Components
|
| 13 |
|
| 14 |
**All** the following **components** are available in the **article.mdx** file. You can also create your own **components** by creating a new file in the `/components` folder.
|
| 15 |
+
You have to import them in the **.mdx** file you want to use them in.
|
| 16 |
|
| 17 |
<br/>
|
| 18 |
<div className="button-group">
|
|
|
|
|
|
|
|
|
|
| 19 |
<a className="button" href="#responsiveimage">ResponsiveImage</a>
|
|
|
|
| 20 |
<a className="button" href="#placement">Placement</a>
|
| 21 |
<a className="button" href="#accordion">Accordion</a>
|
| 22 |
<a className="button" href="#note">Note</a>
|
| 23 |
+
<a className="button" href="#htmlembed">HtmlEmbed</a>
|
| 24 |
+
<a className="button" href="#iframe">Iframe</a>
|
|
|
|
| 25 |
</div>
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
### ResponsiveImage
|
| 28 |
|
| 29 |
**Responsive images** automatically generate an optimized `srcset` and `sizes` so the browser downloads the most appropriate file for the current viewport and DPR. You can also request multiple output formats (e.g., **AVIF**, **WebP**, fallback **PNG/JPEG**) and control **lazy loading/decoding** for better **performance**.
|
|
|
|
| 64 |
```
|
| 65 |
|
| 66 |
|
| 67 |
+
### Placement
|
| 68 |
|
| 69 |
+
Use these helpers when you need to step outside the main content flow: **Sidenotes** for contextual side notes, **Wide** to extend beyond the main column, and **Full-width** for full-width, immersive sections.
|
|
|
|
|
|
|
| 70 |
|
| 71 |
+
#### Sidenotes
|
| 72 |
|
| 73 |
+
<Sidenote>
|
| 74 |
+
This paragraph presents a **key idea** concisely.
|
| 75 |
+
<Fragment slot="aside">
|
| 76 |
+
**Side note** for brief context or a definition.
|
| 77 |
+
</Fragment>
|
| 78 |
+
</Sidenote>
|
| 79 |
|
| 80 |
+
<small className="muted">Example</small>
|
| 81 |
+
```mdx
|
| 82 |
+
import Sidenote from '../components/Sidenote.astro'
|
|
|
|
| 83 |
|
| 84 |
+
<Sidenote>
|
| 85 |
+
Main paragraph with the core idea.
|
| 86 |
+
<Fragment slot="aside">Short side note.</Fragment>
|
| 87 |
+
</Sidenote>
|
| 88 |
+
```
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
+
#### Wide example
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
+
<Wide>
|
| 94 |
+
<div className="demo-wide">demo wide</div>
|
| 95 |
+
</Wide>
|
|
|
|
|
|
|
| 96 |
|
| 97 |
<small className="muted">Example</small>
|
| 98 |
+
```mdx
|
| 99 |
+
import Wide from '../components/Wide.astro'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
+
<Wide>
|
| 102 |
+
Your content here...
|
| 103 |
+
</Wide>
|
| 104 |
+
```
|
| 105 |
|
| 106 |
+
#### Full-width example
|
| 107 |
|
| 108 |
+
<FullWidth>
|
| 109 |
+
<div className="demo-full-width">demo full-width</div>
|
| 110 |
+
</FullWidth>
|
| 111 |
|
| 112 |
<small className="muted">Example</small>
|
| 113 |
```mdx
|
| 114 |
+
import FullWidth from '../components/FullWidth.astro'
|
| 115 |
|
| 116 |
+
<FullWidth>
|
| 117 |
+
Your content here...
|
| 118 |
+
</FullWidth>
|
| 119 |
```
|
| 120 |
|
| 121 |
|
|
|
|
| 169 |
| `title` | No | string | Short title displayed in header |
|
| 170 |
| `emoji` | No | string | Emoji displayed before the title |
|
| 171 |
| `class` | No | string | Extra classes for custom styling |
|
| 172 |
+
| `variant`| No | 'info' | 'success' | 'danger' | Visual intent of the note |
|
| 173 |
+
|
| 174 |
|
| 175 |
<Note title="Heads‑up" emoji="💡" variant="info">
|
| 176 |
Use notes to surface context without breaking reading flow.
|
|
|
|
| 210 |
```
|
| 211 |
|
| 212 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
### HtmlEmbed
|
| 214 |
|
| 215 |
The main purpose of the ```HtmlEmbed``` component is to **embed** a **Plotly** or **D3.js** chart in your article. **Libraries** are already imported in the template.
|
app/src/content/chapters/debug-components.mdx
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Accordion from '../../components/Accordion.astro';
|
| 2 |
+
import HtmlEmbed from '../../components/HtmlEmbed.astro';
|
| 3 |
+
import ResponsiveImage from '../../components/ResponsiveImage.astro';
|
| 4 |
+
import Wide from '../../components/Wide.astro';
|
| 5 |
+
import FullWidth from '../../components/FullWidth.astro';
|
| 6 |
+
import Note from '../../components/Note.astro';
|
| 7 |
+
|
| 8 |
+
| Prop | Required |
|
| 9 |
+
|------------------------|----------|
|
| 10 |
+
| `zoomable` | No |
|
| 11 |
+
| `downloadable` | No |
|
| 12 |
+
| `loading="lazy"` | No |
|
| 13 |
+
| `caption` | No |
|
| 14 |
+
|
| 15 |
+
<Accordion title="table">
|
| 16 |
+
| Prop | Required | Description
|
| 17 |
+
|-------------|----------|----------------------------------------------------------------------------------
|
| 18 |
+
| `src` | Yes | Path to the embed file in the `embeds` folder.
|
| 19 |
+
| `title` | No | Short title displayed above the card.
|
| 20 |
+
| `desc` | No | Short description displayed below the card. Supports inline HTML (e.g., links).
|
| 21 |
+
| `frameless` | No | Removes the card background and border for seamless embeds.
|
| 22 |
+
| `align` | No | Aligns the title/description text. One of `left` (default), `center`, `right`.
|
| 23 |
+
</Accordion>
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
<Accordion title="simple">
|
| 27 |
+
<p>Simple example</p>
|
| 28 |
+
</Accordion>
|
| 29 |
+
|
| 30 |
+
<Accordion title="code">
|
| 31 |
+
```mdx
|
| 32 |
+
import HtmlEmbed from '../components/HtmlEmbed.astro'
|
| 33 |
+
|
| 34 |
+
<HtmlEmbed src="plotly-line.html" title="Plotly Line" desc="Some chart description" />
|
| 35 |
+
<HtmlEmbed src="d3-line-example.html" title="D3 Line" desc="Some chart description" />
|
| 36 |
+
```
|
| 37 |
+
</Accordion>
|
app/src/content/chapters/getting-started.mdx
CHANGED
|
@@ -47,6 +47,8 @@ npm install
|
|
| 47 |
|
| 48 |
<br/>And that's it!
|
| 49 |
|
|
|
|
|
|
|
| 50 |
### Development
|
| 51 |
|
| 52 |
```bash
|
|
|
|
| 47 |
|
| 48 |
<br/>And that's it!
|
| 49 |
|
| 50 |
+
**You're ready to go!** 🎉
|
| 51 |
+
|
| 52 |
### Development
|
| 53 |
|
| 54 |
```bash
|
app/src/content/chapters/introduction.mdx
CHANGED
|
@@ -13,24 +13,24 @@ import Sidenote from "../../components/Sidenote.astro";
|
|
| 13 |
|
| 14 |
<Sidenote>
|
| 15 |
<div className="tag-list">
|
| 16 |
-
<span className="tag">Markdown
|
| 17 |
<span className="tag">KaTeX math</span>
|
| 18 |
<span className="tag">Syntax highlighting</span>
|
| 19 |
<span className="tag">Citations & footnotes</span>
|
| 20 |
-
<span className="tag">Automatic build</span>
|
| 21 |
<span className="tag">Table of contents</span>
|
| 22 |
-
<span className="tag">
|
| 23 |
-
<span className="tag">HTML Embeds</span>
|
| 24 |
<span className="tag">Plotly ready</span>
|
| 25 |
<span className="tag">D3.js ready</span>
|
| 26 |
-
<span className="tag">
|
| 27 |
-
<span className="tag">
|
| 28 |
-
<span className="tag">
|
| 29 |
-
<span className="tag">Mobile friendly</span>
|
| 30 |
<span className="tag">Optimized images</span>
|
|
|
|
|
|
|
|
|
|
| 31 |
<span className="tag">Automatic PDF export</span>
|
| 32 |
-
<span className="tag">
|
| 33 |
-
<span className="tag">
|
| 34 |
</div>
|
| 35 |
<Fragment slot="aside">
|
| 36 |
If you have questions, remarks or suggestions, open a discussion on the <a href="https://huggingface.co/spaces/tfrere/research-blog-template/discussions?status=open&type=discussion">Community tab</a>!
|
|
@@ -42,9 +42,11 @@ The web enables what static PDFs can’t: **interactive diagrams**, **progressiv
|
|
| 42 |
|
| 43 |
If you write technical blogs or research notes, you’ll benefit from a **ready‑to‑publish workflow** with clear notation, optimized media, and **automatic builds**.
|
| 44 |
|
| 45 |
-
|
| 46 |
|
| 47 |
<Sidenote>
|
|
|
|
|
|
|
| 48 |
To give you a sense of what inspired this template, here is a short, curated list of well‑designed and often interactive works from Distill:
|
| 49 |
|
| 50 |
- [Growing Neural Cellular Automata](https://distill.pub/2020/growing-ca/)
|
|
|
|
| 13 |
|
| 14 |
<Sidenote>
|
| 15 |
<div className="tag-list">
|
| 16 |
+
<span className="tag">Markdown-based</span>
|
| 17 |
<span className="tag">KaTeX math</span>
|
| 18 |
<span className="tag">Syntax highlighting</span>
|
| 19 |
<span className="tag">Citations & footnotes</span>
|
|
|
|
| 20 |
<span className="tag">Table of contents</span>
|
| 21 |
+
<span className="tag">Mermaid diagrams</span>
|
|
|
|
| 22 |
<span className="tag">Plotly ready</span>
|
| 23 |
<span className="tag">D3.js ready</span>
|
| 24 |
+
<span className="tag">HTML embeds</span>
|
| 25 |
+
<span className="tag">Gradio app embeds</span>
|
| 26 |
+
<span className="tag">Dataviz color palettes</span>
|
|
|
|
| 27 |
<span className="tag">Optimized images</span>
|
| 28 |
+
<span className="tag">Lightweight bundle</span>
|
| 29 |
+
<span className="tag">SEO-friendly</span>
|
| 30 |
+
<span className="tag">Automatic build</span>
|
| 31 |
<span className="tag">Automatic PDF export</span>
|
| 32 |
+
<span className="tag">Dark theme</span>
|
| 33 |
+
<span className="tag">Mobile friendly</span>
|
| 34 |
</div>
|
| 35 |
<Fragment slot="aside">
|
| 36 |
If you have questions, remarks or suggestions, open a discussion on the <a href="https://huggingface.co/spaces/tfrere/research-blog-template/discussions?status=open&type=discussion">Community tab</a>!
|
|
|
|
| 42 |
|
| 43 |
If you write technical blogs or research notes, you’ll benefit from a **ready‑to‑publish workflow** with clear notation, optimized media, and **automatic builds**.
|
| 44 |
|
| 45 |
+
### Inspired by Distill
|
| 46 |
|
| 47 |
<Sidenote>
|
| 48 |
+
This project draws strong inspiration from [**Distill**](https://distill.pub) (2016–2021), which championed clear, web‑native scholarship and set a high standard for interactive scientific communication.
|
| 49 |
+
|
| 50 |
To give you a sense of what inspired this template, here is a short, curated list of well‑designed and often interactive works from Distill:
|
| 51 |
|
| 52 |
- [Growing Neural Cellular Automata](https://distill.pub/2020/growing-ca/)
|
app/src/content/chapters/markdown.mdx
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Image } from 'astro:assets';
|
| 2 |
+
import placeholder from '../assets/images/placeholder.png';
|
| 3 |
+
import audioDemo from '../assets/audio/audio-example.wav';
|
| 4 |
+
import HtmlEmbed from '../../components/HtmlEmbed.astro';
|
| 5 |
+
import Sidenote from '../../components/Sidenote.astro';
|
| 6 |
+
import Wide from '../../components/Wide.astro';
|
| 7 |
+
import Note from '../../components/Note.astro';
|
| 8 |
+
import FullWidth from '../../components/FullWidth.astro';
|
| 9 |
+
import Accordion from '../../components/Accordion.astro';
|
| 10 |
+
import ResponsiveImage from '../../components/ResponsiveImage.astro';
|
| 11 |
+
|
| 12 |
+
## Markdown
|
| 13 |
+
|
| 14 |
+
All the following **markdown features** are available **natively** in the `article.mdx` file. See also the complete [**Markdown documentation**](https://www.markdownguide.org/basic-syntax/).
|
| 15 |
+
|
| 16 |
+
<br/>
|
| 17 |
+
<div className="button-group">
|
| 18 |
+
<a className="button" href="#math">Math</a>
|
| 19 |
+
<a className="button" href="#code-blocks">Code</a>
|
| 20 |
+
<a className="button" href="#citations-and-notes">Citations</a>
|
| 21 |
+
<a className="button" href="#footnotes">Footnotes</a>
|
| 22 |
+
<a className="button" href="#mermaid-diagrams">Mermaid</a>
|
| 23 |
+
<a className="button" href="#separator">Separator</a>
|
| 24 |
+
<a className="button" href="#table">Table</a>
|
| 25 |
+
<a className="button" href="#audio">Audio</a>
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
### Math
|
| 29 |
+
|
| 30 |
+
KaTeX is used for math rendering.
|
| 31 |
+
|
| 32 |
+
**Inline**
|
| 33 |
+
|
| 34 |
+
This is an inline math equation: $x^2 + y^2 = z^2$.
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
<small className="muted">Example</small>
|
| 38 |
+
```mdx
|
| 39 |
+
$x^2 + y^2 = z^2$
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
**Block**
|
| 43 |
+
|
| 44 |
+
$$
|
| 45 |
+
\mathrm{Attention}(Q,K,V)=\mathrm{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V
|
| 46 |
+
$$
|
| 47 |
+
|
| 48 |
+
<small className="muted">Example</small>
|
| 49 |
+
```mdx
|
| 50 |
+
$$
|
| 51 |
+
\mathrm{Attention}(Q,K,V)=\mathrm{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V
|
| 52 |
+
$$
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
### Code
|
| 56 |
+
|
| 57 |
+
Use fenced code blocks with a language for syntax highlighting.
|
| 58 |
+
|
| 59 |
+
```python
|
| 60 |
+
def greet(name: str) -> None:
|
| 61 |
+
print(f"Hello, {name}!")
|
| 62 |
+
|
| 63 |
+
greet("Astro")
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
<small className="muted">Example</small>
|
| 67 |
+
````mdx
|
| 68 |
+
```python
|
| 69 |
+
def greet(name: str) -> None:
|
| 70 |
+
print(f"Hello, {name}!")
|
| 71 |
+
|
| 72 |
+
greet("Astro")
|
| 73 |
+
```
|
| 74 |
+
````
|
| 75 |
+
|
| 76 |
+
### Citations
|
| 77 |
+
|
| 78 |
+
**Citations** use the `@` syntax (e.g., `[@vaswani2017attention]` or `@vaswani2017attention` in narrative form) and are **automatically** collected to render the **bibliography** at the end of the article. The citation keys come from `app/src/content/bibliography.bib`.
|
| 79 |
+
|
| 80 |
+
1) In-text citation with brackets: [@vaswani2017attention].
|
| 81 |
+
|
| 82 |
+
2) Narrative citation: As shown by @kingma2015adam, stochastic optimization is widely used.
|
| 83 |
+
|
| 84 |
+
3) Multiple citations and a footnote together: see [@mckinney2017python; @he2016resnet] for related work.
|
| 85 |
+
|
| 86 |
+
4) All citations in one group: [@vaswani2017attention; @mckinney2017python; @he2016resnet; @silver2017mastering; @openai2023gpt4; @doe2020thesis; @cover2006entropy; @zenodo2021dataset; @sklearn2024; @smith2024privacy; @kingma2015adam; @raffel2020t5].
|
| 87 |
+
|
| 88 |
+
<small className="muted">Example</small>
|
| 89 |
+
```mdx
|
| 90 |
+
1) In-text citation with brackets: [@vaswani2017attention].
|
| 91 |
+
|
| 92 |
+
2) Narrative citation: As shown by @kingma2015adam, stochastic optimization is widely used.
|
| 93 |
+
|
| 94 |
+
3) Multiple citations and a footnote together: see [@mckinney2017python; @he2016resnet] for related work.
|
| 95 |
+
|
| 96 |
+
4) All citations in one group: [@vaswani2017attention; @mckinney2017python; @he2016resnet; @silver2017mastering; @openai2023gpt4; @doe2020thesis; @cover2006entropy; @zenodo2021dataset; @sklearn2024; @smith2024privacy; @kingma2015adam; @raffel2020t5].
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
### Footnotes
|
| 100 |
+
|
| 101 |
+
**Footnotes** use an identifier like `[^f1]` and a definition anywhere in the document, e.g., `[^f1]: Your explanation`. They are **numbered** and **listed automatically** at the end of the article.
|
| 102 |
+
|
| 103 |
+
1) Footnote attached to the sentence above[^f1].
|
| 104 |
+
|
| 105 |
+
[^f1]: Footnote attached to the sentence above.
|
| 106 |
+
|
| 107 |
+
2) Multi-paragraph footnote example[^f2].
|
| 108 |
+
|
| 109 |
+
[^f2]: Multi-paragraph footnote. First paragraph.
|
| 110 |
+
|
| 111 |
+
Second paragraph with a link to [Astro](https://astro.build).
|
| 112 |
+
|
| 113 |
+
2) Footnote containing a list[^f3].
|
| 114 |
+
|
| 115 |
+
[^f3]: Footnote with a list:
|
| 116 |
+
|
| 117 |
+
- First item
|
| 118 |
+
- Second item
|
| 119 |
+
|
| 120 |
+
3) Footnote with an inline code and an indented code block[^f4].
|
| 121 |
+
|
| 122 |
+
[^f4]: Footnote with code snippet:
|
| 123 |
+
|
| 124 |
+
```ts
|
| 125 |
+
function add(a: number, b: number) {
|
| 126 |
+
return a + b;
|
| 127 |
+
}
|
| 128 |
+
```
|
| 129 |
+
Result: `add(2, 3) === 5`.
|
| 130 |
+
|
| 131 |
+
4) Footnote that includes citations inside[^f5] and another footnote[^f1].
|
| 132 |
+
|
| 133 |
+
[^f5]: Footnote containing citations [@vaswani2017attention] and [@kingma2015adam].
|
| 134 |
+
|
| 135 |
+
<small className="muted">Example</small>
|
| 136 |
+
```mdx
|
| 137 |
+
1) Footnote attached to the sentence above[^f1].
|
| 138 |
+
|
| 139 |
+
2) Multi-paragraph footnote example[^f2].
|
| 140 |
+
|
| 141 |
+
2) Footnote containing a list[^f3].
|
| 142 |
+
|
| 143 |
+
3) Footnote with an inline code and an indented code block[^f4].
|
| 144 |
+
|
| 145 |
+
4) Footnote that includes citations inside[^f5].
|
| 146 |
+
|
| 147 |
+
[^f1]: Footnote attached to the sentence above.
|
| 148 |
+
|
| 149 |
+
[^f2]: Multi-paragraph footnote. First paragraph.
|
| 150 |
+
|
| 151 |
+
Second paragraph with a link to [Astro](https://astro.build).
|
| 152 |
+
|
| 153 |
+
[^f3]: Footnote with a list:
|
| 154 |
+
|
| 155 |
+
- First item
|
| 156 |
+
- Second item
|
| 157 |
+
|
| 158 |
+
[^f4]: Footnote with code snippet:
|
| 159 |
+
|
| 160 |
+
function add(a: number, b: number) {
|
| 161 |
+
return a + b;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
Result: `add(2, 3) === 5`.
|
| 165 |
+
|
| 166 |
+
[^f5]: Footnote containing citations [@vaswani2017attention] and [@kingma2015adam].
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
### Mermaid diagram
|
| 171 |
+
|
| 172 |
+
Native mermaid diagrams are supported. You can use the <a target="_blank" href="https://mermaid.live/edit#pako:eNpVjUFPg0AQhf_KZk6a0AYsCywHE0u1lyZ66EnoYQMDSyy7ZFlSK_DfXWiMOqd58773ZoBcFQgxlGd1yQXXhhx3mSR2ntJE6LozDe9OZLV6HPdoSKMkXkeyvdsr0gnVtrWs7m_8doZIMhxmDIkRtfyYblay5F8ljmSXHnhrVHv66xwvaiTPaf0mbP1_R2i0qZe05HHJVznXJOF6QcCBStcFxEb36ECDuuGzhGF2MzACG8wgtmuBJe_PJoNMTjbWcvmuVPOT1KqvBNj6c2dV3xbc4K7mlea_CMoCdaJ6aSCm3lIB8QCfED94dM2o77ssjFzK3MiBq2WCNWUeiza-H26YvU8OfC0_3XVII9eLQuYFIaVBGEzfyTJ22g"> live editor</a> to create your diagram and copy the code to your article.
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
```mermaid
|
| 176 |
+
erDiagram
|
| 177 |
+
DATASET ||--o{ SAMPLE : contains
|
| 178 |
+
RUN }o--o{ SAMPLE : uses
|
| 179 |
+
RUN ||--|| MODEL : trains
|
| 180 |
+
RUN ||--o{ METRIC : logs
|
| 181 |
+
|
| 182 |
+
DATASET {
|
| 183 |
+
string id
|
| 184 |
+
string name
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
SAMPLE {
|
| 188 |
+
string id
|
| 189 |
+
string uri
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
MODEL {
|
| 193 |
+
string id
|
| 194 |
+
string framework
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
RUN {
|
| 198 |
+
string id
|
| 199 |
+
date startedAt
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
METRIC {
|
| 203 |
+
string name
|
| 204 |
+
float value
|
| 205 |
+
}
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
<small className="muted">Example</small>
|
| 209 |
+
````mdx
|
| 210 |
+
```mermaid
|
| 211 |
+
erDiagram
|
| 212 |
+
DATASET ||--o{ SAMPLE : contains
|
| 213 |
+
RUN }o--o{ SAMPLE : uses
|
| 214 |
+
RUN ||--|| MODEL : trains
|
| 215 |
+
RUN ||--o{ METRIC : logs
|
| 216 |
+
|
| 217 |
+
DATASET {
|
| 218 |
+
string id
|
| 219 |
+
string name
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
SAMPLE {
|
| 223 |
+
string id
|
| 224 |
+
string uri
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
MODEL {
|
| 228 |
+
string id
|
| 229 |
+
string framework
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
RUN {
|
| 233 |
+
string id
|
| 234 |
+
date startedAt
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
METRIC {
|
| 238 |
+
string name
|
| 239 |
+
float value
|
| 240 |
+
}
|
| 241 |
+
```
|
| 242 |
+
````
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
### Separator
|
| 246 |
+
|
| 247 |
+
Use `---` on its own line to insert a horizontal separator between sections. This is a standard Markdown “thematic break”. Don’t confuse it with the `---` used at the very top of the file to delimit the frontmatter.
|
| 248 |
+
|
| 249 |
+
---
|
| 250 |
+
|
| 251 |
+
<small className="muted">Example</small>
|
| 252 |
+
```mdx
|
| 253 |
+
Intro paragraph.
|
| 254 |
+
|
| 255 |
+
---
|
| 256 |
+
|
| 257 |
+
Next section begins here.
|
| 258 |
+
```
|
| 259 |
+
|
| 260 |
+
### Table
|
| 261 |
+
|
| 262 |
+
| Method | Score |
|
| 263 |
+
|---|---|
|
| 264 |
+
| A | 0.78 |
|
| 265 |
+
| B | 0.86 |
|
| 266 |
+
|
| 267 |
+
<small className="muted">Example</small>
|
| 268 |
+
```mdx
|
| 269 |
+
| Method | Score |
|
| 270 |
+
| --- | --- |
|
| 271 |
+
| A | 0.78 |
|
| 272 |
+
| B | 0.86 |
|
| 273 |
+
```
|
| 274 |
+
|
| 275 |
+
### Audio
|
| 276 |
+
|
| 277 |
+
<audio controls src={audioDemo}/>
|
| 278 |
+
<br/>
|
| 279 |
+
<small className="muted">Example</small>
|
| 280 |
+
```mdx
|
| 281 |
+
import audioDemo from './assets/audio/audio-example.wav'
|
| 282 |
+
|
| 283 |
+
<audio controls src={audioDemo}/>
|
| 284 |
+
```
|
| 285 |
+
|
app/src/content/chapters/writing-your-content.mdx
CHANGED
|
@@ -8,7 +8,7 @@ import FullWidth from '../../components/FullWidth.astro';
|
|
| 8 |
import HtmlEmbed from '../../components/HtmlEmbed.astro';
|
| 9 |
import audioDemo from '../assets/audio/audio-example.wav';
|
| 10 |
|
| 11 |
-
## Writing
|
| 12 |
|
| 13 |
### **Introduction**
|
| 14 |
|
|
@@ -21,62 +21,59 @@ Everything is **self-contained** under `app/src/content/`:
|
|
| 21 |
|
| 22 |
The `article.mdx` file is the **main entry point** of your article.
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
<small className="muted">app/src/content/article.mdx</small>
|
| 25 |
```mdx
|
| 26 |
-
{/*
|
| 27 |
---
|
|
|
|
| 28 |
title: "This is the main title"
|
| 29 |
subtitle: "This will be displayed just below the banner"
|
| 30 |
description: "A modern, MDX-first research article template with math, citations, and interactive figures."
|
| 31 |
-
authors:
|
| 32 |
-
- "Thibaud Frere"
|
| 33 |
-
- "Alice Martin"
|
| 34 |
-
- "Robert Brown"
|
| 35 |
-
affiliation: "Hugging Face"
|
| 36 |
published: "Feb 19, 2025"
|
| 37 |
tags:
|
| 38 |
- research
|
| 39 |
- template
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
{/*
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
---
|
| 45 |
|
| 46 |
-
{/*
|
| 47 |
-
import
|
| 48 |
-
import
|
| 49 |
-
|
| 50 |
-
{/* CONTENT */}
|
| 51 |
-
# Hello, world
|
| 52 |
-
|
| 53 |
-
This is a short paragraph written in Markdown. Below is an example image:
|
| 54 |
-
|
| 55 |
-
<Image src={placeholder} alt="Example image" />
|
| 56 |
-
```
|
| 57 |
-
|
| 58 |
-
### MDX
|
| 59 |
-
|
| 60 |
-
**MDX** is a mix of **Markdown** and **HTML/JSX**: write regular Markdown, and embed **interactive components** inline when needed. We’ll describe the available components you can use later in this guide.
|
| 61 |
-
|
| 62 |
-
For Markdown syntax, see the complete [**Markdown documentation**](https://www.markdownguide.org/basic-syntax/).
|
| 63 |
-
|
| 64 |
-
<small className="muted">Example</small>
|
| 65 |
-
```mdx
|
| 66 |
-
{/* IMPORTS */}
|
| 67 |
-
import { Image } from 'astro:assets'
|
| 68 |
-
import placeholder from './assets/images/placeholder.png'
|
| 69 |
import Sidenote from '../components/Sidenote.astro'
|
| 70 |
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
This paragraph is written in Markdown.
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
Below is an image imported via Astro and optimized at build time:
|
| 78 |
|
| 79 |
-
|
| 80 |
```
|
| 81 |
|
| 82 |
|
|
@@ -102,6 +99,8 @@ import MyChapter from './chapters/my-chapter.mdx';
|
|
| 102 |
|
| 103 |
The **Table of contents** is generated **automatically** from your **H2–H4** headings. Keep headings **short** and **descriptive**; links work on **desktop** and **mobile**.
|
| 104 |
|
|
|
|
|
|
|
| 105 |
### Theme
|
| 106 |
|
| 107 |
All **interactive elements** (buttons, inputs, cards, etc.) are themed with the **primary color** you choose.
|
|
@@ -167,57 +166,3 @@ document.documentElement.style.setProperty('--palette-count', '8');
|
|
| 167 |
document.documentElement.style.setProperty('--palette-diverging-count', '7');
|
| 168 |
window.ColorPalettes.refresh();
|
| 169 |
```
|
| 170 |
-
|
| 171 |
-
### Placement
|
| 172 |
-
|
| 173 |
-
Use these helpers when you need to step outside the main content flow: **Sidenotes** for contextual side notes, **Wide** to extend beyond the main column, and **Full-width** for full-width, immersive sections.
|
| 174 |
-
|
| 175 |
-
#### Sidenotes
|
| 176 |
-
|
| 177 |
-
<Sidenote>
|
| 178 |
-
This paragraph presents a **key idea** concisely.
|
| 179 |
-
<Fragment slot="aside">
|
| 180 |
-
**Side note** for brief context or a definition.
|
| 181 |
-
</Fragment>
|
| 182 |
-
</Sidenote>
|
| 183 |
-
|
| 184 |
-
<small className="muted">Example</small>
|
| 185 |
-
```mdx
|
| 186 |
-
import Sidenote from '../components/Sidenote.astro'
|
| 187 |
-
|
| 188 |
-
<Sidenote>
|
| 189 |
-
Main paragraph with the core idea.
|
| 190 |
-
<Fragment slot="aside">Short side note.</Fragment>
|
| 191 |
-
</Sidenote>
|
| 192 |
-
```
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
#### Wide example
|
| 196 |
-
|
| 197 |
-
<Wide>
|
| 198 |
-
<div className="demo-wide">demo wide</div>
|
| 199 |
-
</Wide>
|
| 200 |
-
|
| 201 |
-
<small className="muted">Example</small>
|
| 202 |
-
```mdx
|
| 203 |
-
import Wide from '../components/Wide.astro'
|
| 204 |
-
|
| 205 |
-
<Wide>
|
| 206 |
-
Your content here...
|
| 207 |
-
</Wide>
|
| 208 |
-
```
|
| 209 |
-
|
| 210 |
-
#### Full-width example
|
| 211 |
-
|
| 212 |
-
<FullWidth>
|
| 213 |
-
<div className="demo-full-width">demo full-width</div>
|
| 214 |
-
</FullWidth>
|
| 215 |
-
|
| 216 |
-
<small className="muted">Example</small>
|
| 217 |
-
```mdx
|
| 218 |
-
import FullWidth from '../components/FullWidth.astro'
|
| 219 |
-
|
| 220 |
-
<FullWidth>
|
| 221 |
-
Your content here...
|
| 222 |
-
</FullWidth>
|
| 223 |
-
```
|
|
|
|
| 8 |
import HtmlEmbed from '../../components/HtmlEmbed.astro';
|
| 9 |
import audioDemo from '../assets/audio/audio-example.wav';
|
| 10 |
|
| 11 |
+
## Writing your content
|
| 12 |
|
| 13 |
### **Introduction**
|
| 14 |
|
|
|
|
| 21 |
|
| 22 |
The `article.mdx` file is the **main entry point** of your article.
|
| 23 |
|
| 24 |
+
**MDX** is a mix of **Markdown** and **HTML/JSX**: write regular Markdown, and embed **interactive components** inline when needed. We’ll describe the available options you can use later in this guide.
|
| 25 |
+
|
| 26 |
+
Here's what the file looks like in detail:
|
| 27 |
+
|
| 28 |
<small className="muted">app/src/content/article.mdx</small>
|
| 29 |
```mdx
|
| 30 |
+
{/* -- Header ---------------------------- */}
|
| 31 |
---
|
| 32 |
+
{/* ---- Required parameters ------------- */}
|
| 33 |
title: "This is the main title"
|
| 34 |
subtitle: "This will be displayed just below the banner"
|
| 35 |
description: "A modern, MDX-first research article template with math, citations, and interactive figures."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
published: "Feb 19, 2025"
|
| 37 |
tags:
|
| 38 |
- research
|
| 39 |
- template
|
| 40 |
+
authors:
|
| 41 |
+
- name: "Thibaud Frere" {/* Author name */}
|
| 42 |
+
url: "https://huggingface.co/tfrere" {/* Optional author url */}
|
| 43 |
+
affiliations: [1] {/* Optional author affiliations based on the list below */}
|
| 44 |
+
- name: "Alice Martin"
|
| 45 |
+
url: "https://example.com/~alice"
|
| 46 |
+
affiliations: [1, 2]
|
| 47 |
+
- name: "Robert Brown"
|
| 48 |
+
url: "https://example.com/~bob"
|
| 49 |
+
affiliations: [2]
|
| 50 |
+
|
| 51 |
+
{/* ---- Optional parameters ------------- */}
|
| 52 |
+
affiliations: {/* Affiliations list */}
|
| 53 |
+
- name: "Hugging Face"
|
| 54 |
+
url: "https://huggingface.co"
|
| 55 |
+
- name: "Example University"
|
| 56 |
+
url: "https://example.edu"
|
| 57 |
+
doi: 10.1234/abcd.efgh {/* Add a DOI to your article */}
|
| 58 |
+
{/* Add a licence text to the end of your article */}
|
| 59 |
+
licence: Diagrams and text are licensed under <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener noreferrer">CC‑BY 4.0</a> with the source available on <a href="https://huggingface.co/spaces/stfrere/research-article-template">Hugging Face</a>, unless noted otherwise. Figures reused from other sources are excluded and marked in their captions (“Figure from …”).
|
| 60 |
+
seoThumbImage: "https://example.com/thumb.png" {/* Override default thumbnail */}
|
| 61 |
+
tableOfContentsAutoCollapse: true {/* By default the table of contents is not collapsing */}
|
| 62 |
---
|
| 63 |
|
| 64 |
+
{/* -- Imports -------------------------- */}
|
| 65 |
+
import placeholder from '../assets/images/placeholder.png'
|
| 66 |
+
import ResponsiveImage from '../components/ResponsiveImage.astro'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
import Sidenote from '../components/Sidenote.astro'
|
| 68 |
|
| 69 |
+
{/* -- Markdown content ------------------ */}
|
| 70 |
+
<Sidenote>
|
| 71 |
+
This paragraph is written in Markdown.
|
| 72 |
+
<Fragment slot="aside">A short callout inserted via a component.</Fragment>
|
| 73 |
+
</Sidenote>
|
| 74 |
+
<ResponsiveImage src={placeholder} alt="Sample image with optimization" />
|
|
|
|
| 75 |
|
| 76 |
+
This paragraph is also written in Markdown.
|
| 77 |
```
|
| 78 |
|
| 79 |
|
|
|
|
| 99 |
|
| 100 |
The **Table of contents** is generated **automatically** from your **H2–H4** headings. Keep headings **short** and **descriptive**; links work on **desktop** and **mobile**.
|
| 101 |
|
| 102 |
+
<Note variant="info">You can make the table of contents collapse by changing the `tableOfContentsAutoCollapse` parameter in the frontmatter. Which is `true` by default.</Note>
|
| 103 |
+
|
| 104 |
### Theme
|
| 105 |
|
| 106 |
All **interactive elements** (buttons, inputs, cards, etc.) are themed with the **primary color** you choose.
|
|
|
|
| 166 |
document.documentElement.style.setProperty('--palette-diverging-count', '7');
|
| 167 |
window.ColorPalettes.refresh();
|
| 168 |
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/src/pages/index.astro
CHANGED
|
@@ -4,7 +4,7 @@ import Hero from '../components/Hero.astro';
|
|
| 4 |
import Footer from '../components/Footer.astro';
|
| 5 |
import ThemeToggle from '../components/ThemeToggle.astro';
|
| 6 |
import Seo from '../components/Seo.astro';
|
| 7 |
-
import
|
| 8 |
// Default OG image served from public/
|
| 9 |
const ogDefaultUrl = '/thumb.jpg';
|
| 10 |
import 'katex/dist/katex.min.css';
|
|
@@ -18,11 +18,78 @@ const docTitleHtml = (articleFM?.title ?? 'Untitled article')
|
|
| 18 |
.replace(/\n/g, '<br/>');
|
| 19 |
const subtitle = articleFM?.subtitle ?? '';
|
| 20 |
const description = articleFM?.description ?? '';
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
const published = articleFM?.published ?? undefined;
|
| 23 |
const tags = articleFM?.tags ?? [];
|
| 24 |
-
// Prefer
|
| 25 |
-
const fmOg = articleFM?.
|
| 26 |
const imageAbs: string = fmOg && fmOg.startsWith('http')
|
| 27 |
? fmOg
|
| 28 |
: (Astro.site ? new URL((fmOg ?? ogDefaultUrl), Astro.site).toString() : (fmOg ?? ogDefaultUrl));
|
|
@@ -43,33 +110,27 @@ const extractYear = (val: string | undefined): number | undefined => {
|
|
| 43 |
};
|
| 44 |
|
| 45 |
const year = extractYear(published);
|
| 46 |
-
const citationAuthorsText =
|
| 47 |
const citationText = `${citationAuthorsText}${year ? ` (${year})` : ''}. "${titleFlat}".`;
|
| 48 |
|
| 49 |
-
const authorsBib =
|
| 50 |
-
const keyAuthor = (
|
| 51 |
const keyTitle = titleFlat.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '').slice(0, 24);
|
| 52 |
const bibKey = `${keyAuthor}${year ?? ''}_${keyTitle}`;
|
| 53 |
-
const
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
// 2) public env flag: PUBLIC_TOC_AUTO_COLLAPSE
|
| 57 |
-
// Falls back to 'false' when not defined (string or boolean)
|
| 58 |
-
const __env = (import.meta as any)?.env || {};
|
| 59 |
-
const envCollapse = (
|
| 60 |
-
String((__env.PUBLIC_TABLE_OF_CONTENT_AUTO_COLLAPSE ?? __env.PUBLIC_TOC_AUTO_COLLAPSE ?? 'false')).toLowerCase() === 'true'
|
| 61 |
-
|| (__env.PUBLIC_TABLE_OF_CONTENT_AUTO_COLLAPSE === true)
|
| 62 |
-
|| (__env.PUBLIC_TOC_AUTO_COLLAPSE === true)
|
| 63 |
-
);
|
| 64 |
const tableOfContentAutoCollapse = Boolean(
|
| 65 |
-
(articleFM as any)?.tableOfContentAutoCollapse ?? (articleFM as any)?.
|
| 66 |
);
|
|
|
|
|
|
|
| 67 |
---
|
| 68 |
<html lang="en" data-theme="light" data-toc-auto-collapse={tableOfContentAutoCollapse ? '1' : '0'}>
|
| 69 |
<head>
|
| 70 |
<meta charset="utf-8" />
|
| 71 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 72 |
-
<Seo title={docTitle} description={description} authors={
|
| 73 |
<script is:inline>
|
| 74 |
(() => {
|
| 75 |
try {
|
|
@@ -90,10 +151,10 @@ const tableOfContentAutoCollapse = Boolean(
|
|
| 90 |
</head>
|
| 91 |
<body>
|
| 92 |
<ThemeToggle />
|
| 93 |
-
<Hero title={docTitleHtml} titleRaw={docTitle} description={subtitle} authors={
|
| 94 |
|
| 95 |
<section class="content-grid">
|
| 96 |
-
<
|
| 97 |
<main>
|
| 98 |
<Article />
|
| 99 |
<style is:inline>
|
|
@@ -103,7 +164,7 @@ const tableOfContentAutoCollapse = Boolean(
|
|
| 103 |
</main>
|
| 104 |
</section>
|
| 105 |
|
| 106 |
-
<Footer citationText={citationText} bibtex={bibtex} />
|
| 107 |
|
| 108 |
|
| 109 |
<script>
|
|
|
|
| 4 |
import Footer from '../components/Footer.astro';
|
| 5 |
import ThemeToggle from '../components/ThemeToggle.astro';
|
| 6 |
import Seo from '../components/Seo.astro';
|
| 7 |
+
import TableOfContents from '../components/TableOfContents.astro';
|
| 8 |
// Default OG image served from public/
|
| 9 |
const ogDefaultUrl = '/thumb.jpg';
|
| 10 |
import 'katex/dist/katex.min.css';
|
|
|
|
| 18 |
.replace(/\n/g, '<br/>');
|
| 19 |
const subtitle = articleFM?.subtitle ?? '';
|
| 20 |
const description = articleFM?.description ?? '';
|
| 21 |
+
// Accept authors as string[] or array of objects { name, url, affiliations? }
|
| 22 |
+
const rawAuthors = (articleFM as any)?.authors ?? [];
|
| 23 |
+
type Affiliation = { id: number; name: string; url?: string };
|
| 24 |
+
type Author = { name: string; url?: string; affiliationIndices?: number[] };
|
| 25 |
+
|
| 26 |
+
// Normalize affiliations from frontmatter: supports strings or objects { id?, name, url? }
|
| 27 |
+
const rawAffils = (articleFM as any)?.affiliations ?? (articleFM as any)?.affiliation ?? [];
|
| 28 |
+
const normalizedAffiliations: Affiliation[] = (() => {
|
| 29 |
+
const seen: Map<string, number> = new Map();
|
| 30 |
+
const list: Affiliation[] = [];
|
| 31 |
+
const pushUnique = (name: string, url?: string) => {
|
| 32 |
+
const key = `${String(name).trim()}|${url ? String(url).trim() : ''}`;
|
| 33 |
+
if (seen.has(key)) return seen.get(key)!;
|
| 34 |
+
const id = list.length + 1;
|
| 35 |
+
list.push({ id, name: String(name).trim(), url: url ? String(url) : undefined });
|
| 36 |
+
seen.set(key, id);
|
| 37 |
+
return id;
|
| 38 |
+
};
|
| 39 |
+
const input = Array.isArray(rawAffils) ? rawAffils : (rawAffils ? [rawAffils] : []);
|
| 40 |
+
for (const a of input) {
|
| 41 |
+
if (typeof a === 'string') {
|
| 42 |
+
pushUnique(a);
|
| 43 |
+
} else if (a && typeof a === 'object') {
|
| 44 |
+
const name = a.name ?? a.label ?? a.text ?? a.affiliation ?? '';
|
| 45 |
+
if (!String(name).trim()) continue;
|
| 46 |
+
const url = a.url || a.link;
|
| 47 |
+
// Respect provided numeric id for display stability if present and sequential; otherwise reassign
|
| 48 |
+
pushUnique(String(name), url ? String(url) : undefined);
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
return list;
|
| 52 |
+
})();
|
| 53 |
+
|
| 54 |
+
// Helper: ensure an affiliation exists and return its id
|
| 55 |
+
const ensureAffiliation = (val: any): number | undefined => {
|
| 56 |
+
if (val == null) return undefined;
|
| 57 |
+
if (typeof val === 'number' && Number.isFinite(val) && val > 0) {
|
| 58 |
+
return Math.floor(val);
|
| 59 |
+
}
|
| 60 |
+
const name = typeof val === 'string' ? val : (val?.name ?? val?.label ?? val?.text ?? val?.affiliation);
|
| 61 |
+
if (!name || !String(name).trim()) return undefined;
|
| 62 |
+
const existing = normalizedAffiliations.find(a => a.name === String(name).trim());
|
| 63 |
+
if (existing) return existing.id;
|
| 64 |
+
const id = normalizedAffiliations.length + 1;
|
| 65 |
+
normalizedAffiliations.push({ id, name: String(name).trim(), url: val?.url || val?.link });
|
| 66 |
+
return id;
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
// Normalize authors and map affiliations -> indices (Distill-like)
|
| 70 |
+
const normalizedAuthors: Author[] = (Array.isArray(rawAuthors) ? rawAuthors : [])
|
| 71 |
+
.map((a: any) => {
|
| 72 |
+
if (typeof a === 'string') {
|
| 73 |
+
return { name: a } as Author;
|
| 74 |
+
}
|
| 75 |
+
const name = String(a?.name || '').trim();
|
| 76 |
+
const url = a?.url || a?.link;
|
| 77 |
+
let indices: number[] | undefined = undefined;
|
| 78 |
+
const raw = a?.affiliations ?? a?.affiliation ?? a?.affils;
|
| 79 |
+
if (raw != null) {
|
| 80 |
+
const entries = Array.isArray(raw) ? raw : [raw];
|
| 81 |
+
const ids = entries.map(ensureAffiliation).filter((x): x is number => typeof x === 'number');
|
| 82 |
+
const unique = Array.from(new Set(ids)).sort((x, y) => x - y);
|
| 83 |
+
if (unique.length) indices = unique;
|
| 84 |
+
}
|
| 85 |
+
return { name, url, affiliationIndices: indices } as Author;
|
| 86 |
+
})
|
| 87 |
+
.filter((a: Author) => a.name && a.name.trim().length > 0);
|
| 88 |
+
const authorNames: string[] = normalizedAuthors.map(a => a.name);
|
| 89 |
const published = articleFM?.published ?? undefined;
|
| 90 |
const tags = articleFM?.tags ?? [];
|
| 91 |
+
// Prefer seoThumbImage from frontmatter if provided
|
| 92 |
+
const fmOg = articleFM?.seoThumbImage as string | undefined;
|
| 93 |
const imageAbs: string = fmOg && fmOg.startsWith('http')
|
| 94 |
? fmOg
|
| 95 |
: (Astro.site ? new URL((fmOg ?? ogDefaultUrl), Astro.site).toString() : (fmOg ?? ogDefaultUrl));
|
|
|
|
| 110 |
};
|
| 111 |
|
| 112 |
const year = extractYear(published);
|
| 113 |
+
const citationAuthorsText = authorNames.join(', ');
|
| 114 |
const citationText = `${citationAuthorsText}${year ? ` (${year})` : ''}. "${titleFlat}".`;
|
| 115 |
|
| 116 |
+
const authorsBib = authorNames.join(' and ');
|
| 117 |
+
const keyAuthor = (authorNames[0] || 'article').split(/\s+/).slice(-1)[0].toLowerCase();
|
| 118 |
const keyTitle = titleFlat.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '').slice(0, 24);
|
| 119 |
const bibKey = `${keyAuthor}${year ?? ''}_${keyTitle}`;
|
| 120 |
+
const doi = (ArticleMod as any)?.frontmatter?.doi ? String((ArticleMod as any).frontmatter.doi) : undefined;
|
| 121 |
+
const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBib}},\n ${year ? `year={${year}},\n ` : ''}${doi ? `doi={${doi}}` : ''}\n}`;
|
| 122 |
+
const envCollapse = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
const tableOfContentAutoCollapse = Boolean(
|
| 124 |
+
(articleFM as any)?.tableOfContentAutoCollapse ?? (articleFM as any)?.tableOfContentsAutoCollapse ?? envCollapse
|
| 125 |
);
|
| 126 |
+
// Licence note (HTML allowed)
|
| 127 |
+
const licence = (articleFM as any)?.licence ?? (articleFM as any)?.license ?? (articleFM as any)?.licenseNote;
|
| 128 |
---
|
| 129 |
<html lang="en" data-theme="light" data-toc-auto-collapse={tableOfContentAutoCollapse ? '1' : '0'}>
|
| 130 |
<head>
|
| 131 |
<meta charset="utf-8" />
|
| 132 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 133 |
+
<Seo title={docTitle} description={description} authors={authorNames} published={published} tags={tags} image={imageAbs} />
|
| 134 |
<script is:inline>
|
| 135 |
(() => {
|
| 136 |
try {
|
|
|
|
| 151 |
</head>
|
| 152 |
<body>
|
| 153 |
<ThemeToggle />
|
| 154 |
+
<Hero title={docTitleHtml} titleRaw={docTitle} description={subtitle} authors={normalizedAuthors as any} affiliations={normalizedAffiliations as any} affiliation={articleFM?.affiliation} published={articleFM?.published} doi={doi} />
|
| 155 |
|
| 156 |
<section class="content-grid">
|
| 157 |
+
<TableOfContents tableOfContentAutoCollapse={tableOfContentAutoCollapse} />
|
| 158 |
<main>
|
| 159 |
<Article />
|
| 160 |
<style is:inline>
|
|
|
|
| 164 |
</main>
|
| 165 |
</section>
|
| 166 |
|
| 167 |
+
<Footer citationText={citationText} bibtex={bibtex} licence={licence} doi={doi} />
|
| 168 |
|
| 169 |
|
| 170 |
<script>
|
app/src/styles/_base.css
CHANGED
|
@@ -1,16 +1,5 @@
|
|
| 1 |
-
|
| 2 |
-
/* Base / Reset */
|
| 3 |
-
/* ============================================================================ */
|
| 4 |
-
html { box-sizing: border-box; }
|
| 5 |
-
*, *::before, *::after { box-sizing: inherit; }
|
| 6 |
-
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, Apple Color Emoji, Segoe UI Emoji; color: var(--text-color); }
|
| 7 |
-
audio { display: block; width: 100%; }
|
| 8 |
-
/* Avoid constraining <main> inside grid; scope container sizing elsewhere if needed */
|
| 9 |
-
/* main { max-width: 980px; margin: 24px auto; padding: 16px; } */
|
| 10 |
|
| 11 |
-
/* ============================================================================ */
|
| 12 |
-
/* Typography (inspired by Distill) */
|
| 13 |
-
/* ============================================================================ */
|
| 14 |
html { font-size: 14px; line-height: 1.6; }
|
| 15 |
@media (min-width: 768px) { html { font-size: 16px; } }
|
| 16 |
@media (min-width: 1024px) { html { font-size: 17px; } }
|
|
@@ -70,226 +59,12 @@ html { font-size: 14px; line-height: 1.6; }
|
|
| 70 |
margin: var(--spacing-4) 0;
|
| 71 |
}
|
| 72 |
|
| 73 |
-
/* Rely on Shiki's own token spans; no class remap */
|
| 74 |
-
/* Placeholder block (discreet centered text) */
|
| 75 |
-
.placeholder-block {
|
| 76 |
-
display: grid;
|
| 77 |
-
place-items: center;
|
| 78 |
-
min-height: 120px;
|
| 79 |
-
color: var(--muted-color);
|
| 80 |
-
font-size: 12px;
|
| 81 |
-
border: 1px dashed var(--border-color);
|
| 82 |
-
border-radius: 8px;
|
| 83 |
-
background: var(--surface-bg);
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
/* Demo blocks for width helpers */
|
| 87 |
-
.demo-wide,
|
| 88 |
-
.demo-full-width {
|
| 89 |
-
display: grid;
|
| 90 |
-
place-items: center;
|
| 91 |
-
min-height: 150px;
|
| 92 |
-
color: var(--muted-color);
|
| 93 |
-
font-size: 12px;
|
| 94 |
-
border: 1px dashed var(--border-color);
|
| 95 |
-
border-radius: 8px;
|
| 96 |
-
background: var(--surface-bg);
|
| 97 |
-
}
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
.content-grid main table {
|
| 101 |
-
border-collapse: collapse;
|
| 102 |
-
/* Make markdown tables scroll horizontally when they overflow on small screens */
|
| 103 |
-
display: block;
|
| 104 |
-
width: 100%;
|
| 105 |
-
max-width: none;
|
| 106 |
-
overflow-x: auto;
|
| 107 |
-
-webkit-overflow-scrolling: touch;
|
| 108 |
-
margin: 0 0 var(--spacing-4);
|
| 109 |
-
}
|
| 110 |
-
.content-grid main th, .content-grid main td {
|
| 111 |
-
border-bottom: 1px solid var(--border-color);
|
| 112 |
-
padding: 6px 8px;
|
| 113 |
-
text-align: left;
|
| 114 |
-
font-size: 15px;
|
| 115 |
-
white-space: nowrap; /* prevent squashing; allow horizontal scroll instead */
|
| 116 |
-
}
|
| 117 |
-
.content-grid main thead th { border-bottom: 1px solid var(--border-color); }
|
| 118 |
-
|
| 119 |
.content-grid main hr { border: none; border-bottom: 1px solid var(--border-color); margin: var(--spacing-5) 0; }
|
| 120 |
|
| 121 |
-
/*
|
| 122 |
-
.code-block {
|
| 123 |
-
background: rgba(120, 120, 120, 0.5);
|
| 124 |
-
border: 1px solid var(--border-color);
|
| 125 |
-
border-radius: 6px;
|
| 126 |
-
padding: var(--spacing-3);
|
| 127 |
-
font-size: 14px;
|
| 128 |
-
overflow: auto;
|
| 129 |
-
}
|
| 130 |
-
*/
|
| 131 |
-
|
| 132 |
-
/* ============================================================================ */
|
| 133 |
-
/* Media / Figures */
|
| 134 |
-
/* ============================================================================ */
|
| 135 |
-
img,
|
| 136 |
-
picture {
|
| 137 |
-
max-width: 100%;
|
| 138 |
-
height: auto;
|
| 139 |
-
display: block;
|
| 140 |
-
position: relative;
|
| 141 |
-
z-index: var(--z-elevated);
|
| 142 |
-
}
|
| 143 |
-
|
| 144 |
-
/* Inline feature tags */
|
| 145 |
-
.tag-list { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0 16px; }
|
| 146 |
-
.tag {
|
| 147 |
-
display: inline-flex;
|
| 148 |
-
align-items: center;
|
| 149 |
-
gap: 6px;
|
| 150 |
-
padding: 4px 8px;
|
| 151 |
-
font-size: 12px;
|
| 152 |
-
line-height: 1;
|
| 153 |
-
border-radius: 999px;
|
| 154 |
-
background: var(--surface-bg);
|
| 155 |
-
border: 1px solid var(--border-color);
|
| 156 |
-
color: var(--text-color);
|
| 157 |
-
}
|
| 158 |
-
[data-theme="dark"] .tag { background: #1a1f27; border-color: rgba(255,255,255,.15); }
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
/* ============================================================================ */
|
| 163 |
-
/* Figures, captions & image credits */
|
| 164 |
-
/* ============================================================================ */
|
| 165 |
-
figure { margin: 12px 0; }
|
| 166 |
-
figcaption { text-align: left; font-size: 0.9rem; color: var(--muted-color); margin-top: 6px; }
|
| 167 |
-
.image-credit { display: block; margin-top: 4px; font-size: 12px; color: var(--muted-color); }
|
| 168 |
-
.image-credit a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
|
| 169 |
-
|
| 170 |
-
/* Zoomable overlay container (if used by any lightbox implementation) */
|
| 171 |
-
[data-zoom-overlay],
|
| 172 |
-
.zoom-overlay {
|
| 173 |
-
position: fixed;
|
| 174 |
-
inset: 0;
|
| 175 |
-
z-index: var(--z-overlay);
|
| 176 |
-
}
|
| 177 |
-
|
| 178 |
-
/* Download link inside figures */
|
| 179 |
-
figure .download-link { position: relative; z-index: var(--z-elevated); }
|
| 180 |
-
|
| 181 |
-
/* ============================================================================ */
|
| 182 |
-
/* Buttons (minimal, clean) */
|
| 183 |
-
/* ============================================================================ */
|
| 184 |
-
button, .button {
|
| 185 |
-
appearance: none;
|
| 186 |
-
background: linear-gradient(15deg, var(--primary-color) 0%, var(--primary-color-hover) 35%);
|
| 187 |
-
color: white!important;
|
| 188 |
-
border: 1px solid transparent;
|
| 189 |
-
border-radius: 6px;
|
| 190 |
-
padding: 8px 12px;
|
| 191 |
-
font-size: 14px;
|
| 192 |
-
line-height: 1;
|
| 193 |
-
cursor: pointer;
|
| 194 |
-
display: inline-block;
|
| 195 |
-
transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, transform .02s ease;
|
| 196 |
-
}
|
| 197 |
-
/* Icon-only buttons: equal X/Y padding */
|
| 198 |
-
button:has(> svg:only-child),
|
| 199 |
-
.button:has(> svg:only-child) {
|
| 200 |
-
padding: 8px !important;
|
| 201 |
-
}
|
| 202 |
-
button:hover, .button:hover {
|
| 203 |
-
filter: brightness(96%);
|
| 204 |
-
}
|
| 205 |
-
button:active, .button:active {
|
| 206 |
-
transform: translateY(1px);
|
| 207 |
-
}
|
| 208 |
-
button:focus-visible, .button:focus-visible {
|
| 209 |
-
outline: none;
|
| 210 |
-
}
|
| 211 |
-
button:disabled, .button:disabled {
|
| 212 |
-
opacity: .6;
|
| 213 |
-
cursor: not-allowed;
|
| 214 |
-
}
|
| 215 |
-
|
| 216 |
-
/* Ghost/Muted button: subtle outline, primary color text/border */
|
| 217 |
-
.button--ghost {
|
| 218 |
-
background: transparent !important;
|
| 219 |
-
color: var(--primary-color) !important;
|
| 220 |
-
border-color: var(--primary-color) !important;
|
| 221 |
-
}
|
| 222 |
-
.button--ghost:hover {
|
| 223 |
-
color: var(--primary-color-hover) !important;
|
| 224 |
-
border-color: var(--primary-color-hover) !important;
|
| 225 |
-
filter: none;
|
| 226 |
-
}
|
| 227 |
-
|
| 228 |
-
.button-group .button {
|
| 229 |
-
margin: 5px;
|
| 230 |
-
}
|
| 231 |
-
|
| 232 |
-
/* ============================================================================ */
|
| 233 |
-
/* Print styles */
|
| 234 |
-
/* ========================================================================= */
|
| 235 |
-
@media print {
|
| 236 |
-
html, body { background: #fff; }
|
| 237 |
-
/* Margins handled by Playwright; avoid extra global margins */
|
| 238 |
-
body { margin: 0; }
|
| 239 |
-
|
| 240 |
-
/* Keep the banner (hero), hide non-essential UI elements */
|
| 241 |
-
#theme-toggle { display: none !important; }
|
| 242 |
-
|
| 243 |
-
/* Links: remove underline */
|
| 244 |
-
.content-grid main a { text-decoration: none; border-bottom: 1px solid rgba(0,0,0,.2); }
|
| 245 |
-
|
| 246 |
-
/* Avoid breaks inside complex blocks */
|
| 247 |
-
.content-grid main pre,
|
| 248 |
-
.content-grid main blockquote,
|
| 249 |
-
.content-grid main table,
|
| 250 |
-
.content-grid main figure { break-inside: avoid; page-break-inside: avoid; }
|
| 251 |
-
|
| 252 |
-
/* Soft page breaks around main headings */
|
| 253 |
-
.content-grid main h2 { page-break-before: auto; page-break-after: avoid; break-after: avoid-page; }
|
| 254 |
-
|
| 255 |
-
/* Small icon labels not needed when printing */
|
| 256 |
-
.code-lang-chip { display: none !important; }
|
| 257 |
-
|
| 258 |
-
/* Adjust more contrasty colors for print */
|
| 259 |
-
:root {
|
| 260 |
-
--border-color: rgba(0,0,0,.2);
|
| 261 |
-
--link-underline: rgba(0,0,0,.3);
|
| 262 |
-
--link-underline-hover: rgba(0,0,0,.4);
|
| 263 |
-
}
|
| 264 |
-
|
| 265 |
-
/* Force single column to reduce widows/orphans and awkward breaks */
|
| 266 |
-
.content-grid { grid-template-columns: 1fr !important; }
|
| 267 |
-
.toc, .right-aside, .toc-mobile { display: none !important; }
|
| 268 |
-
main > nav:first-of-type { display: none !important; }
|
| 269 |
-
|
| 270 |
-
/* Avoid page breaks inside complex visual blocks */
|
| 271 |
-
.hero,
|
| 272 |
-
.hero-banner,
|
| 273 |
-
.d3-galaxy,
|
| 274 |
-
.d3-galaxy svg,
|
| 275 |
-
.html-embed__card,
|
| 276 |
-
.html-embed__card,
|
| 277 |
-
.js-plotly-plot,
|
| 278 |
-
figure,
|
| 279 |
-
pre,
|
| 280 |
-
table,
|
| 281 |
-
blockquote,
|
| 282 |
-
.wide,
|
| 283 |
-
.full-width {
|
| 284 |
-
break-inside: avoid;
|
| 285 |
-
page-break-inside: avoid;
|
| 286 |
-
}
|
| 287 |
-
/* Prefer keeping header+lead together */
|
| 288 |
-
.hero { page-break-after: avoid; }
|
| 289 |
-
}
|
| 290 |
-
|
| 291 |
.muted {
|
| 292 |
color: var(--muted-color);
|
| 293 |
}
|
| 294 |
|
| 295 |
-
|
|
|
|
|
|
|
|
|
| 1 |
+
@import "https://fonts.googleapis.com/css2?family=Source+Sans+Pro:ital,wght@0,200..900;1,200..900&display=swap";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
html { font-size: 14px; line-height: 1.6; }
|
| 4 |
@media (min-width: 768px) { html { font-size: 16px; } }
|
| 5 |
@media (min-width: 1024px) { html { font-size: 17px; } }
|
|
|
|
| 59 |
margin: var(--spacing-4) 0;
|
| 60 |
}
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
.content-grid main hr { border: none; border-bottom: 1px solid var(--border-color); margin: var(--spacing-5) 0; }
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
.muted {
|
| 65 |
color: var(--muted-color);
|
| 66 |
}
|
| 67 |
|
| 68 |
+
[data-footnote-ref] {
|
| 69 |
+
margin-left: 4px;
|
| 70 |
+
}
|
app/src/styles/_layout.css
CHANGED
|
@@ -1,9 +1,6 @@
|
|
| 1 |
/* ============================================================================ */
|
| 2 |
-
/* Layout – 3-column grid (
|
| 3 |
/* ============================================================================ */
|
| 4 |
-
:root {
|
| 5 |
-
--content-padding-x: 16px;
|
| 6 |
-
}
|
| 7 |
|
| 8 |
.content-grid {
|
| 9 |
max-width: 1280px;
|
|
@@ -16,75 +13,27 @@
|
|
| 16 |
align-items: start;
|
| 17 |
}
|
| 18 |
|
| 19 |
-
.content-grid > main {
|
|
|
|
| 20 |
margin: 0;
|
| 21 |
padding: 0;
|
| 22 |
}
|
| 23 |
|
| 24 |
-
@media (max-width:
|
| 25 |
-
.content-grid {
|
|
|
|
|
|
|
| 26 |
}
|
| 27 |
|
| 28 |
-
/* TOC (left column) */
|
| 29 |
-
.toc { position: sticky; top: 24px; }
|
| 30 |
-
.toc nav { border-left: 1px solid var(--border-color); padding-left: 16px; font-size: 13px; }
|
| 31 |
-
.toc .title { font-weight: 600; font-size: 14px; margin-bottom: 8px; }
|
| 32 |
-
|
| 33 |
-
/* Hide in-article TOC (duplicated by sticky aside) */
|
| 34 |
-
main > nav:first-of-type { display: none; }
|
| 35 |
-
|
| 36 |
-
/* TOC look & feel */
|
| 37 |
-
.toc nav ul { margin: 0 0 6px; padding-left: 1em; }
|
| 38 |
-
.toc nav li { list-style: none; margin: .25em 0; }
|
| 39 |
-
.toc nav a { color: var(--text-color); text-decoration: none; border-bottom: none; }
|
| 40 |
-
.toc nav > ul > li > a { font-weight: 700; }
|
| 41 |
-
.toc nav a:hover { text-decoration: underline solid var(--muted-color); }
|
| 42 |
-
.toc nav a.active { text-decoration: underline; }
|
| 43 |
-
|
| 44 |
-
/* Mobile TOC accordion */
|
| 45 |
-
.toc-mobile { display: none; margin: 8px 0 16px; }
|
| 46 |
-
.toc-mobile > summary { cursor: pointer; list-style: none; padding: 8px 12px; border: 1px solid var(--border-color); border-radius: 8px; color: var(--text-color); font-weight: 600; }
|
| 47 |
-
.toc-mobile[open] > summary { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
|
| 48 |
-
.toc-mobile nav { border-left: none; padding: 10px 12px; font-size: 14px; border: 1px solid var(--border-color); border-top: none; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; }
|
| 49 |
-
.toc-mobile nav ul { margin: 0 0 6px; padding-left: 1em; }
|
| 50 |
-
.toc-mobile nav li { list-style: none; margin: .25em 0; }
|
| 51 |
-
.toc-mobile nav a { color: var(--text-color); text-decoration: none; border-bottom: none; }
|
| 52 |
-
.toc-mobile nav > ul > li > a { font-weight: 700; }
|
| 53 |
-
.toc-mobile nav a:hover { text-decoration: underline solid var(--muted-color); }
|
| 54 |
-
.toc-mobile nav a.active { text-decoration: underline; }
|
| 55 |
-
|
| 56 |
-
/* Right aside (notes) */
|
| 57 |
-
.right-aside { position: sticky; top: 24px; }
|
| 58 |
-
.right-aside .aside-card { background: var(--surface-bg); border: 1px solid var(--border-color); border-radius: 8px; padding: 10px; margin-bottom: 10px; font-size: 0.9rem; color: var(--text-color); }
|
| 59 |
|
| 60 |
/* Responsive – collapse to single column */
|
| 61 |
-
@media (max-width:
|
| 62 |
-
.content-grid {
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
.right-aside { display: none; }
|
| 66 |
-
main > nav:first-of-type { display: block; }
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
.margin-aside { position: relative; margin: 12px 0; }
|
| 72 |
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
top: 0;
|
| 76 |
-
right: -260px; /* push into the right grid column (width 260 + gap 32) */
|
| 77 |
-
width: 260px;
|
| 78 |
-
border-radius: 8px;
|
| 79 |
-
padding: 0 30px;
|
| 80 |
-
font-size: 0.9rem;
|
| 81 |
-
color: var(--muted-color);
|
| 82 |
-
}
|
| 83 |
-
@media (max-width: 1100px) {
|
| 84 |
-
.margin-aside__aside {
|
| 85 |
-
position: static;
|
| 86 |
-
width: auto;
|
| 87 |
-
margin-top: 8px;
|
| 88 |
}
|
| 89 |
}
|
| 90 |
|
|
@@ -92,22 +41,18 @@ main > nav:first-of-type { display: none; }
|
|
| 92 |
/* ============================================================================ */
|
| 93 |
/* Width helpers – slightly wider than main column, and full-width to viewport */
|
| 94 |
/* ---------------------------------------------------------------------------- */
|
| 95 |
-
|
| 96 |
-
/* <div className="wide"> ... </div> */
|
| 97 |
-
/* <div className="full-width"> ... </div> */
|
| 98 |
-
/* These center the content relative to the viewport while keeping it responsive. */
|
| 99 |
-
/* */
|
| 100 |
-
/* Notes: */
|
| 101 |
-
/* - These helpers work inside the main article column; they break out visually */
|
| 102 |
-
/* to be wider or fully span the viewport. On small screens, they fall back to 100%. */
|
| 103 |
-
/* - Adjust the target width in .wide if desired. */
|
| 104 |
.wide,
|
| 105 |
-
.full-width {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
.wide {
|
| 108 |
/* Target up to ~1100px while staying within viewport minus page gutters */
|
| 109 |
width: min(1100px, 100vw - 32px);
|
| 110 |
-
margin-left: 50
|
| 111 |
transform: translateX(-50%);
|
| 112 |
}
|
| 113 |
|
|
@@ -118,7 +63,7 @@ main > nav:first-of-type { display: none; }
|
|
| 118 |
margin-right: calc(50% - 50vw);
|
| 119 |
}
|
| 120 |
|
| 121 |
-
@media (max-width:
|
| 122 |
.wide,
|
| 123 |
.full-width {
|
| 124 |
width: 100%;
|
|
|
|
| 1 |
/* ============================================================================ */
|
| 2 |
+
/* Layout – 3-column grid (Table of Contents / Article / Aside) */
|
| 3 |
/* ============================================================================ */
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
.content-grid {
|
| 6 |
max-width: 1280px;
|
|
|
|
| 13 |
align-items: start;
|
| 14 |
}
|
| 15 |
|
| 16 |
+
.content-grid > main {
|
| 17 |
+
max-width: 100%;
|
| 18 |
margin: 0;
|
| 19 |
padding: 0;
|
| 20 |
}
|
| 21 |
|
| 22 |
+
@media (max-width: var(--bp-content-collapse)) {
|
| 23 |
+
.content-grid {
|
| 24 |
+
overflow: hidden;
|
| 25 |
+
}
|
| 26 |
}
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
/* Responsive – collapse to single column */
|
| 30 |
+
@media (max-width: var(--bp-content-collapse)) {
|
| 31 |
+
.content-grid {
|
| 32 |
+
grid-template-columns: 1fr;
|
| 33 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
+
main > nav:first-of-type {
|
| 36 |
+
display: block;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
}
|
| 38 |
}
|
| 39 |
|
|
|
|
| 41 |
/* ============================================================================ */
|
| 42 |
/* Width helpers – slightly wider than main column, and full-width to viewport */
|
| 43 |
/* ---------------------------------------------------------------------------- */
|
| 44 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
.wide,
|
| 46 |
+
.full-width {
|
| 47 |
+
box-sizing: border-box;
|
| 48 |
+
position: relative;
|
| 49 |
+
z-index: var(--z-elevated);
|
| 50 |
+
}
|
| 51 |
|
| 52 |
.wide {
|
| 53 |
/* Target up to ~1100px while staying within viewport minus page gutters */
|
| 54 |
width: min(1100px, 100vw - 32px);
|
| 55 |
+
margin-left: calc(50% + var(--content-padding-x) * 2);
|
| 56 |
transform: translateX(-50%);
|
| 57 |
}
|
| 58 |
|
|
|
|
| 63 |
margin-right: calc(50% - 50vw);
|
| 64 |
}
|
| 65 |
|
| 66 |
+
@media (max-width: var(--bp-content-collapse)) {
|
| 67 |
.wide,
|
| 68 |
.full-width {
|
| 69 |
width: 100%;
|
app/src/styles/_print.css
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
/* ============================================================================ */
|
| 3 |
+
/* Print styles */
|
| 4 |
+
/* ========================================================================= */
|
| 5 |
+
@media print {
|
| 6 |
+
html, body { background: #fff; }
|
| 7 |
+
/* Margins handled by Playwright; avoid extra global margins */
|
| 8 |
+
body { margin: 0; }
|
| 9 |
+
|
| 10 |
+
/* Keep the banner (hero), hide non-essential UI elements */
|
| 11 |
+
#theme-toggle { display: none !important; }
|
| 12 |
+
|
| 13 |
+
/* Links: remove underline */
|
| 14 |
+
.content-grid main a { text-decoration: none; border-bottom: 1px solid rgba(0,0,0,.2); }
|
| 15 |
+
|
| 16 |
+
/* Avoid breaks inside complex blocks */
|
| 17 |
+
.content-grid main pre,
|
| 18 |
+
.content-grid main blockquote,
|
| 19 |
+
.content-grid main table,
|
| 20 |
+
.content-grid main figure { break-inside: avoid; page-break-inside: avoid; }
|
| 21 |
+
|
| 22 |
+
/* Soft page breaks around main headings */
|
| 23 |
+
.content-grid main h2 { page-break-before: auto; page-break-after: avoid; break-after: avoid-page; }
|
| 24 |
+
|
| 25 |
+
/* Small icon labels not needed when printing */
|
| 26 |
+
.code-lang-chip { display: none !important; }
|
| 27 |
+
|
| 28 |
+
/* Adjust more contrasty colors for print */
|
| 29 |
+
:root {
|
| 30 |
+
--border-color: rgba(0,0,0,.2);
|
| 31 |
+
--link-underline: rgba(0,0,0,.3);
|
| 32 |
+
--link-underline-hover: rgba(0,0,0,.4);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
/* Force single column to reduce widows/orphans and awkward breaks */
|
| 36 |
+
.content-grid { grid-template-columns: 1fr !important; }
|
| 37 |
+
.table-of-contents, .right-aside, .table-of-contents-mobile { display: none !important; }
|
| 38 |
+
main > nav:first-of-type { display: none !important; }
|
| 39 |
+
|
| 40 |
+
/* Avoid page breaks inside complex visual blocks */
|
| 41 |
+
.hero,
|
| 42 |
+
.hero-banner,
|
| 43 |
+
.d3-galaxy,
|
| 44 |
+
.d3-galaxy svg,
|
| 45 |
+
.html-embed__card,
|
| 46 |
+
.html-embed__card,
|
| 47 |
+
.js-plotly-plot,
|
| 48 |
+
figure,
|
| 49 |
+
pre,
|
| 50 |
+
table,
|
| 51 |
+
blockquote,
|
| 52 |
+
.wide,
|
| 53 |
+
.full-width {
|
| 54 |
+
break-inside: avoid;
|
| 55 |
+
page-break-inside: avoid;
|
| 56 |
+
}
|
| 57 |
+
/* Prefer keeping header+lead together */
|
| 58 |
+
.hero { page-break-after: avoid; }
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.muted {
|
| 62 |
+
color: var(--muted-color);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
|
app/src/styles/_reset.css
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
html { box-sizing: border-box; }
|
| 2 |
+
*, *::before, *::after { box-sizing: inherit; }
|
| 3 |
+
body { margin: 0; font-family: var(--default-font-family); color: var(--text-color); }
|
| 4 |
+
audio { display: block; width: 100%; }
|
| 5 |
+
|
| 6 |
+
img,
|
| 7 |
+
picture {
|
| 8 |
+
max-width: 100%;
|
| 9 |
+
height: auto;
|
| 10 |
+
display: block;
|
| 11 |
+
position: relative;
|
| 12 |
+
z-index: var(--z-elevated);
|
| 13 |
+
}
|
app/src/styles/_variables.css
CHANGED
|
@@ -1,28 +1,34 @@
|
|
| 1 |
/* ============================================================================ */
|
| 2 |
-
/*
|
| 3 |
/* ============================================================================ */
|
| 4 |
:root {
|
| 5 |
-
/*
|
| 6 |
--neutral-600: rgb(107, 114, 128);
|
| 7 |
--neutral-400: rgb(185, 185, 185);
|
| 8 |
--neutral-300: rgb(228, 228, 228);
|
| 9 |
--neutral-200: rgb(245, 245, 245);
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
--on-primary: #ffffff;
|
| 15 |
|
|
|
|
| 16 |
--text-color: rgba(0,0,0,.85);
|
| 17 |
--muted-color: rgba(0,0,0,.6);
|
| 18 |
--border-color: rgba(0,0,0,.1);
|
| 19 |
-
|
| 20 |
-
/* Light surfaces & links */
|
| 21 |
--surface-bg: #fafafa;
|
| 22 |
--code-bg: #f6f8fa;
|
| 23 |
-
|
|
|
|
|
|
|
| 24 |
--link-underline-hover: var(--primary-color-hover);
|
| 25 |
|
|
|
|
| 26 |
--spacing-1: 8px;
|
| 27 |
--spacing-2: 12px;
|
| 28 |
--spacing-3: 16px;
|
|
@@ -34,32 +40,63 @@
|
|
| 34 |
--spacing-9: 64px;
|
| 35 |
--spacing-10: 72px;
|
| 36 |
|
| 37 |
-
/*
|
| 38 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
/*
|
| 41 |
-
--
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
--
|
| 45 |
-
--
|
| 46 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
}
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
| 49 |
[data-theme="dark"] {
|
| 50 |
--text-color: rgba(255,255,255,.9);
|
| 51 |
--muted-color: rgba(255,255,255,.7);
|
| 52 |
--border-color: rgba(255,255,255,.15);
|
| 53 |
--surface-bg: #12151b;
|
| 54 |
--code-bg: #12151b;
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
--primary-color
|
|
|
|
|
|
|
| 58 |
--on-primary: #0f1115;
|
| 59 |
|
| 60 |
color-scheme: dark;
|
| 61 |
background: #0f1115;
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
| 1 |
/* ============================================================================ */
|
| 2 |
+
/* Design Tokens */
|
| 3 |
/* ============================================================================ */
|
| 4 |
:root {
|
| 5 |
+
/* Neutrals */
|
| 6 |
--neutral-600: rgb(107, 114, 128);
|
| 7 |
--neutral-400: rgb(185, 185, 185);
|
| 8 |
--neutral-300: rgb(228, 228, 228);
|
| 9 |
--neutral-200: rgb(245, 245, 245);
|
| 10 |
|
| 11 |
+
--default-font-family: Source Sans Pro,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
|
| 12 |
+
|
| 13 |
+
/* Brand (OKLCH base + derived states) */
|
| 14 |
+
--primary-base: oklch(0.75 0.12 340);
|
| 15 |
+
--primary-color: var(--primary-base);
|
| 16 |
+
--primary-color-hover: oklch(from var(--primary-color) calc(l - 0.05) c h);
|
| 17 |
+
--primary-color-active: oklch(from var(--primary-color) calc(l - 0.10) c h);
|
| 18 |
--on-primary: #ffffff;
|
| 19 |
|
| 20 |
+
/* Text & Surfaces */
|
| 21 |
--text-color: rgba(0,0,0,.85);
|
| 22 |
--muted-color: rgba(0,0,0,.6);
|
| 23 |
--border-color: rgba(0,0,0,.1);
|
|
|
|
|
|
|
| 24 |
--surface-bg: #fafafa;
|
| 25 |
--code-bg: #f6f8fa;
|
| 26 |
+
|
| 27 |
+
/* Links */
|
| 28 |
+
--link-underline: var(--primary-color);
|
| 29 |
--link-underline-hover: var(--primary-color-hover);
|
| 30 |
|
| 31 |
+
/* Spacing scale */
|
| 32 |
--spacing-1: 8px;
|
| 33 |
--spacing-2: 12px;
|
| 34 |
--spacing-3: 16px;
|
|
|
|
| 40 |
--spacing-9: 64px;
|
| 41 |
--spacing-10: 72px;
|
| 42 |
|
| 43 |
+
/* Breakpoints */
|
| 44 |
+
--bp-xs: 480px;
|
| 45 |
+
--bp-sm: 640px;
|
| 46 |
+
--bp-md: 768px;
|
| 47 |
+
--bp-lg: 1024px;
|
| 48 |
+
--bp-content-collapse: 1100px; /* layout single-column */
|
| 49 |
+
--bp-xl: 1280px;
|
| 50 |
+
|
| 51 |
+
/* Layout */
|
| 52 |
+
--content-padding-x: 16px; /* default page gutter */
|
| 53 |
+
--block-spacing-y: var(--spacing-4); /* default vertical spacing between block components */
|
| 54 |
|
| 55 |
+
/* Config */
|
| 56 |
+
--palette-count: 6;
|
| 57 |
+
|
| 58 |
+
/* Button tokens */
|
| 59 |
+
--button-radius: 6px;
|
| 60 |
+
--button-padding-x: 12px;
|
| 61 |
+
--button-padding-y: 8px;
|
| 62 |
+
--button-font-size: 14px;
|
| 63 |
+
--button-icon-padding: 8px;
|
| 64 |
+
/* Big button */
|
| 65 |
+
--button-big-padding-x: 16px;
|
| 66 |
+
--button-big-padding-y: 12px;
|
| 67 |
+
--button-big-font-size: 16px;
|
| 68 |
+
--button-big-icon-padding: 12px;
|
| 69 |
+
|
| 70 |
+
/* Table tokens */
|
| 71 |
+
--table-border-radius: 8px;
|
| 72 |
+
--table-header-bg: oklch(from var(--surface-bg) calc(l - 0.02) c h);
|
| 73 |
+
--table-row-odd-bg: oklch(from var(--surface-bg) calc(l - 0.01) c h);
|
| 74 |
+
|
| 75 |
+
/* Z-index */
|
| 76 |
+
--z-base: 0;
|
| 77 |
+
--z-content: 1;
|
| 78 |
+
--z-elevated: 10;
|
| 79 |
+
--z-overlay: 1000;
|
| 80 |
+
--z-modal: 1100;
|
| 81 |
+
--z-tooltip: 1200;
|
| 82 |
}
|
| 83 |
+
|
| 84 |
+
/* ============================================================================ */
|
| 85 |
+
/* Dark Theme Overrides */
|
| 86 |
+
/* ============================================================================ */
|
| 87 |
[data-theme="dark"] {
|
| 88 |
--text-color: rgba(255,255,255,.9);
|
| 89 |
--muted-color: rgba(255,255,255,.7);
|
| 90 |
--border-color: rgba(255,255,255,.15);
|
| 91 |
--surface-bg: #12151b;
|
| 92 |
--code-bg: #12151b;
|
| 93 |
+
|
| 94 |
+
/* Primary (lower L in dark) */
|
| 95 |
+
--primary-color: oklch(from var(--primary-base) calc(l - 0.08) c h);
|
| 96 |
+
--primary-color-hover: oklch(from var(--primary-color) calc(l - 0.05) c h);
|
| 97 |
+
--primary-color-active: oklch(from var(--primary-color) calc(l - 0.10) c h);
|
| 98 |
--on-primary: #0f1115;
|
| 99 |
|
| 100 |
color-scheme: dark;
|
| 101 |
background: #0f1115;
|
| 102 |
+
}
|
|
|
|
|
|
|
|
|
app/src/styles/components/_button.css
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
button, .button {
|
| 2 |
+
appearance: none;
|
| 3 |
+
background: linear-gradient(15deg, var(--primary-color) 0%, var(--primary-color-hover) 35%);
|
| 4 |
+
color: white!important;
|
| 5 |
+
border: 1px solid transparent;
|
| 6 |
+
border-radius: var(--button-radius);
|
| 7 |
+
padding: var(--button-padding-y) var(--button-padding-x);
|
| 8 |
+
font-size: var(--button-font-size);
|
| 9 |
+
line-height: 1;
|
| 10 |
+
cursor: pointer;
|
| 11 |
+
display: inline-block;
|
| 12 |
+
text-decoration: none;
|
| 13 |
+
transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, transform .02s ease;
|
| 14 |
+
}
|
| 15 |
+
/* Icon-only buttons: equal X/Y padding */
|
| 16 |
+
button:has(> svg:only-child),
|
| 17 |
+
.button:has(> svg:only-child) {
|
| 18 |
+
padding: var(--button-icon-padding) !important;
|
| 19 |
+
}
|
| 20 |
+
button:hover, .button:hover {
|
| 21 |
+
filter: brightness(96%);
|
| 22 |
+
}
|
| 23 |
+
button:active, .button:active {
|
| 24 |
+
transform: translateY(1px);
|
| 25 |
+
}
|
| 26 |
+
button:focus-visible, .button:focus-visible {
|
| 27 |
+
outline: none;
|
| 28 |
+
}
|
| 29 |
+
button:disabled, .button:disabled {
|
| 30 |
+
opacity: .6;
|
| 31 |
+
cursor: not-allowed;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/* Ghost/Muted button: subtle outline, primary color text/border */
|
| 35 |
+
.button--ghost {
|
| 36 |
+
background: transparent !important;
|
| 37 |
+
color: var(--primary-color) !important;
|
| 38 |
+
border-color: var(--primary-color) !important;
|
| 39 |
+
}
|
| 40 |
+
.button--ghost:hover {
|
| 41 |
+
color: var(--primary-color-hover) !important;
|
| 42 |
+
border-color: var(--primary-color-hover) !important;
|
| 43 |
+
filter: none;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/* Big button: larger padding and font size */
|
| 47 |
+
.button--big {
|
| 48 |
+
padding: var(--button-big-padding-y) var(--button-big-padding-x) !important;
|
| 49 |
+
font-size: var(--button-big-font-size);
|
| 50 |
+
}
|
| 51 |
+
.button--big:has(> svg:only-child) {
|
| 52 |
+
padding: var(--button-big-icon-padding) !important;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.button-group .button {
|
| 56 |
+
margin: 5px;
|
| 57 |
+
}
|
| 58 |
+
|
app/src/styles/components/_code.css
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
code {
|
| 2 |
font-size: 14px;
|
| 3 |
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
| 4 |
background-color: var(--code-bg);
|
| 5 |
-
padding: 0.2em 0.4em;
|
| 6 |
border-radius: 0.3em;
|
| 7 |
border: 1px solid var(--border-color);
|
| 8 |
color: var(--text-color);
|
|
@@ -10,56 +12,129 @@ code {
|
|
| 10 |
line-height: 1.5;
|
| 11 |
}
|
| 12 |
|
| 13 |
-
/*
|
| 14 |
-
/*
|
| 15 |
-
|
| 16 |
-
.astro-code {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
section.content-grid pre {
|
| 21 |
-
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
/* Wrap long lines on mobile to avoid overflow (URLs, etc.) */
|
| 24 |
/* Wrap long lines only on small screens to prevent layout overflow */
|
| 25 |
-
@media (max-width:
|
| 26 |
.astro-code,
|
| 27 |
-
section.content-grid pre {
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
}
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
html[data-theme='dark'] .astro-code { background-color: var(--shiki-dark-bg); }
|
| 34 |
|
| 35 |
/* Apply token color from per-span vars exposed by Shiki dual themes */
|
| 36 |
-
|
| 37 |
-
|
| 38 |
|
| 39 |
-
/* Token color remapping using Shiki CSS variables on the wrapper */
|
| 40 |
/* Optional: boost contrast for light theme */
|
| 41 |
-
|
| 42 |
--shiki-foreground: #24292f;
|
| 43 |
--shiki-background: #ffffff;
|
| 44 |
}
|
| 45 |
|
| 46 |
/* Line numbers for Shiki-rendered code blocks */
|
| 47 |
-
.astro-code code {
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
.astro-code .line:empty::after { content: "\00a0"; }
|
|
|
|
| 51 |
/* Hide trailing empty line added by parsers */
|
| 52 |
.astro-code code > .line:last-child:empty { display: none; }
|
| 53 |
|
| 54 |
-
/*
|
| 55 |
-
|
| 56 |
-
/*
|
| 57 |
.code-card { position: relative; }
|
|
|
|
| 58 |
.code-card .code-copy {
|
| 59 |
-
position: absolute;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
}
|
| 61 |
-
|
| 62 |
-
.code-card pre { margin: 0
|
| 63 |
|
| 64 |
/* Discreet filetype/language label shown under the Copy button */
|
| 65 |
.code-card::after {
|
|
@@ -78,15 +153,18 @@ html[data-theme='light'] .astro-code {
|
|
| 78 |
/* When no copy button (single-line), keep the label in the top-right corner */
|
| 79 |
.code-card.no-copy::after { top: 8px; right: 8px; }
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
/*
|
|
|
|
| 84 |
.accordion .astro-code { padding: 0; border: none; }
|
| 85 |
|
| 86 |
-
/*
|
|
|
|
|
|
|
| 87 |
.astro-code::after {
|
| 88 |
content: attr(data-language);
|
| 89 |
-
position:
|
| 90 |
right: 0;
|
| 91 |
bottom: 0;
|
| 92 |
font-size: 10px;
|
|
@@ -96,7 +174,7 @@ html[data-theme='light'] .astro-code {
|
|
| 96 |
background: var(--surface-bg);
|
| 97 |
border-top: 1px solid var(--border-color);
|
| 98 |
border-left: 1px solid var(--border-color);
|
| 99 |
-
opacity:
|
| 100 |
border-radius: 8px 0 0 0; /* round only top-left */
|
| 101 |
padding: 4px 6px;
|
| 102 |
pointer-events: none;
|
|
|
|
| 1 |
+
/* ============================================================================ */
|
| 2 |
+
/* Inline code */
|
| 3 |
+
/* ============================================================================ */
|
| 4 |
code {
|
| 5 |
font-size: 14px;
|
| 6 |
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
| 7 |
background-color: var(--code-bg);
|
|
|
|
| 8 |
border-radius: 0.3em;
|
| 9 |
border: 1px solid var(--border-color);
|
| 10 |
color: var(--text-color);
|
|
|
|
| 12 |
line-height: 1.5;
|
| 13 |
}
|
| 14 |
|
| 15 |
+
/* ============================================================================ */
|
| 16 |
+
/* Shiki code blocks */
|
| 17 |
+
/* ============================================================================ */
|
| 18 |
+
.astro-code {
|
| 19 |
+
position: relative;
|
| 20 |
+
border: 1px solid var(--border-color);
|
| 21 |
+
border-radius: 6px;
|
| 22 |
+
padding: 0;
|
| 23 |
+
font-size: 14px;
|
| 24 |
+
--code-gutter-width: 2.5em;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/* Shared sizing & horizontal scroll for code containers */
|
| 28 |
+
.astro-code,
|
| 29 |
+
section.content-grid pre {
|
| 30 |
+
overflow-x: auto;
|
| 31 |
+
width: 100%;
|
| 32 |
+
max-width: 100%;
|
| 33 |
+
box-sizing: border-box;
|
| 34 |
+
-webkit-overflow-scrolling: touch;
|
| 35 |
+
padding: 0;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/* Consistent vertical spacing for code blocks */
|
| 39 |
+
.astro-code,
|
| 40 |
+
section.content-grid pre {
|
| 41 |
+
margin: 0 0 var(--block-spacing-y);
|
| 42 |
+
}
|
| 43 |
|
| 44 |
+
section.content-grid pre { margin: 0; }
|
| 45 |
+
|
| 46 |
+
section.content-grid pre code {
|
| 47 |
+
display: inline-block;
|
| 48 |
+
min-width: 100%;
|
| 49 |
+
}
|
| 50 |
|
|
|
|
| 51 |
/* Wrap long lines only on small screens to prevent layout overflow */
|
| 52 |
+
@media (max-width: var(--bp-content-collapse)) {
|
| 53 |
.astro-code,
|
| 54 |
+
section.content-grid pre {
|
| 55 |
+
white-space: pre-wrap;
|
| 56 |
+
overflow-wrap: anywhere;
|
| 57 |
+
word-break: break-word;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
section.content-grid pre code {
|
| 61 |
+
white-space: pre-wrap;
|
| 62 |
+
display: block;
|
| 63 |
+
min-width: 0;
|
| 64 |
+
}
|
| 65 |
}
|
| 66 |
|
| 67 |
+
/* Themes */
|
| 68 |
+
[data-theme='light'] .astro-code { background-color: var(--code-bg); }
|
|
|
|
| 69 |
|
| 70 |
/* Apply token color from per-span vars exposed by Shiki dual themes */
|
| 71 |
+
[data-theme='light'] .astro-code span { color: var(--shiki-light) !important; }
|
| 72 |
+
[data-theme='dark'] .astro-code span { color: var(--shiki-dark) !important; }
|
| 73 |
|
|
|
|
| 74 |
/* Optional: boost contrast for light theme */
|
| 75 |
+
[data-theme='light'] .astro-code {
|
| 76 |
--shiki-foreground: #24292f;
|
| 77 |
--shiki-background: #ffffff;
|
| 78 |
}
|
| 79 |
|
| 80 |
/* Line numbers for Shiki-rendered code blocks */
|
| 81 |
+
.astro-code code {
|
| 82 |
+
counter-reset: astro-code-line;
|
| 83 |
+
display: block;
|
| 84 |
+
background: none;
|
| 85 |
+
border: none;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.astro-code .line {
|
| 89 |
+
display: inline-block;
|
| 90 |
+
position: relative;
|
| 91 |
+
padding-left: calc(var(--code-gutter-width) + var(--spacing-1));
|
| 92 |
+
min-height: 1.25em;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.astro-code .line::before {
|
| 96 |
+
counter-increment: astro-code-line;
|
| 97 |
+
content: counter(astro-code-line);
|
| 98 |
+
position: absolute;
|
| 99 |
+
left: 0;
|
| 100 |
+
top: 0;
|
| 101 |
+
bottom: 0;
|
| 102 |
+
width: calc(var(--code-gutter-width));
|
| 103 |
+
text-align: right;
|
| 104 |
+
color: var(--muted-color);
|
| 105 |
+
opacity: .3;
|
| 106 |
+
user-select: none;
|
| 107 |
+
padding-right: var(--spacing-2);
|
| 108 |
+
border-right: 1px solid var(--border-color);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
.astro-code .line:empty::after { content: "\00a0"; }
|
| 112 |
+
|
| 113 |
/* Hide trailing empty line added by parsers */
|
| 114 |
.astro-code code > .line:last-child:empty { display: none; }
|
| 115 |
|
| 116 |
+
/* ============================================================================ */
|
| 117 |
+
/* Non-Shiki pre wrapper (rehype) */
|
| 118 |
+
/* ============================================================================ */
|
| 119 |
.code-card { position: relative; }
|
| 120 |
+
|
| 121 |
.code-card .code-copy {
|
| 122 |
+
position: absolute;
|
| 123 |
+
top: 6px;
|
| 124 |
+
right: 6px;
|
| 125 |
+
z-index: 3;
|
| 126 |
+
padding: 6px 12px;
|
| 127 |
+
border: none;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.code-card .code-copy svg {
|
| 131 |
+
width: 16px;
|
| 132 |
+
height: 16px;
|
| 133 |
+
display: block;
|
| 134 |
+
fill: currentColor;
|
| 135 |
}
|
| 136 |
+
|
| 137 |
+
.code-card pre { margin: 0 0 var(--spacing-1); }
|
| 138 |
|
| 139 |
/* Discreet filetype/language label shown under the Copy button */
|
| 140 |
.code-card::after {
|
|
|
|
| 153 |
/* When no copy button (single-line), keep the label in the top-right corner */
|
| 154 |
.code-card.no-copy::after { top: 8px; right: 8px; }
|
| 155 |
|
| 156 |
+
/* ============================================================================ */
|
| 157 |
+
/* Contextual overrides */
|
| 158 |
+
/* ============================================================================ */
|
| 159 |
+
/* Inside Accordions: remove padding and border on code containers */
|
| 160 |
.accordion .astro-code { padding: 0; border: none; }
|
| 161 |
|
| 162 |
+
/* ============================================================================ */
|
| 163 |
+
/* Language/extension vignette (bottom-right, discreet) */
|
| 164 |
+
/* ============================================================================ */
|
| 165 |
.astro-code::after {
|
| 166 |
content: attr(data-language);
|
| 167 |
+
position: sticky;
|
| 168 |
right: 0;
|
| 169 |
bottom: 0;
|
| 170 |
font-size: 10px;
|
|
|
|
| 174 |
background: var(--surface-bg);
|
| 175 |
border-top: 1px solid var(--border-color);
|
| 176 |
border-left: 1px solid var(--border-color);
|
| 177 |
+
opacity: 1;
|
| 178 |
border-radius: 8px 0 0 0; /* round only top-left */
|
| 179 |
padding: 4px 6px;
|
| 180 |
pointer-events: none;
|
app/src/styles/components/_footer.css
DELETED
|
@@ -1,56 +0,0 @@
|
|
| 1 |
-
.distill-footer { contain: layout style; font-size: 0.8em; line-height: 1.7em; margin-top: 60px; margin-bottom: 0; border-top: 1px solid rgba(0, 0, 0, 0.1); color: rgba(0, 0, 0, 0.5); }
|
| 2 |
-
.footer-inner { max-width: 1280px; margin: 0 auto; padding: 60px 16px 48px; display: grid; grid-template-columns: 220px minmax(0, 680px) 260px; gap: 32px; align-items: start; }
|
| 3 |
-
|
| 4 |
-
/* Use the parent grid (3 columns like .content-grid) */
|
| 5 |
-
.citation-block,
|
| 6 |
-
.references-block { display: contents; }
|
| 7 |
-
.citation-block > h3,
|
| 8 |
-
.references-block > h3 { grid-column: 1; font-size: 15px; margin: 0; text-align: right; padding-right: 30px; }
|
| 9 |
-
.citation-block > :not(h3),
|
| 10 |
-
.references-block > :not(h3) { grid-column: 2; }
|
| 11 |
-
@media (max-width: 1100px) {
|
| 12 |
-
.footer-inner { grid-template-columns: 1fr; gap: 16px; }
|
| 13 |
-
.footer-inner > h3 { grid-column: auto; margin-top: 16px; }
|
| 14 |
-
}
|
| 15 |
-
.citation-block h3 { margin: 0 0 8px; }
|
| 16 |
-
.citation-block h4 { margin: 16px 0 8px; font-size: 14px; text-transform: uppercase; color: var(--muted-color); }
|
| 17 |
-
|
| 18 |
-
.citation-block p, .references {
|
| 19 |
-
margin-top:0;
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
/* Distill-like appendix citation styling */
|
| 23 |
-
.citation {
|
| 24 |
-
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
| 25 |
-
font-size: 11px;
|
| 26 |
-
line-height: 15px;
|
| 27 |
-
border-left: 1px solid rgba(0, 0, 0, 0.1);
|
| 28 |
-
padding-left: 18px;
|
| 29 |
-
border: 1px solid rgba(0,0,0,0.1);
|
| 30 |
-
background: rgba(0, 0, 0, 0.02);
|
| 31 |
-
padding: 10px 18px;
|
| 32 |
-
border-radius: 3px;
|
| 33 |
-
color: rgba(150, 150, 150, 1);
|
| 34 |
-
overflow: hidden;
|
| 35 |
-
margin-top: -12px;
|
| 36 |
-
white-space: pre-wrap;
|
| 37 |
-
word-wrap: break-word;
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
.citation a { color: rgba(0, 0, 0, 0.6); text-decoration: underline; }
|
| 41 |
-
|
| 42 |
-
.citation.short { margin-top: -4px; }
|
| 43 |
-
|
| 44 |
-
.references-block h3 { margin: 0; }
|
| 45 |
-
|
| 46 |
-
/* Distill-like list styling for references/footnotes */
|
| 47 |
-
.references-block ol { padding: 0 0 0 15px; }
|
| 48 |
-
@media (min-width: 768px) { .references-block ol { padding: 0 0 0 30px; margin-left: -30px; } }
|
| 49 |
-
.references-block li { margin-bottom: 1em; }
|
| 50 |
-
.references-block a { color: rgba(0, 0, 0, 0.6); }
|
| 51 |
-
|
| 52 |
-
@media (max-width: 1100px) {
|
| 53 |
-
.footer-inner { display: block; padding: 40px 16px; }
|
| 54 |
-
}
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/src/styles/components/_poltly.css
DELETED
|
@@ -1,44 +0,0 @@
|
|
| 1 |
-
/* ============================================================================ */
|
| 2 |
-
/* Plotly – fragments & controls */
|
| 3 |
-
/* ============================================================================ */
|
| 4 |
-
.html-embed__card svg text { fill: var(--text-color) !important; }
|
| 5 |
-
.html-embed__card label { color: var(--text-color) !important; }
|
| 6 |
-
.plotly-graph-div { width: 100% !important; min-height: 320px; }
|
| 7 |
-
@media (max-width: 768px) { .plotly-graph-div { min-height: 260px; } }
|
| 8 |
-
[id^="plot-"] { display: flex; flex-direction: column; align-items: center; gap: 15px; }
|
| 9 |
-
.plotly_caption { font-style: italic; margin-top: 10px; }
|
| 10 |
-
.plotly_controls { display: flex; flex-wrap: wrap; justify-content: center; gap: 30px; }
|
| 11 |
-
.plotly_input_container { display: flex; align-items: center; flex-direction: column; gap: 10px; }
|
| 12 |
-
.plotly_input_container > select { padding: 2px 4px; line-height: 1.5em; text-align: center; border-radius: 4px; font-size: 12px; background-color: var(--neutral-200); outline: none; border: 1px solid var(--neutral-300); }
|
| 13 |
-
.plotly_slider { display: flex; align-items: center; gap: 10px; }
|
| 14 |
-
.plotly_slider > input[type="range"] { -webkit-appearance: none; appearance: none; height: 2px; background: var(--neutral-400); border-radius: 5px; outline: none; }
|
| 15 |
-
.plotly_slider > input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: var(--primary-color); cursor: pointer; }
|
| 16 |
-
.plotly_slider > input[type="range"]::-moz-range-thumb { width: 18px; height: 18px; border-radius: 50%; background: var(--primary-color); cursor: pointer; }
|
| 17 |
-
.plotly_slider > span { font-size: 14px; line-height: 1.6em; min-width: 16px; }
|
| 18 |
-
|
| 19 |
-
/* ---------------------------------------------------------------------------- */
|
| 20 |
-
/* Dark mode overrides for Plotly readability */
|
| 21 |
-
/* ---------------------------------------------------------------------------- */
|
| 22 |
-
[data-theme="dark"] .html-embed__card .xaxislayer-above text,
|
| 23 |
-
[data-theme="dark"] .html-embed__card .yaxislayer-above text,
|
| 24 |
-
[data-theme="dark"] .html-embed__card .infolayer text,
|
| 25 |
-
[data-theme="dark"] .html-embed__card .legend text,
|
| 26 |
-
[data-theme="dark"] .html-embed__card .annotation text,
|
| 27 |
-
[data-theme="dark"] .html-embed__card .colorbar text,
|
| 28 |
-
[data-theme="dark"] .html-embed__card .hoverlayer text { fill: #fff !important; }
|
| 29 |
-
|
| 30 |
-
[data-theme="dark"] .html-embed__card .xaxislayer-above path,
|
| 31 |
-
[data-theme="dark"] .html-embed__card .yaxislayer-above path,
|
| 32 |
-
[data-theme="dark"] .html-embed__card .xlines-above,
|
| 33 |
-
[data-theme="dark"] .html-embed__card .ylines-above { stroke: rgba(255,255,255,.35) !important; }
|
| 34 |
-
|
| 35 |
-
[data-theme="dark"] .html-embed__card .gridlayer path { stroke: rgba(255,255,255,.15) !important; }
|
| 36 |
-
|
| 37 |
-
/* Legend and hover backgrounds */
|
| 38 |
-
[data-theme="dark"] .html-embed__card .legend rect.bg { fill: rgba(0,0,0,.25) !important; stroke: rgba(255,255,255,.2) !important; }
|
| 39 |
-
[data-theme="dark"] .html-embed__card .hoverlayer .bg { fill: rgba(0,0,0,.8) !important; stroke: rgba(255,255,255,.2) !important; }
|
| 40 |
-
|
| 41 |
-
/* Colorbar background (keep gradient intact) */
|
| 42 |
-
[data-theme="dark"] .html-embed__card .colorbar .cbbg { fill: rgba(0,0,0,.25) !important; stroke: rgba(255,255,255,.2) !important; }
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/src/styles/components/_table.css
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.content-grid main table {
|
| 2 |
+
border-collapse: collapse;
|
| 3 |
+
table-layout: auto;
|
| 4 |
+
margin: 0;
|
| 5 |
+
}
|
| 6 |
+
.content-grid main th, .content-grid main td {
|
| 7 |
+
border-bottom: 1px solid var(--border-color);
|
| 8 |
+
padding: 6px 8px;
|
| 9 |
+
text-align: left;
|
| 10 |
+
font-size: 15px;
|
| 11 |
+
white-space: nowrap; /* prevent squashing; allow horizontal scroll instead */
|
| 12 |
+
}
|
| 13 |
+
.content-grid main thead th { border-bottom: 1px solid var(--border-color); }
|
| 14 |
+
.content-grid main thead th {
|
| 15 |
+
border-bottom: 1px solid var(--border-color);
|
| 16 |
+
}
|
| 17 |
+
.content-grid main thead th {
|
| 18 |
+
background: var(--table-header-bg);
|
| 19 |
+
padding-top: 10px;
|
| 20 |
+
padding-bottom: 10px;
|
| 21 |
+
font-weight: 600;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.content-grid main hr {
|
| 25 |
+
border: none;
|
| 26 |
+
border-bottom: 1px solid var(--border-color);
|
| 27 |
+
margin: var(--spacing-5) 0;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/* Scroll wrapper: keeps table 100% width but enables horizontal scroll when needed */
|
| 31 |
+
.content-grid main .table-scroll {
|
| 32 |
+
width: 100%;
|
| 33 |
+
overflow-x: auto;
|
| 34 |
+
-webkit-overflow-scrolling: touch;
|
| 35 |
+
border: 1px solid var(--border-color);
|
| 36 |
+
border-radius: var(--table-border-radius);
|
| 37 |
+
background: var(--surface-bg);
|
| 38 |
+
margin: 0 0 var(--block-spacing-y);
|
| 39 |
+
}
|
| 40 |
+
.content-grid main .table-scroll > table {
|
| 41 |
+
width: fit-content;
|
| 42 |
+
min-width: 100%;
|
| 43 |
+
max-width: none;
|
| 44 |
+
}
|
| 45 |
+
/* Vertical dividers between columns (no outer right border) */
|
| 46 |
+
.content-grid main .table-scroll > table th,
|
| 47 |
+
.content-grid main .table-scroll > table td {
|
| 48 |
+
border-right: 1px solid var(--border-color);
|
| 49 |
+
}
|
| 50 |
+
.content-grid main .table-scroll > table th:last-child,
|
| 51 |
+
.content-grid main .table-scroll > table td:last-child {
|
| 52 |
+
border-right: none;
|
| 53 |
+
}
|
| 54 |
+
.content-grid main .table-scroll > table thead th:first-child {
|
| 55 |
+
border-top-left-radius: var(--table-border-radius);
|
| 56 |
+
}
|
| 57 |
+
.content-grid main .table-scroll > table thead th:last-child {
|
| 58 |
+
border-top-right-radius: var(--table-border-radius);
|
| 59 |
+
}
|
| 60 |
+
.content-grid main .table-scroll > table tbody tr:last-child td:first-child {
|
| 61 |
+
border-bottom-left-radius: var(--table-border-radius);
|
| 62 |
+
}
|
| 63 |
+
.content-grid main .table-scroll > table tbody tr:last-child td:last-child {
|
| 64 |
+
border-bottom-right-radius: var(--table-border-radius);
|
| 65 |
+
}
|
| 66 |
+
/* Zebra striping for odd rows */
|
| 67 |
+
.content-grid main .table-scroll > table tbody tr:nth-child(odd) td {
|
| 68 |
+
background: var(--table-row-odd-bg);
|
| 69 |
+
}
|
| 70 |
+
/* Remove bottom border on last row */
|
| 71 |
+
.content-grid main .table-scroll > table tbody tr:last-child td {
|
| 72 |
+
border-bottom: none;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/* Accordion context: remove outer borders/radius and fit content flush */
|
| 76 |
+
.accordion .accordion__content .table-scroll {
|
| 77 |
+
border: none;
|
| 78 |
+
border-radius: 0;
|
| 79 |
+
margin: 0;
|
| 80 |
+
}
|
| 81 |
+
.accordion .accordion__content .table-scroll > table thead th:first-child,
|
| 82 |
+
.accordion .accordion__content .table-scroll > table thead th:last-child,
|
| 83 |
+
.accordion .accordion__content .table-scroll > table tbody tr:last-child td:first-child,
|
| 84 |
+
.accordion .accordion__content .table-scroll > table tbody tr:last-child td:last-child {
|
| 85 |
+
border-radius: 0;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
/* Fallback for browsers without fit-content support */
|
| 89 |
+
@supports not (width: fit-content) {
|
| 90 |
+
.content-grid main .table-scroll > table {
|
| 91 |
+
width: max-content;
|
| 92 |
+
min-width: 100%;
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
app/src/styles/components/_tag.css
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.tag-list { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0 16px; }
|
| 2 |
+
|
| 3 |
+
.tag {
|
| 4 |
+
display: inline-flex;
|
| 5 |
+
align-items: center;
|
| 6 |
+
gap: 6px;
|
| 7 |
+
padding: 10px 16px;
|
| 8 |
+
font-size: 12px;
|
| 9 |
+
line-height: 1;
|
| 10 |
+
border-radius: 100px;
|
| 11 |
+
background: var(--surface-bg);
|
| 12 |
+
border: 1px solid var(--border-color);
|
| 13 |
+
color: var(--text-color);
|
| 14 |
+
}
|
app/src/styles/global.css
CHANGED
|
@@ -1,56 +1,27 @@
|
|
| 1 |
@import './_variables.css';
|
|
|
|
| 2 |
@import './_base.css';
|
| 3 |
@import './_layout.css';
|
| 4 |
-
@import './
|
| 5 |
@import './components/_code.css';
|
| 6 |
-
@import './components/
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
/* Opt-in zoomable images */
|
| 20 |
-
img[data-zoomable] { cursor: zoom-in; }
|
| 21 |
-
.medium-zoom--opened img[data-zoomable] { cursor: zoom-out; }
|
| 22 |
-
|
| 23 |
-
/* Download button for img[data-downloadable] */
|
| 24 |
-
figure.has-dl-btn { position: relative; }
|
| 25 |
-
.dl-host { position: relative; }
|
| 26 |
-
.img-dl-wrap { position: relative; display: inline-block; }
|
| 27 |
-
.img-dl-btn {
|
| 28 |
-
position: absolute;
|
| 29 |
-
right: 8px;
|
| 30 |
-
bottom: 8px;
|
| 31 |
-
align-items: center;
|
| 32 |
-
justify-content: center;
|
| 33 |
-
width: 30px;
|
| 34 |
-
height: 30px;
|
| 35 |
-
border-radius: 6px;
|
| 36 |
-
color: white;
|
| 37 |
-
text-decoration: none;
|
| 38 |
-
border: 1px solid rgba(255,255,255,0.25);
|
| 39 |
-
z-index: var(--z-elevated);
|
| 40 |
-
display: none;
|
| 41 |
}
|
| 42 |
-
.img-dl-btn svg { width: 18px; height: 18px; fill: currentColor; }
|
| 43 |
-
.img-dl-wrap:hover .img-dl-btn { display: inline-flex; }
|
| 44 |
-
[data-theme="dark"] .img-dl-btn { background: rgba(255,255,255,0.15); color: white; border-color: rgba(255,255,255,0.25); }
|
| 45 |
-
[data-theme="dark"] .img-dl-btn:hover { background: rgba(255,255,255,0.25); }
|
| 46 |
-
|
| 47 |
-
/* ============================================================================ */
|
| 48 |
-
/* Theme Toggle button (moved from component) */
|
| 49 |
-
/* ============================================================================ */
|
| 50 |
-
#theme-toggle { display: inline-flex; align-items: center; gap: 8px; border: none; background: transparent; padding: 6px 10px; border-radius: 8px; cursor: pointer; margin: 12px 16px; color: var(--text-color) !important; }
|
| 51 |
-
#theme-toggle .icon.dark { display: none; }
|
| 52 |
-
[data-theme="dark"] #theme-toggle .icon.light { display: none; }
|
| 53 |
-
[data-theme="dark"] #theme-toggle .icon.dark { display: inline; }
|
| 54 |
-
#theme-toggle .icon { filter: none !important; }
|
| 55 |
-
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
@import './_variables.css';
|
| 2 |
+
@import './_reset.css';
|
| 3 |
@import './_base.css';
|
| 4 |
@import './_layout.css';
|
| 5 |
+
@import './_print.css';
|
| 6 |
@import './components/_code.css';
|
| 7 |
+
@import './components/_button.css';
|
| 8 |
+
@import './components/_table.css';
|
| 9 |
+
@import './components/_tag.css';
|
| 10 |
|
| 11 |
+
.demo-wide,
|
| 12 |
+
.demo-full-width {
|
| 13 |
+
display: grid;
|
| 14 |
+
place-items: center;
|
| 15 |
+
min-height: 150px;
|
| 16 |
+
color: var(--muted-color);
|
| 17 |
+
font-size: 12px;
|
| 18 |
+
border: 1px dashed var(--border-color);
|
| 19 |
+
border-radius: 8px;
|
| 20 |
+
background: var(--surface-bg);
|
| 21 |
+
margin-bottom: var(--block-spacing-y);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
+
.mermaid {
|
| 25 |
+
background: none!important;
|
| 26 |
+
margin-bottom: var(--block-spacing-y) !important;
|
| 27 |
+
}
|