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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,7 @@ In `resolve` mode, cross-references become links with resolved text. `[@sec-pay]

Two other modes support previews and drafts:

- `refs='ids'` displays each target id as a link, such as `<a href="#sec-pay" class="xref">sec-pay</a>`. Authored text remains as a prefix. Variants are ignored. This mode needs no target registry and performs no numbering or reference validation. Captions remain as authored. Use it when targets can be outside the fragment being previewed, where numbering would otherwise restart for each fragment.
- `refs='ids'` displays each target id as a link, such as `<a href="#sec-pay" class="xref">sec-pay</a>`. Authored text remains as a prefix. Variants are ignored. This mode does not validate references. Headings are numbered when the argument or frontmatter supplies a scheme. It never numbers headings automatically. Captions remain as authored. Use it when targets can be outside the fragment being previewed, where numbering would otherwise restart for each fragment.
- `refs='lenient'` resolves and numbers references as in `resolve` mode. An unresolved reference falls back to its `ids` link and adds a warning instead of raising.

Use `id_prefix='md-'` to distinguish exported ids from those of the host page. Every element id receives the prefix. The original id remains in `data-id`, for uses such as CSS `attr()` markers. Reference hrefs and links to in-fragment ids receive the prefix too. Links to outside ids are unchanged.
Expand All @@ -464,7 +464,9 @@ Use `id_prefix='md-'` to distinguish exported ids from those of the host page. E

#### Numbering

Set `number_headings` to `'legal'`, `'decimal'`, or a `{lvlText: numFmt}` dictionary as in mdhtml2docx. If a reference needs a heading number and no scheme was supplied, numbering uses `'decimal'`.
Set `number_headings` to `'legal'`, `'decimal'`, or a `{lvlText: numFmt}` dictionary as in mdhtml2docx. When the argument is omitted, HTML, GFM, and Typst exporters use the document's frontmatter `number_headings` setting. If a reference needs a heading number and neither source supplies a scheme, numbering uses `'decimal'`.

For example, put `number_headings: legal` in frontmatter and run `md2html contract.md --frontmatter`. Headings use legal numbering without a separate numbering option. `viewmd contract.ipynb` also reads this setting from the notebook's frontmatter cell.

Heading numbers appear in `<span class="heading-number">` elements. Reference text includes the full context, such as "3.(c)(iii)", computed from the scheme using Word's rules.

Expand Down
6 changes: 6 additions & 0 deletions py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ type TemplateArg = (String, String, String, Option<(String, String)>, String, Op
/// Frontmatter key/value pairs in source order.
type Meta = Vec<(String, String)>;

/// The flat `meta` pairs of a leading frontmatter block, by the dialect's rule,
/// without parsing the document: `[]` when it opens with none.
#[pyfunction]
fn frontmatter_meta(src: &str) -> Meta { mdhtml::frontmatter::extract(src).map(|(m, _)| m).unwrap_or_default() }

#[pyfunction]
#[pyo3(signature = (
markdown,
Expand Down Expand Up @@ -434,6 +439,7 @@ impl Resolver {
#[pymodule]
fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(md2mdhtml, m)?)?;
m.add_function(wrap_pyfunction!(frontmatter_meta, m)?)?;
m.add_function(wrap_pyfunction!(mdhtml2md, m)?)?;
m.add_function(wrap_pyfunction!(wrap_md, m)?)?;
m.add_function(wrap_pyfunction!(md_chunks, m)?)?;
Expand Down
13 changes: 11 additions & 2 deletions python/mdhtml/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ def math_js(fn=None, **opts):
return _math_js(fn, "".join(f", {k}: {json.dumps(v)}" for k, v in opts.items()))


def _headnums(src, number_headings):
"The call's `number_headings`, else the source's frontmatter `number_headings:` (an `Mdhtml` carries its `meta`)"
return number_headings if number_headings is not None else getattr(src, "meta", {}).get("number_headings")


def meta_table(meta):
"Frontmatter metadata (`md2mdhtml`'s `meta` dict) as a small `<table class=\"frontmatter\">`, for prepending to rendered output"
rows = "".join(f"<tr><th>{escape(k)}</th><td>{escape(v)}</td></tr>" for k, v in meta.items())
Expand Down Expand Up @@ -93,8 +98,9 @@ def mdhtml2html(src, dest=None, reftypes: dict | None = None, number_headings=No
`gh_ids=True` derives them by GitHub's rules instead (github-slugger's), so anchors match a
GitHub-rendered page and links written against one keep working.
`refs='ids'` instead bakes each reference as a working link showing its
target id (class `xref`), with no registry, numbering, or failure modes - for live-preview
contexts where targets may sit outside the fragment. `refs='lenient'` sits between the two:
target id (class `xref`), with no registry or failure modes - for live-preview
contexts where targets may sit outside the fragment; a given scheme still numbers the
headings there (per fragment), but nothing numbers automatically. `refs='lenient'` sits between the two:
references resolve and number as usual, and any that cannot resolve bake as `ids` links and
are reported in `.warnings` rather than raising - for drafts, where some targets are still
to be written. `id_prefix` namespaces the output's ids:
Expand All @@ -106,8 +112,11 @@ def mdhtml2html(src, dest=None, reftypes: dict | None = None, number_headings=No
may return replacement markup for the highlighted block (None keeps it; `text` is unescaped).
Highlighting comes from the optional fastpylight package (`pip install 'mdhtml[hl]'`);
without it, code blocks render plain and a warning reports it.
`number_headings=None` takes the scheme from the source's frontmatter `number_headings:` when
`src` is `md2mdhtml`'s result (its `meta` carries the block), else numbers automatically.
Returns an `Html` str carrying `.warnings`; `dest` also writes it to a file."""
if refs not in ("resolve", "ids", "lenient"): raise ValueError(f"unknown refs mode {refs!r}")
number_headings = _headnums(src, number_headings)
if not isinstance(src, str): src = src.to_html()
hl_fn = None if hl is None else _hl_fn(hl)
out, warnings = _export_html(src, reftypes, number_headings, hl, toc, refs, id_prefix, fn_salt, hl_lang, code_wrap, hl_fn, auto_ids, gh_ids)
Expand Down
7 changes: 5 additions & 2 deletions python/mdhtml/md.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
from hashlib import sha256
from dataclasses import astuple, is_dataclass

from ._native import blocks as _blocks, edit_nodes as _edit_nodes, anchors as _anchors, trailing_attr_span as _trailing_attr_span
from ._native import (blocks as _blocks, edit_nodes as _edit_nodes, anchors as _anchors, trailing_attr_span as _trailing_attr_span,
frontmatter_meta as _frontmatter_meta)
from .export import HeadingNums, Resolver, group_plan, ref_tokens, ref_variant

__all__ = ["md2gfm"]
Expand Down Expand Up @@ -245,8 +246,10 @@ def md2gfm(src, dest=None, reftypes: dict | None = None, number_headings=None, m
each template token is rewritten to whatever the
`tmpl` callable `(node) -> str` returns: the node dict carries `body`, `syntax`, `form`,
scanner classification (`kind`, `name`, `inverted`), and spans (`mustache_code` is a ready-made recipe;
without `tmpl`, tokens pass through). All other source text is preserved byte-for-byte.
without `tmpl`, tokens pass through). All other source text is preserved byte-for-byte,
the frontmatter included; `number_headings=None` takes the scheme from its `number_headings:`.
Returns an `Md` str carrying `.warnings`; `dest` also writes it to a file."""
if number_headings is None: number_headings = dict(_frontmatter_meta(src)).get("number_headings")
normalized, offsets = _normalize_offsets(src)
imgbase = Path(dest).parent if dest is not None else Path(".")
ex = _GfmExporter(reftypes, number_headings, math, implicit_figures, templates, tmpl,
Expand Down
2 changes: 1 addition & 1 deletion python/mdhtml/md2html.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,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: NumMode = None, # Heading numbering scheme
number_headings: NumMode = None, # Heading numbering scheme (default: the frontmatter's `number_headings:`, else automatic)
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 `fastpylight.themes()`
Expand Down
9 changes: 6 additions & 3 deletions python/mdhtml/typst.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
from pathlib import Path

from fast5ever import Element, Text, parse_fragment as mdhtml2dom
from .export import _HEADS, _RAW_TYPE, _els, _text, HeadingNums, Resolver, decode_raw, tmpl_node as _tmpl_node, group_plan, ref_tokens, ref_variant, target_kind
from .export import (_HEADS, _RAW_TYPE, _els, _text, _headnums, HeadingNums, Resolver, decode_raw, tmpl_node as _tmpl_node, group_plan,
ref_tokens, ref_variant, target_kind)

__all__ = ["mdhtml2typst", "mdhtml2pdf"]

Expand Down Expand Up @@ -322,8 +323,10 @@ def mdhtml2typst(src, dest=None, reftypes: dict | None = None, number_headings=N
(a dict containing `op`, operand `value`, and DOM `form`;
`None` drops them). `table_styles` maps a table's `custom-style` name or class (matched in that
order, case-insensitively) to extra Typst table arguments, e.g. `{'borderless table': 'stroke: none'}`.
`prelude` text is prepended before the generated setup. Returns a `Typst`
str carrying `.warnings`; `dest` also writes it to a file."""
`prelude` text is prepended before the generated setup. `number_headings=None` takes the
scheme from the source's frontmatter `number_headings:` when `src` is `md2mdhtml`'s result.
Returns a `Typst` str carrying `.warnings`; `dest` also writes it to a file."""
number_headings = _headnums(src, number_headings)
if not isinstance(src, str): src = src.to_html()
ex = _TypstExporter(reftypes, number_headings, tmpl, table_styles)
body = ex.run(mdhtml2dom(src))
Expand Down
9 changes: 7 additions & 2 deletions python/mdhtml/viewmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from aidialog.dialog import dlg2md
from aidialog.ipynb import read_ipynb
from fastcore.nbio import nb_frontmatter

from . import DASHES, replacements, mdhtml2html, md2mdhtml
from .export import _fastpylight
Expand Down Expand Up @@ -53,15 +54,19 @@ def _head_section(path):
def main(
file: str = None, # Markdown file (or .ipynb notebook) to view (default: stdin)
refs: RefsMode = RefsMode.lenient, # References: target ids ('ids'), numbered ('resolve'), or numbered with ids as fallback ('lenient')
number_headings: NumMode = None, # Heading numbering scheme
number_headings: NumMode = None, # Heading numbering scheme (default: the frontmatter's `number_headings:`, else automatic)
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, # Strip leading `key: value` frontmatter into the page title and a metadata table
head: Annotated[str, "File inlined into the page head: .css as <style>, .js as <script>, else raw HTML; repeatable", dict(action="append")] = None,
**kwargs):
"Render Markdown (or a Jupyter notebook) to a page with the viewer UI, and open it in a browser"
text = dlg2md(read_ipynb(file)) if file and file.endswith(".ipynb") else read_src(file)
if file and file.endswith(".ipynb"):
nb = read_ipynb(file)
text = dlg2md(nb)
if number_headings is None: number_headings = nb_frontmatter(nb, strvals=True).get("number_headings")
else: text = read_src(file)
src = md2mdhtml(text, implicit_figures=implicit_figures, frontmatter=frontmatter,
templates=MUSTACHE, callbacks={'template_token': mustache_pill, 'text': replacements(*DASHES)}, **kwargs)
html = mdhtml2html(src, auto_ids=auto_ids, refs=refs, number_headings=number_headings, toc=True,
Expand Down
3 changes: 3 additions & 0 deletions src/export_html.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ impl Exporter {
els.iter().copied().filter(|&e| ename(&self.dom, e) == Some("a") && self.dom.attr(e, "data-ref").is_some() && !grouped.contains(&e)).collect();
let lenient = opts.refs == RefsMode::Lenient;
if opts.refs == RefsMode::Ids {
// A requested scheme still numbers the headings; with no
// registry, nothing numbers automatically.
self.number_headings(&[], opts)?;
for &g in &groups { self.lower_group_ids(g, opts); }
for &a in &singles { self.bake_id(a, opts); }
}
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ pub mod chunk;
pub mod diagnostic;
mod entity;
pub mod export_html;
mod frontmatter;
pub mod frontmatter;
mod highlight;
mod inline;
mod line;
Expand Down
20 changes: 19 additions & 1 deletion tests/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import pytest

from mdhtml import TemplateDelimiter, dialect_css, math_js, mdhtml2dom, mdhtml2html, md2gfm, md2mdhtml
from mdhtml import TemplateDelimiter, dialect_css, math_js, mdhtml2dom, mdhtml2html, mdhtml2typst, md2gfm, md2mdhtml
from mdhtml.mustache import MUSTACHE, mustache_pill
from mdhtml.export import SCHEMES

Expand Down Expand Up @@ -34,6 +34,24 @@ def test_refs_and_heading_numbering():
assert '<a href="#sec-b">Section 1.1</a>' in d


def test_frontmatter_selects_numbering():
"A frontmatter `number_headings:` numbers like the call argument, which wins when given; `ids` mode numbers on request only"
fm = '---\nnumber_headings: legal\n---\n\n# T\n\n## A {#sec-a}\n\n### B {#sec-b}\n\nSee [@sec-b].\n'
src = md2mdhtml(fm)
h = mdhtml2html(src)
assert '<span class="heading-number">(a)</span> B' in h and '<a href="#sec-b">Section 1.(a)</a>' in h
assert '<span class="heading-number">1.1.</span> B' in mdhtml2html(src, number_headings='decimal') # the argument wins
assert '<span class="heading-number">1.1.</span> B' in mdhtml2html(str(src)) # a bare string carries no meta: automatic
with pytest.raises(ValueError, match="unknown numbering scheme 'roman'"):
mdhtml2html(md2mdhtml('---\nnumber_headings: roman\n---\n\n## A\n'))
ids = mdhtml2html(src, refs='ids')
assert '<span class="heading-number">(a)</span> B' in ids and 'class="xref">sec-b</a>' in ids
assert 'heading-number' not in mdhtml2html(md2mdhtml('## A {#sec-a}\n\nSee [@sec-a].'), refs='ids') # never automatically
g = md2gfm(fm)
assert g.startswith('---\nnumber_headings: legal\n---\n') and '### (a) B\n' in g and 'See Section 1.(a).' in g
assert 'numbering("a", n.at(2))' in mdhtml2typst(src)


def test_ref_errors():
with pytest.raises(ValueError, match='not found'): mdhtml2html(md2mdhtml('See [@sec-x].'))
auto = mdhtml2html(md2mdhtml('## A {#sec-a}\n\nSee [@sec-a].')) # refs trigger auto decimal numbering
Expand Down
9 changes: 8 additions & 1 deletion tests/test_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -929,7 +929,7 @@ def test_table_width_lowering():


def test_viewmd_main_writes_page(tmp_path, monkeypatch):
import webbrowser
import json, webbrowser
from mdhtml import viewmd
monkeypatch.setattr(webbrowser, "open", lambda uri: None)
monkeypatch.setattr(viewmd, "CACHE", tmp_path / "cache")
Expand All @@ -938,3 +938,10 @@ def test_viewmd_main_writes_page(tmp_path, monkeypatch):
viewmd.main.__wrapped__(str(src))
h = (tmp_path / "cache" / "doc.html").read_text()
assert "<h1" in h and "<em>text</em>" in h # page written via Path.mk_write, dirs created
srcs = ["---\nnumber_headings: legal\n---\n", "# T", "## A", "### B"]
cells = [dict(id=f"c{i}", cell_type="raw" if i == 0 else "markdown", metadata={}, source=s) for i, s in enumerate(srcs)]
nb = tmp_path / "dlg.ipynb"
nb.write_text(json.dumps(dict(cells=cells, metadata={}, nbformat=4, nbformat_minor=5)))
viewmd.main.__wrapped__(str(nb))
h = (tmp_path / "cache" / "dlg.html").read_text()
assert '<span class="heading-number">(a)</span> B' in h # the frontmatter message's scheme, though dlg2md fences it as code
Loading