From 46493bca9a069dab0bfddf1a3e29b0f0fe9251d6 Mon Sep 17 00:00:00 2001 From: Piotr Czapla Date: Thu, 3 Sep 2026 15:27:14 +0200 Subject: [PATCH] Frontmatter number_headings selects the heading numbering; refs='ids' numbers on request A document's frontmatter `number_headings: legal` (or `decimal`) now drives every exporter when the call gives no scheme: `mdhtml2html` and `mdhtml2typst` read it from the `Mdhtml` result's `meta`, `md2gfm` extracts it from the raw source by the dialect's own rule (new native `frontmatter_meta`), and `viewmd` reads a notebook's frontmatter message through fastcore's `nb_frontmatter` since `dlg2md` fences that raw cell as code. The call argument still wins when both are present. `refs='ids'` (the live-preview mode Solveit renders in) used to skip numbering entirely; a requested scheme now numbers the fragment's headings there too, while nothing numbers automatically without a registry. Co-Authored-By: Claude Fable 5.1 --- README.md | 8 ++++---- docs/DIALECT.md | 4 ++-- python/mdhtml/export.py | 13 +++++++++++-- python/mdhtml/md.py | 7 +++++-- python/mdhtml/md2html.py | 2 +- python/mdhtml/typst.py | 9 ++++++--- python/mdhtml/viewmd.py | 8 ++++++-- src/export_html.rs | 3 +++ src/python.rs | 6 ++++++ tests/test_export.py | 20 +++++++++++++++++++- tests/test_python.py | 9 ++++++++- 11 files changed, 71 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 602961d..d99305b 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ md2html --theme=onedark --hl=api input.md --out - `viewmd` is `md2html`'s page plus a small viewer UI, always opened in a browser: a theme picker and light/dark toggle, a collapsible table of contents with scrollspy (responsive below a breakpoint, ☰ to pin it either way), fold triangles on headings (shift-click folds a section's subsections too), copy buttons on code blocks, and mermaid diagrams drawn in place. Frontmatter handling is on by default (`--no-frontmatter` for raw), `DASHES` typography (en/em dashes and ellipses) is applied to plain text, references default to `--refs=lenient`, and `--head` inlines extra `.css`/`.js` files into the page — `examples/sample.css` and `examples/sample.js` show it styling the sample's custom attributes. -`viewmd` also renders Jupyter notebooks: pass a `.ipynb` file and each code cell appears as a highlighted `python` block with its stored outputs beneath it in a bordered `output` section — streams, results, and tracebacks as text, HTML display objects (DataFrames and the like) rendered live, and images inlined. Markdown cells are ordinary Markdown, so cross-references, footnotes, math, and frontmatter all work; `examples/nbsample.ipynb` is a small demo. Solveit dialogs render too: each prompt appears in a bordered `prompt` section with the AI reply indented beneath it in a `reply` section, and tool calls in replies (fastllm's fenced JSON blocks) shown as folded details with a code-span label. +`viewmd` also renders Jupyter notebooks: pass a `.ipynb` file and each code cell appears as a highlighted `python` block with its stored outputs beneath it in a bordered `output` section — streams, results, and tracebacks as text, HTML display objects (DataFrames and the like) rendered live, and images inlined. Markdown cells are ordinary Markdown, so cross-references, footnotes, math, and frontmatter all work, and a notebook's frontmatter message (a raw cell) supplies `number_headings` the same way a document's block does; `examples/nbsample.ipynb` is a small demo. Solveit dialogs render too: each prompt appears in a bordered `prompt` section with the AI reply indented beneath it in a `reply` section, and tool calls in replies (fastllm's fenced JSON blocks) shown as folded details with a code-span label. ```bash viewmd README.md @@ -143,7 +143,7 @@ The same paragraph reflowing is available as `wrap_md(markdown, width=None)`. It The result is a `str` subclass whose `warnings` list names any construct whose closer never arrived — an unclosed `:::` div, code fence, math block, raw HTML container, or comment — each with its opening line number. The render itself closes them at end of input, so a viewer can show the page and append the warnings after it. Both CLIs print them to stderr. -The result's `meta` dict holds the document's frontmatter: a leading block of `key: value` lines between `---` fences, recognized by default (`frontmatter=False` turns it off), stripped from the content, and never parsed as YAML — values are plain strings. A document that opens with `---` but doesn't fit that shape — a heading or prose inside, no closing fence, no keys at all — is left untouched, so a leading thematic break still parses as one. `md2html --frontmatter` (and `viewmd`, where it is on by default) uses `meta` to title the page and prepend a small metadata table (`meta_table(meta)` builds it). +The result's `meta` dict holds the document's frontmatter: a leading block of `key: value` lines between `---` fences, recognized by default (`frontmatter=False` turns it off), stripped from the content, and never parsed as YAML — values are plain strings. A document that opens with `---` but doesn't fit that shape — a heading or prose inside, no closing fence, no keys at all — is left untouched, so a leading thematic break still parses as one. `md2html --frontmatter` (and `viewmd`, where it is on by default) uses `meta` to title the page and prepend a small metadata table (`meta_table(meta)` builds it). One key is read by the exporters themselves: `number_headings: legal` (or `decimal`) selects the heading numbering scheme whenever a call gives none, so a document carries its own numbering into `mdhtml2html`, `md2gfm`, and `mdhtml2typst`. ### Markdown chunks @@ -363,8 +363,8 @@ html = mdhtml2html(md2mdhtml(markdown), number_headings='legal') The result is still a body fragment (a str subclass carrying a `warnings` list; pass `dest=` to also write a file). `mdhtml2html` accepts an MDHTML string or a fast5ever node, never mutates its input, and applies: -- Cross-references become real links with baked text: `[@sec-pay]` renders as `Section 1.`, groups join as "Sections 1. and 1.(a)", and figure and table targets get "Figure 1"-style text. `reftypes=dict(exh=('Exhibit', 'Exhibits'))` adds prefix words beyond the built-in `sec`, `fig`, and `tbl`. A missing target, an unknown token, or an unknown type needing a prefix raises. The Word-only `page` and `rel` variants render as the full number. `refs='ids'` is the second mode, for live-preview contexts where targets may sit outside the fragment: each reference bakes as a working link showing its target id (`sec-pay`, author text kept as a prefix, variants ignored), with no registry, numbering, or failure modes; captions render as authored, since without a registry the numbers would restart per fragment. `refs='lenient'` is the third, for drafts: everything resolves and numbers as in `resolve` mode, except that each reference which cannot resolve bakes as its `ids` link and is reported in `warnings` instead of raising. `id_prefix='md-'` namespaces the output against the ids of a host page: every element id is prefixed (the original kept in `data-id`, e.g. for CSS `attr()` markers), along with ref hrefs and any link to an in-fragment id; links to outside ids are untouched. `fn_salt` adds a further prefix to footnote ids only (`fn-*`/`fnref-*`), keeping footnote pairs distinct across fragments that share one `id_prefix`. -- Headings are numbered when `number_headings` is given ('legal', 'decimal', or a `{lvlText: numFmt}` dict as in mdhtml2docx), or automatically with 'decimal' when some reference needs a heading number. Numbers bake in as ``, and full-context reference text ("3.(c)(iii)") is computed Word-style from the scheme. Scheme level 0 is the h1 document title: its empty lvlText shows no number, and bumping it restarts every level below (Word's own rule), so `%2` is the h2 counter and a file holding several documents, each opening with an h1, numbers each of them from 1. A custom dict has the same shape, title entry first. A title cannot be cited by number; cite it with `{ref=text}`. +- Cross-references become real links with baked text: `[@sec-pay]` renders as `Section 1.`, groups join as "Sections 1. and 1.(a)", and figure and table targets get "Figure 1"-style text. `reftypes=dict(exh=('Exhibit', 'Exhibits'))` adds prefix words beyond the built-in `sec`, `fig`, and `tbl`. A missing target, an unknown token, or an unknown type needing a prefix raises. The Word-only `page` and `rel` variants render as the full number. `refs='ids'` is the second mode, for live-preview contexts where targets may sit outside the fragment: each reference bakes as a working link showing its target id (`sec-pay`, author text kept as a prefix, variants ignored), with no registry or failure modes; captions render as authored, since without a registry the numbers would restart per fragment, and headings number only when a scheme is requested (never automatically). `refs='lenient'` is the third, for drafts: everything resolves and numbers as in `resolve` mode, except that each reference which cannot resolve bakes as its `ids` link and is reported in `warnings` instead of raising. `id_prefix='md-'` namespaces the output against the ids of a host page: every element id is prefixed (the original kept in `data-id`, e.g. for CSS `attr()` markers), along with ref hrefs and any link to an in-fragment id; links to outside ids are untouched. `fn_salt` adds a further prefix to footnote ids only (`fn-*`/`fnref-*`), keeping footnote pairs distinct across fragments that share one `id_prefix`. +- Headings are numbered when `number_headings` is given ('legal', 'decimal', or a `{lvlText: numFmt}` dict as in mdhtml2docx), when the source's frontmatter names a scheme (`number_headings: legal`; the call argument wins when both are present), or automatically with 'decimal' when some reference needs a heading number. Numbers bake in as ``, and full-context reference text ("3.(c)(iii)") is computed Word-style from the scheme. Scheme level 0 is the h1 document title: its empty lvlText shows no number, and bumping it restarts every level below (Word's own rule), so `%2` is the h2 counter and a file holding several documents, each opening with an h1, numbers each of them from 1. A custom dict has the same shape, title entry first. A title cannot be cited by number; cite it with `{ref=text}`. - Figures and tables number independently whenever refs resolve: a caption or an id earns a `Figure 1: ` in the `figcaption` or `caption`. - `{=html}` raw data is decoded and spliced in place; raw data for other formats is removed. Malformed payloads are dropped with a warning. - A `colwidths` attribute lowers to a ``; `fr` values share the width remaining after fixed lengths. diff --git a/docs/DIALECT.md b/docs/DIALECT.md index e7714df..af71e49 100644 --- a/docs/DIALECT.md +++ b/docs/DIALECT.md @@ -101,7 +101,7 @@ Automatic heading ids are an export concern, not part of the parse: `md2mdhtml` Parse options which infer document structure are off by default. Explicit Markdown syntax remains enabled: for example, an explicit heading id is emitted without any option, and bracket math is recognized because its delimiters state the author's intent. `implicit_figures` enables an inferred transformation. -With `frontmatter=True` (the default), a document opening with a `---` line, closed by a `---` or `...` line, whose every non-blank, non-comment line between is `key: value` (at least one), is document metadata rather than content: the parse strips it and returns the pairs as `meta`, with values taken verbatim — no YAML types, one matching pair of surrounding quotes removed. A leading block that doesn't fit this shape is content as usual, so a document starting with a thematic break renders one. Frontmatter never reaches the fragment; consumers decide its rendering (page title, a metadata table) from `meta`. +With `frontmatter=True` (the default), a document opening with a `---` line, closed by a `---` or `...` line, whose every non-blank, non-comment line between is `key: value` (at least one), is document metadata rather than content: the parse strips it and returns the pairs as `meta`, with values taken verbatim — no YAML types, one matching pair of surrounding quotes removed. A leading block that doesn't fit this shape is content as usual, so a document starting with a thematic break renders one. Frontmatter never reaches the fragment; consumers decide its rendering (page title, a metadata table) from `meta`. One key has dialect meaning: `number_headings` names the heading numbering scheme (see Cross-references and numbering), which the exporters apply when a call gives none. ## Links, images, and figures @@ -341,7 +341,7 @@ The parser does not resolve numbers or require targets to exist. Converters repo Reference targets are the id-bearing headings, paragraphs, figures, tables, spans, and definition terms. Headings resolve to a heading number ("Section 1"), figures and tables to a caption number ("Table 2"), and paragraphs only through `{ref=text}`. Spans and definition terms resolve to their own text with no prefix word, so `the [@def-term] period` reads as running prose citing the defined term, and a rendering variant that needs a number (`leaf`, `rel`) is an error for them. Converters with a link mechanism link the cited text back to its definition site; `md2gfm` renders the text alone. -The shipped exporters lower references from one shared vocabulary (`mdhtml.export`) at three levels of liveness: `mdhtml2docx` bakes REF fields Word keeps live, `mdhtml2html` bakes links with computed text, and `md2gfm` bakes plain text. Prefix words come from `REFTYPES` (`sec`, `fig`, `tbl`; extended per call with `reftypes=`) and heading numbering from `SCHEMES` (`'legal'`, `'decimal'`, or a `{lvlText: numFmt}` dict). `number_headings=None` means automatic: headings are numbered exactly when some reference needs a heading number. Scheme level 0 is the h1 document title, with an empty lvlText: it shows no number, and bumping it restarts every level below, so `%2` is the h2 counter and each document in a file that opens with its own h1 numbers from 1; a title is cited with `{ref=text}`, never by number. +The shipped exporters lower references from one shared vocabulary (`mdhtml.export`) at three levels of liveness: `mdhtml2docx` bakes REF fields Word keeps live, `mdhtml2html` bakes links with computed text, and `md2gfm` bakes plain text. Prefix words come from `REFTYPES` (`sec`, `fig`, `tbl`; extended per call with `reftypes=`) and heading numbering from `SCHEMES` (`'legal'`, `'decimal'`, or a `{lvlText: numFmt}` dict). `number_headings=None` defers to the frontmatter's `number_headings:` when the source has one, else means automatic: headings are numbered exactly when some reference needs a heading number. Scheme level 0 is the h1 document title, with an empty lvlText: it shows no number, and bumping it restarts every level below, so `%2` is the h2 counter and each document in a file that opens with its own h1 numbers from 1; a title is cited with `{ref=text}`, never by number. `mdhtml2html` also offers `refs='ids'` for live-preview contexts where targets may sit outside the fragment: each reference bakes as a working link showing its target id (class `xref`), with no registry, numbering, or failure modes - and captions render as authored, since per-fragment numbers would lie. `id_prefix` namespaces the fragment's ids against a host page (the authored id kept in `data-id`), and `fn_salt` adds a further prefix to the `fn-*`/`fnref-*` footnote namespace only, keeping footnote pairs distinct across fragments that share one `id_prefix`. diff --git a/python/mdhtml/export.py b/python/mdhtml/export.py index f2b0b71..fda7e49 100644 --- a/python/mdhtml/export.py +++ b/python/mdhtml/export.py @@ -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 ``, for prepending to rendered output" rows = "".join(f"" for k, v in meta.items()) @@ -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: @@ -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) diff --git a/python/mdhtml/md.py b/python/mdhtml/md.py index b2f1e5f..7464cce 100644 --- a/python/mdhtml/md.py +++ b/python/mdhtml/md.py @@ -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"] @@ -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, diff --git a/python/mdhtml/md2html.py b/python/mdhtml/md2html.py index 9dcd587..543d46a 100644 --- a/python/mdhtml/md2html.py +++ b/python/mdhtml/md2html.py @@ -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()` diff --git a/python/mdhtml/typst.py b/python/mdhtml/typst.py index 45113da..e35fbf3 100644 --- a/python/mdhtml/typst.py +++ b/python/mdhtml/typst.py @@ -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"] @@ -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)) diff --git a/python/mdhtml/viewmd.py b/python/mdhtml/viewmd.py index 19f74c8..06b4c18 100644 --- a/python/mdhtml/viewmd.py +++ b/python/mdhtml/viewmd.py @@ -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 @@ -53,7 +54,7 @@ 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 @@ -61,7 +62,10 @@ def main( head: Annotated[str, "File inlined into the page head: .css as
{escape(k)}{escape(v)}