Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ conda/
*~
~*
_tmp*
tmp*
tags

# Byte-compiled / optimized / DLL files
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<details>` in HTML with a first-child heading as `<summary>`, 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
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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"
3 changes: 3 additions & 0 deletions python/mdhtml/controls.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<div class="vm-controls"><button id="vm-toc" type="button" title="Contents">☰</button>
<select id="vm-theme"><option value="auto">Theme</option>__OPTS__</select>
<button id="vm-mode" type="button" title="Light/dark"></button></div>
20 changes: 3 additions & 17 deletions python/mdhtml/md2html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()`
Expand Down
15 changes: 15 additions & 0 deletions python/mdhtml/page.css
Original file line number Diff line number Diff line change
@@ -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; }
51 changes: 51 additions & 0 deletions python/mdhtml/view.css
Original file line number Diff line number Diff line change
@@ -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; }
}
114 changes: 114 additions & 0 deletions python/mdhtml/view.js
Original file line number Diff line number Diff line change
@@ -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', '<span class="vm-mark" title="Click folds this section; shift-click folds its subsections too"></span>');
}
if (items > 1) {
document.body.insertAdjacentHTML('afterbegin',
'<div class="vm-root vm-head"><span class="vm-mark" title="Click folds the document; shift-click folds every section"></span><span class="vm-title"></span></div>');
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();
Loading