diff --git a/.gitignore b/.gitignore index e0f1fda..5c5458b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,6 @@ conda/ *~ ~* _tmp* -tmp* tags # Byte-compiled / optimized / DLL files diff --git a/CHANGELOG.md b/CHANGELOG.md index 1119055..9a21112 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### New Features +- `viewmd` renders notebooks via aidialog's new `dlg2md`/`msg2md` (built on fastcore's new `render_md`): standard notebook output ordering (streams merged, results last), progress-bar carriage returns cleaned up, and jpeg/svg/latex/javascript outputs now supported - Lower a table `width` attribute to an inline style width (bare number = px, invalid left visible, beats `colwidths`); `viewmd` adopts typrose as its typography layer, with the light/dark toggle flipping `prose-invert` - Add the `details` collapsible block: `::: {.details}` lowers to `
` in HTML with a first-child heading as ``, degrades to a bold label elsewhere; class word reserved in DIALECT.md's new converter-obligations section - Move `auto_ids` from `to_mdhtml` to `to_html` (on by default there) and drop the `data-auto-id` marker: derived heading ids are an export concern; merge docs/MD.md into docs/DIALECT.md as the single dialect spec diff --git a/pyproject.toml b/pyproject.toml index f1ba6b5..7e54d1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version"] description = "A bounded-time, Pandoc-leaning Markdown parser with GFM, Extra/kramdown, math, fenced divs, and MDHTML output." license = {text = "MIT OR Apache-2.0"} requires-python = ">=3.10" -dependencies = ["fast5ever>=0.1.1", "fastcore>=2.1.10", "aidialog>=0.0.3"] +dependencies = ["fast5ever>=0.1.1", "fastcore>=2.1.12", "aidialog>=0.0.3"] readme = "README.md" authors = [{name = "Jeremy Howard", email = "j@fast.ai"}] classifiers = [ @@ -43,7 +43,7 @@ addopts = "-m 'not slow'" timeout = 60 [tool.uv] -cache-keys = [{ file = "pyproject.toml" }, { file = "src/**/*.rs" }, { file = "Cargo.toml" }, { file = "Cargo.lock" }] +cache-keys = [{ file = "pyproject.toml" }, { file = "src/**/*.rs" }, { file = "src/**/*.css" }, { file = "Cargo.toml" }, { file = "Cargo.lock" }] [tool.fastship] branch = "main" diff --git a/python/mdhtml/controls.html b/python/mdhtml/controls.html new file mode 100644 index 0000000..038b5b4 --- /dev/null +++ b/python/mdhtml/controls.html @@ -0,0 +1,3 @@ +
+ +
\ No newline at end of file diff --git a/python/mdhtml/md2html.py b/python/mdhtml/md2html.py index dab48f7..20b5e75 100644 --- a/python/mdhtml/md2html.py +++ b/python/mdhtml/md2html.py @@ -15,26 +15,12 @@ RefsMode = str_enum('RefsMode', 'ids', 'lenient', 'resolve') HlMode = str_enum('HlMode', 'spans', 'api', 'off') +NumMode = str_enum('NumMode', 'legal', 'decimal') KATEX = "https://cdn.jsdelivr.net/npm/katex@0.16.22/dist" MERMAID = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs" CACHE = Path.home() / ".cache" / "md2html" -PAGE_CSS = """:root { color-scheme: light dark; } -body { max-width: 46rem; margin: 2rem auto; padding: 0 1rem; font-family: system-ui, sans-serif; line-height: 1.6; } -h1, h2, h3, h4, h5, h6 { line-height: 1.25; margin: 1.6em 0 0.6em; } -pre { padding: 0.8em 1em; border-radius: 0.4em; overflow-x: auto; background: light-dark(#f6f8fa, #161b22); } -code { font-family: ui-monospace, monospace; font-size: 0.9em; } -:not(pre) > code { padding: 0.1em 0.3em; border-radius: 0.3em; background: light-dark(#f0f1f3, #22272e); } -table { border-collapse: collapse; margin: 1em 0; } -th, td { border: 1px solid light-dark(#d0d7de, #30363d); padding: 0.3em 0.6em; } -blockquote { margin: 1em 0; padding-left: 1em; border-left: 3px solid light-dark(#d0d7de, #30363d); } -figure { margin: 1.5em 0; } -figcaption { font-size: 0.9em; opacity: 0.8; } -img { max-width: 100%; } -table.frontmatter { font-size: 0.85em; } -table.frontmatter th, table.frontmatter td { border: none; padding: 0.1em 1em 0.1em 0; } -table.frontmatter th { text-align: left; font-weight: 600; opacity: 0.65; } -""" +PAGE_CSS = (Path(__file__).parent/"page.css").read_text(encoding="utf-8") def _imgs(el): for c in el.children: @@ -97,7 +83,7 @@ def main( out: str = None, # Where to write: a path, `-` for stdout; omitted opens a browser, or writes to stdout when piped fragment: bool = False, # Emit the body fragment alone, with no page shell refs: RefsMode = RefsMode.ids, # Bake references as target ids, with numbering ('resolve'), or numbering that degrades to ids ('lenient') - number_headings: str = None, # Heading numbering scheme: 'legal' or 'decimal' + number_headings: NumMode = None, # Heading numbering scheme toc: bool = False, # Prepend a table of contents hl: HlMode = HlMode.spans, # Code highlighting: classed spans, the Highlight API, or off theme: str = "vscode_light", # Code colors in light mode: any name from `mdhtml.themes()` diff --git a/python/mdhtml/page.css b/python/mdhtml/page.css new file mode 100644 index 0000000..243e304 --- /dev/null +++ b/python/mdhtml/page.css @@ -0,0 +1,15 @@ +:root { color-scheme: light dark; } +body { max-width: 46rem; margin: 2rem auto; padding: 0 1rem; font-family: system-ui, sans-serif; line-height: 1.6; } +h1, h2, h3, h4, h5, h6 { line-height: 1.25; margin: 1.6em 0 0.6em; } +pre { padding: 0.8em 1em; border-radius: 0.4em; overflow-x: auto; background: light-dark(#f6f8fa, #161b22); } +code { font-family: ui-monospace, monospace; font-size: 0.9em; } +:not(pre) > code { padding: 0.1em 0.3em; border-radius: 0.3em; background: light-dark(#f0f1f3, #22272e); } +table { border-collapse: collapse; margin: 1em 0; } +th, td { border: 1px solid light-dark(#d0d7de, #30363d); padding: 0.3em 0.6em; } +blockquote { margin: 1em 0; padding-left: 1em; border-left: 3px solid light-dark(#d0d7de, #30363d); } +figure { margin: 1.5em 0; } +figcaption { font-size: 0.9em; opacity: 0.8; } +img { max-width: 100%; } +table.frontmatter { font-size: 0.85em; } +table.frontmatter th, table.frontmatter td { border: none; padding: 0.1em 1em 0.1em 0; } +table.frontmatter th { text-align: left; font-weight: 600; opacity: 0.65; } diff --git a/python/mdhtml/view.css b/python/mdhtml/view.css new file mode 100644 index 0000000..946272d --- /dev/null +++ b/python/mdhtml/view.css @@ -0,0 +1,51 @@ + +body { max-width: none; padding: 0 1.5rem; display: grid; justify-content: center; column-gap: 2.5rem; + grid-template-columns: minmax(0, 46rem) 15rem; } +body > * { grid-column: 1; } +body > nav.toc { grid-column: 2; grid-row: 1 / span 9999; position: sticky; top: 2rem; align-self: start; + max-height: 88vh; overflow: auto; font-size: 0.85em; line-height: 1.4; } +nav.toc ol { list-style: none; margin: 0; padding-left: 0.9em; } +nav.toc > ol { padding-left: 0; } +nav.toc a { color: inherit; text-decoration: none; opacity: 0.65; display: block; padding: 0.15em 0; } +nav.toc a:hover { opacity: 1; } +nav.toc a[aria-current] { opacity: 1; font-weight: 600; } +body.vm-toc-off { grid-template-columns: minmax(0, 46rem); } +body.vm-toc-off > nav.toc { display: none; } + +.vm-controls { position: fixed; top: 0.6rem; right: 0.8rem; z-index: 20; display: flex; gap: 0.3rem; } +.vm-controls > * { font: inherit; font-size: 0.8rem; padding: 0.2em 0.5em; cursor: pointer; + border: 1px solid light-dark(#d0d7de, #30363d); border-radius: 0.4em; + background: light-dark(#fff, #161b22); color: inherit; } + +.vm-code { position: relative; } +.vm-copy { position: absolute; top: 0.4rem; right: 0.4rem; font: inherit; font-size: 0.75rem; + padding: 0.15em 0.5em; cursor: pointer; opacity: 0; transition: opacity 0.1s; + border: 1px solid light-dark(#d0d7de, #30363d); border-radius: 0.3em; + background: light-dark(#fff, #161b22); color: inherit; } +.vm-code:hover .vm-copy, .vm-copy:focus { opacity: 1; } + +.vm-head { cursor: pointer; } +.vm-head .vm-mark::before { content: '\25be'; font-size: 1rem; line-height: 1.6; position: absolute; margin-left: -1.1rem; opacity: 0.35; } +.vm-head.vm-closed .vm-mark::before { content: '\25b8'; } +.vm-root { min-height: 1.1em; margin: 0.6em 0; font-weight: 600; opacity: 0.55; } +.vm-hide { display: none !important; } + +/* typrose owns table typography: row borders only, and the frontmatter table stays quiet */ +th, td { border: none; } +table.frontmatter tbody tr { border-bottom: none; } + +div.output { margin-left: 1rem; padding-left: 0.8rem; border-left: 3px solid rgba(232, 141, 58, 0.45); } +div.prompt { padding-left: 0.8rem; border-left: 3px solid rgba(64, 132, 244, 0.45); } +div.reply { margin-left: 1rem; padding-left: 0.8rem; border-left: 3px solid rgba(232, 141, 58, 0.45); } + +@media (max-width: 62rem) { + body { grid-template-columns: minmax(0, 46rem); } + body > nav.toc { position: fixed; grid-column: 1; top: 0; right: 0; height: 100%; width: 15rem; + padding: 3rem 1rem 1rem; overflow: auto; max-height: none; z-index: 10; + background: light-dark(#fff, #0d1117); box-shadow: -2px 0 8px #0003; } + body:not(.vm-toc-on) > nav.toc { display: none; } +} +@media print { + .vm-controls, .vm-copy, .vm-mark, body > nav.toc { display: none; } + .vm-hide { display: revert !important; } +} diff --git a/python/mdhtml/view.js b/python/mdhtml/view.js new file mode 100644 index 0000000..013fa0b --- /dev/null +++ b/python/mdhtml/view.js @@ -0,0 +1,114 @@ + +const THEMES = __THEMES__, root = document.documentElement, ls = localStorage; +const sel = document.getElementById('vm-theme'), modeBtn = document.getElementById('vm-mode'); +let fam = ls.getItem('vm-fam') || 'auto', mode = ls.getItem('vm-mode') || 'auto'; + +const isDark = () => mode === 'auto' ? matchMedia('(prefers-color-scheme: dark)').matches : mode === 'dark'; +function applyTheme() { + root.style.colorScheme = mode === 'auto' ? 'light dark' : mode; + document.body.classList.add('prose'); + document.body.classList.toggle('prose-invert', isDark()); + const t = THEMES.find(t => t[0] === fam); + if (t) root.dataset.hl = isDark() ? t[2] : t[1]; else delete root.dataset.hl; + modeBtn.textContent = mode === 'auto' ? '\u25d0' : isDark() ? '\u263e' : '\u2600'; + sel.value = fam; +} +sel.onchange = () => { fam = sel.value; ls.setItem('vm-fam', fam); applyTheme(); }; +modeBtn.onclick = () => { mode = {auto: 'light', light: 'dark', dark: 'auto'}[mode]; ls.setItem('vm-mode', mode); applyTheme(); }; +matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme); +applyTheme(); + +const tocnav = document.querySelector('nav.toc'); +document.getElementById('vm-toc').onclick = () => { + const shown = tocnav && getComputedStyle(tocnav).display !== 'none'; + document.body.classList.toggle('vm-toc-on', !shown); + document.body.classList.toggle('vm-toc-off', shown); +}; + +const links = new Map([...document.querySelectorAll('nav.toc a')].map(a => [a.getAttribute('href').slice(1), a])); +const io = new IntersectionObserver(es => es.forEach(e => { + if (!e.isIntersecting) return; + links.forEach(a => a.removeAttribute('aria-current')); + links.get(e.target.id).setAttribute('aria-current', 'true'); +}), {rootMargin: '0px 0px -70% 0px'}); +document.querySelectorAll('h1[id],h2[id],h3[id]').forEach(h => { if (links.has(h.id)) io.observe(h); }); + +document.addEventListener('click', e => { + const b = e.target.closest('.vm-copy'); + if (!b) return; + navigator.clipboard.writeText(b.parentElement.querySelector('code').textContent); + b.textContent = 'Copied'; + setTimeout(() => b.textContent = 'Copy', 1200); +}); + +const SKIP = el => el.matches('nav.toc, .vm-controls, script, style'); +const level = el => el.classList.contains('vm-root') ? 0 + : /^H[1-6]$/.test(el.tagName) ? +el.tagName[1] : null; +function sync() { + let closedAt = null; + for (const el of [...document.body.children]) { + if (SKIP(el)) continue; + const lv = level(el); + if (closedAt != null && lv != null && lv <= closedAt) closedAt = null; + el.classList.toggle('vm-hide', closedAt != null); + if (closedAt == null && lv != null && el.classList.contains('vm-closed')) closedAt = lv; + } +} +function section(h) { + const res = []; + for (let el = h.nextElementSibling; el; el = el.nextElementSibling) { + if (SKIP(el)) continue; + const lv = level(el); + if (lv != null && lv <= level(h)) break; + res.push(el); + } + return res; +} +const heads = [...document.querySelectorAll('h1[id],h2[id],h3[id]')].filter(h => h.parentElement === document.body); +if (heads.length) { + const top = Math.min(...heads.map(level)); + const first = heads.find(h => level(h) === top); + let items = heads.filter(h => level(h) === top).length; + for (const el of document.body.children) { if (el.contains(first)) break; if (!SKIP(el) && !el.matches('table.frontmatter')) { items += 1; break; } } + for (const h of heads) { + h.classList.add('vm-head'); + h.insertAdjacentHTML('afterbegin', ''); + } + if (items > 1) { + document.body.insertAdjacentHTML('afterbegin', + '
'); + document.body.querySelector('.vm-title').textContent = document.title; + } + document.addEventListener('mousedown', e => { if (e.shiftKey && e.target.closest('.vm-head')) e.preventDefault(); }); + document.addEventListener('click', e => { + const h = e.target.closest('.vm-head'); + if (!h) return; + if (e.shiftKey) { + const shut = !h.classList.contains('vm-closed'); + for (const el of [h, ...section(h)]) if (el.classList.contains('vm-head')) el.classList.toggle('vm-closed', shut); + } else h.classList.toggle('vm-closed'); + sync(); + }); +} + +function reveal(el) { + el = el || (location.hash && document.getElementById(decodeURIComponent(location.hash.slice(1)))); + if (!el) return; + let t = el; + while (t.parentElement && t.parentElement !== document.body) t = t.parentElement; + let ml = level(t) ?? Infinity; + for (let p = t.previousElementSibling; p; p = p.previousElementSibling) { + const lv = level(p); + if (lv == null || lv >= ml) continue; + p.classList.remove('vm-closed'); + ml = lv; + } + sync(); + el.scrollIntoView(); +} +addEventListener('hashchange', () => reveal()); +document.addEventListener('click', e => { + const a = e.target.closest('a[href^="#"]'); + if (a) reveal(document.getElementById(decodeURIComponent(a.getAttribute('href').slice(1)))); +}); +reveal(); diff --git a/python/mdhtml/viewmd.py b/python/mdhtml/viewmd.py index bccea6c..d42be56 100644 --- a/python/mdhtml/viewmd.py +++ b/python/mdhtml/viewmd.py @@ -3,197 +3,25 @@ from pathlib import Path from typing import Annotated +import fastcore.xtras # for patches # chkstyle: ignore from fastcore.meta import delegates -from fastcore.ansi import strip_ansi from fastcore.script import call_parse -from fastcore.xtras import fenced +from aidialog.dialog import dlg2md from aidialog.ipynb import read_ipynb -from aidialog.dialog import fmt_tools from . import DASHES, MUSTACHE, mustache_pill, replacements, theme_css, to_html, to_mdhtml from ._cli import parse_args, read_src from . import meta_table -from .md2html import CACHE, HlMode, RefsMode, _code_wrap, _inline_imgs, page +from .md2html import CACHE, HlMode, NumMode, RefsMode, _code_wrap, _inline_imgs, page -# (family label, light theme, dark theme); the pair follows the light/dark mode toggle -# Typography layer: the published typrose release, pinned; bump alongside typrose releases TYPROSE = "https://cdn.jsdelivr.net/npm/typrose@0.2.2/typrose.css" +THEMES = [("VS Code", "vscode_light", "vscode_dark"), ("Xcode", "xcode_light", "xcode_dark"), ("One", "onelight", "onedark"), ("Rose Pine", "rosepine_dawn", "rosepine_moon"), ("Modus", "modus_operandi", "modus_vivendi")] -THEMES = [("VS Code", "vscode_light", "vscode_dark"), ("Xcode", "xcode_light", "xcode_dark"), - ("One", "onelight", "onedark"), ("Rose Pine", "rosepine_dawn", "rosepine_moon"), ("Modus", "modus_operandi", "modus_vivendi")] - -VIEW_CSS = """ -body { max-width: none; padding: 0 1.5rem; display: grid; justify-content: center; column-gap: 2.5rem; - grid-template-columns: minmax(0, 46rem) 15rem; } -body > * { grid-column: 1; } -body > nav.toc { grid-column: 2; grid-row: 1 / span 9999; position: sticky; top: 2rem; align-self: start; - max-height: 88vh; overflow: auto; font-size: 0.85em; line-height: 1.4; } -nav.toc ol { list-style: none; margin: 0; padding-left: 0.9em; } -nav.toc > ol { padding-left: 0; } -nav.toc a { color: inherit; text-decoration: none; opacity: 0.65; display: block; padding: 0.15em 0; } -nav.toc a:hover { opacity: 1; } -nav.toc a[aria-current] { opacity: 1; font-weight: 600; } -body.vm-toc-off { grid-template-columns: minmax(0, 46rem); } -body.vm-toc-off > nav.toc { display: none; } - -.vm-controls { position: fixed; top: 0.6rem; right: 0.8rem; z-index: 20; display: flex; gap: 0.3rem; } -.vm-controls > * { font: inherit; font-size: 0.8rem; padding: 0.2em 0.5em; cursor: pointer; - border: 1px solid light-dark(#d0d7de, #30363d); border-radius: 0.4em; - background: light-dark(#fff, #161b22); color: inherit; } - -.vm-code { position: relative; } -.vm-copy { position: absolute; top: 0.4rem; right: 0.4rem; font: inherit; font-size: 0.75rem; - padding: 0.15em 0.5em; cursor: pointer; opacity: 0; transition: opacity 0.1s; - border: 1px solid light-dark(#d0d7de, #30363d); border-radius: 0.3em; - background: light-dark(#fff, #161b22); color: inherit; } -.vm-code:hover .vm-copy, .vm-copy:focus { opacity: 1; } - -.vm-head { cursor: pointer; } -.vm-head .vm-mark::before { content: '\\25be'; font-size: 1rem; line-height: 1.6; position: absolute; margin-left: -1.1rem; opacity: 0.35; } -.vm-head.vm-closed .vm-mark::before { content: '\\25b8'; } -.vm-root { min-height: 1.1em; margin: 0.6em 0; font-weight: 600; opacity: 0.55; } -.vm-hide { display: none; } - -/* typrose owns table typography: row borders only, and the frontmatter table stays quiet */ -th, td { border: none; } -table.frontmatter tbody tr { border-bottom: none; } - -div.output { margin-left: 1rem; padding-left: 0.8rem; border-left: 3px solid rgba(232, 141, 58, 0.45); } -div.prompt { padding-left: 0.8rem; border-left: 3px solid rgba(64, 132, 244, 0.45); } -div.reply { margin-left: 1rem; padding-left: 0.8rem; border-left: 3px solid rgba(232, 141, 58, 0.45); } - -@media (max-width: 62rem) { - body { grid-template-columns: minmax(0, 46rem); } - body > nav.toc { position: fixed; grid-column: 1; top: 0; right: 0; height: 100%; width: 15rem; - padding: 3rem 1rem 1rem; overflow: auto; max-height: none; z-index: 10; - background: light-dark(#fff, #0d1117); box-shadow: -2px 0 8px #0003; } - body:not(.vm-toc-on) > nav.toc { display: none; } -} -@media print { - .vm-controls, .vm-copy, .vm-mark, body > nav.toc { display: none; } - .vm-hide { display: revert; } -} -""" - -VIEW_JS = """ -const THEMES = __THEMES__, root = document.documentElement, ls = localStorage; -const sel = document.getElementById('vm-theme'), modeBtn = document.getElementById('vm-mode'); -let fam = ls.getItem('vm-fam') || 'auto', mode = ls.getItem('vm-mode') || 'auto'; - -const isDark = () => mode === 'auto' ? matchMedia('(prefers-color-scheme: dark)').matches : mode === 'dark'; -function applyTheme() { - root.style.colorScheme = mode === 'auto' ? 'light dark' : mode; - document.body.classList.add('prose'); - document.body.classList.toggle('prose-invert', isDark()); - const t = THEMES.find(t => t[0] === fam); - if (t) root.dataset.hl = isDark() ? t[2] : t[1]; else delete root.dataset.hl; - modeBtn.textContent = mode === 'auto' ? '\\u25d0' : isDark() ? '\\u263e' : '\\u2600'; - sel.value = fam; -} -sel.onchange = () => { fam = sel.value; ls.setItem('vm-fam', fam); applyTheme(); }; -modeBtn.onclick = () => { mode = {auto: 'light', light: 'dark', dark: 'auto'}[mode]; ls.setItem('vm-mode', mode); applyTheme(); }; -matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme); -applyTheme(); - -const tocnav = document.querySelector('nav.toc'); -document.getElementById('vm-toc').onclick = () => { - const shown = tocnav && getComputedStyle(tocnav).display !== 'none'; - document.body.classList.toggle('vm-toc-on', !shown); - document.body.classList.toggle('vm-toc-off', shown); -}; - -const links = new Map([...document.querySelectorAll('nav.toc a')].map(a => [a.getAttribute('href').slice(1), a])); -const io = new IntersectionObserver(es => es.forEach(e => { - if (!e.isIntersecting) return; - links.forEach(a => a.removeAttribute('aria-current')); - links.get(e.target.id).setAttribute('aria-current', 'true'); -}), {rootMargin: '0px 0px -70% 0px'}); -document.querySelectorAll('h1[id],h2[id],h3[id]').forEach(h => { if (links.has(h.id)) io.observe(h); }); - -document.addEventListener('click', e => { - const b = e.target.closest('.vm-copy'); - if (!b) return; - navigator.clipboard.writeText(b.parentElement.querySelector('code').textContent); - b.textContent = 'Copied'; - setTimeout(() => b.textContent = 'Copy', 1200); -}); - -const SKIP = el => el.matches('nav.toc, .vm-controls, script, style'); -const level = el => el.classList.contains('vm-root') ? 0 - : /^H[1-6]$/.test(el.tagName) ? +el.tagName[1] : null; -function sync() { - let closedAt = null; - for (const el of [...document.body.children]) { - if (SKIP(el)) continue; - const lv = level(el); - if (closedAt != null && lv != null && lv <= closedAt) closedAt = null; - el.classList.toggle('vm-hide', closedAt != null); - if (closedAt == null && lv != null && el.classList.contains('vm-closed')) closedAt = lv; - } -} -function section(h) { - const res = []; - for (let el = h.nextElementSibling; el; el = el.nextElementSibling) { - if (SKIP(el)) continue; - const lv = level(el); - if (lv != null && lv <= level(h)) break; - res.push(el); - } - return res; -} -const heads = [...document.querySelectorAll('h1[id],h2[id],h3[id]')].filter(h => h.parentElement === document.body); -if (heads.length) { - const top = Math.min(...heads.map(level)); - const first = heads.find(h => level(h) === top); - let items = heads.filter(h => level(h) === top).length; - for (const el of document.body.children) { if (el.contains(first)) break; if (!SKIP(el) && !el.matches('table.frontmatter')) { items += 1; break; } } - for (const h of heads) { - h.classList.add('vm-head'); - h.insertAdjacentHTML('afterbegin', ''); - } - if (items > 1) { - document.body.insertAdjacentHTML('afterbegin', - '
'); - document.body.querySelector('.vm-title').textContent = document.title; - } - document.addEventListener('mousedown', e => { if (e.shiftKey && e.target.closest('.vm-head')) e.preventDefault(); }); - document.addEventListener('click', e => { - const h = e.target.closest('.vm-head'); - if (!h) return; - if (e.shiftKey) { - const shut = !h.classList.contains('vm-closed'); - for (const el of [h, ...section(h)]) if (el.classList.contains('vm-head')) el.classList.toggle('vm-closed', shut); - } else h.classList.toggle('vm-closed'); - sync(); - }); -} - -function reveal(el) { - el = el || (location.hash && document.getElementById(decodeURIComponent(location.hash.slice(1)))); - if (!el) return; - let t = el; - while (t.parentElement && t.parentElement !== document.body) t = t.parentElement; - let ml = level(t) ?? Infinity; - for (let p = t.previousElementSibling; p; p = p.previousElementSibling) { - const lv = level(p); - if (lv == null || lv >= ml) continue; - p.classList.remove('vm-closed'); - ml = lv; - } - sync(); - el.scrollIntoView(); -} -addEventListener('hashchange', () => reveal()); -document.addEventListener('click', e => { - const a = e.target.closest('a[href^="#"]'); - if (a) reveal(document.getElementById(decodeURIComponent(a.getAttribute('href').slice(1)))); -}); -reveal(); -""" -CONTROLS = """
- -
""" +_ASSETS = Path(__file__).parent +VIEW_CSS = (_ASSETS/"view.css").read_text() +VIEW_JS = (_ASSETS/"view.js").read_text() +CONTROLS = (_ASSETS/"controls.html").read_text() def _copy_wrap(html, lang, text): @@ -212,77 +40,26 @@ def assets(): def _head_section(path): "A file's contents as a head section: .css in `" if p.suffix == ".js": return f"" return t -def _jn(v): - "Notebook JSON keeps some text as line lists; normalize to one string" - return "".join(v) if isinstance(v, (list, tuple)) else (v or "") - - -def _outputs_md(outputs): - "A message's outputs as Markdown: text-ish parts pooled into ```output fences, HTML through ```{=html}, markdown inlined, images as data URIs" - parts, buf = [], [] - def flush(): - txt = strip_ansi("".join(buf)).rstrip() - if txt: parts.append(fenced(txt, "output")) - buf.clear() - for o in outputs: - t, data = o.get("output_type"), o.get("data") or {} - if t == "stream": buf.append(_jn(o.get("text"))) - elif t == "error": buf.append("\n".join(o.get("traceback") or ()) + "\n") - elif t in ("execute_result", "display_data"): - if "text/html" in data: - flush() - parts.append(fenced(_jn(data["text/html"]).strip(), "{=html}")) - elif "text/markdown" in data: - flush() - parts.append(_jn(data["text/markdown"]).strip()) - elif "image/png" in data: - flush() - parts.append(f"![](data:image/png;base64,{''.join(_jn(data['image/png']).split())})") - elif "text/plain" in data: buf.append(_jn(data["text/plain"]) + "\n") - flush() - return "\n\n".join(parts) - - -def _msg_md(m): - "One message as Markdown: notes verbatim, code fenced with its outputs, prompts and replies in `::: prompt`/`::: reply` divs" - if m.msg_type == "note": return m.content - if m.msg_type == "raw": return fenced(m.content) - if m.msg_type == "prompt": - parts = [fenced(m.content, " prompt", ch=":")] - if (ai := (m.ai_res or "")).strip(): parts.append(fenced(fmt_tools(ai), " reply", ch=":")) - return "\n\n".join(parts) - if not m.content.strip(): return None - parts = [fenced(m.content, "python")] - outs = _outputs_md(m.output or ()) - if outs: parts.append(fenced(outs, " output", ch=":")) - return "\n\n".join(parts) - - -def _nb2md(path): - "A notebook as Markdown via the dialog model: notes verbatim, code in ```python fences, outputs in `::: output` divs, prompts and replies in `::: prompt`/`::: reply` divs" - return "\n\n".join(filter(None, map(_msg_md, read_ipynb(path)))) + "\n" - - @call_parse(pos=['file']) @delegates(parse_args) def main( file: str = None, # Markdown file (or .ipynb notebook) to view (default: stdin) - refs: RefsMode = RefsMode.lenient, # Bake references as target ids, with numbering ('resolve'), or numbering that degrades to ids ('lenient') - number_headings: str = None, # Heading numbering scheme: 'legal' or 'decimal' + refs: RefsMode = RefsMode.lenient, # References: target ids ('ids'), numbered ('resolve'), or numbered with ids as fallback ('lenient') + number_headings: NumMode = None, # Heading numbering scheme hl: HlMode = HlMode.spans, # Code highlighting: classed spans, the Highlight API, or off auto_ids: bool = True, # Derive ids for headings implicit_figures: bool = True, # Promote image-only paragraphs to figures - frontmatter: bool = True, # Recognize leading `key: value` frontmatter: strip it, title the page, prepend a metadata table - head: Annotated[str, "Extra head section: a .css/.js file (inlined in