diff --git a/docs/DIALECT.md b/docs/DIALECT.md
index 70f1ea7..aaec99b 100644
--- a/docs/DIALECT.md
+++ b/docs/DIALECT.md
@@ -90,6 +90,10 @@ Headings use `h1` through `h6`; paragraphs, thematic breaks, and block quotes us
Automatic heading ids are an export concern, not part of the parse: `to_mdhtml` emits only authored ids, so identical fragments render identically wherever they later appear. `to_html`'s `auto_ids` option (on by default there) derives an id for each heading without one, and any converter that derives section ids must use the same rules: text is lowercased; whitespace becomes `-`; characters other than letters, numbers, `_`, `-`, and `.` are removed; leading nonletters are removed; and an empty result becomes `section`. Duplicate ids receive `-1`, `-2`, and so on, deduplicated within one export. Explicit ids win and participate in duplicate detection. So `## Hello, world!` exports as `
Hello, world!
`. Embedders rendering several fragments into one page pass `auto_ids=False`, since per-fragment derivation cannot see a sibling fragment's ids.
+`to_html`'s `slug='github'` derives those ids by GitHub's rules instead of the rules above. It is for documents that already live on GitHub, where an anchor is a published address: a wiki page's own table of contents, a `README` section link, or any link written against the rendered page. Those links break under the default rules, so the mode trades dialect consistency for keeping them working, and is not the default anywhere. The rules, as `github-slugger` implements them: text is lowercased; characters other than letters, numbers, marks, `_`, `-`, and the space are removed; each remaining space becomes `-`. Duplicates dedupe as above.
+
+Three corners differ from the default rules and are the reason the mode exists. Only the space U+0020 becomes `-`, so other whitespace is dropped outright and a heading broken across source lines slugs as one word. The text is neither trimmed nor whitespace-collapsed, so a heading opening with an image keeps the space the image left and gains a leading `-`, and a doubled space gives `--`. There is no `section` fallback, so a heading with no slug characters left takes the empty id, and repeats of it dedupe to `-1`, `-2` like any other. Two consequences follow for ordinary prose: a heading ending in a period keeps no period, and a heading that opens with digits keeps them, where the default rules strip to the first letter and fall back to `section` when none remains.
+
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`.
diff --git a/python/mdhtml/export.py b/python/mdhtml/export.py
index 992bc3b..7df09d7 100644
--- a/python/mdhtml/export.py
+++ b/python/mdhtml/export.py
@@ -68,7 +68,8 @@ def _text(el): return " ".join(el.to_text().split())
def to_html(src, dest=None, reftypes: dict | None = None, number_headings=None, hl: str | None = "spans", auto_ids: bool = True,
- toc: bool = False, refs: str = "resolve", id_prefix: str = "", fn_salt: str = "", hl_lang=None, code_wrap=None) -> Html:
+ toc: bool = False, refs: str = "resolve", id_prefix: str = "", fn_salt: str = "", hl_lang=None, code_wrap=None,
+ slug: str = "pandoc") -> Html:
"""Lower MDHTML (a string or DocumentFragment; never mutated) to finished HTML: cross-references
baked as links, headings and captions numbered, `{=html}` raw data spliced, `colwidths` lowered,
and code highlighted. A `div` classed `details` lowers to a `` element, its
@@ -77,6 +78,10 @@ def to_html(src, dest=None, reftypes: dict | None = None, number_headings=None,
spaces to hyphens, `-1` suffixes on duplicates); pass `auto_ids=False` when rendering fragments
that share a page, where per-fragment derived ids would collide. Authored ids (never
auto-derived ones) also get a `data-id` attribute, which anchor displays key on.
+ `slug='github'` derives those ids by GitHub's rules instead, so anchors match a
+ GitHub-rendered page and links written against one keep working. It keeps periods out of
+ the slug, keeps leading digits in, and has no `section` fallback for a heading with no
+ slug characters, where Pandoc's rules give `section`, `section-1` and so on.
`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:
@@ -91,8 +96,9 @@ def to_html(src, dest=None, reftypes: dict | None = None, number_headings=None,
may return replacement markup for the highlighted block (None keeps it; `text` is unescaped).
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}")
+ if slug not in ("pandoc", "github"): raise ValueError(f"unknown slug mode {slug!r}")
if not isinstance(src, str): src = src.to_html()
- out, warnings = _export_html(src, reftypes, number_headings, hl, toc, refs, id_prefix, fn_salt, hl_lang, code_wrap, auto_ids)
+ out, warnings = _export_html(src, reftypes, number_headings, hl, toc, refs, id_prefix, fn_salt, hl_lang, code_wrap, auto_ids, slug)
res = Html(out, warnings)
if dest is not None: Path(dest).write_text(res, encoding="utf-8")
return res
diff --git a/python/mdhtml/md2html.py b/python/mdhtml/md2html.py
index 163a4b1..09f7b83 100644
--- a/python/mdhtml/md2html.py
+++ b/python/mdhtml/md2html.py
@@ -17,6 +17,7 @@
RefsMode = str_enum('RefsMode', 'ids', 'lenient', 'resolve')
HlMode = str_enum('HlMode', 'spans', 'api', 'off')
NumMode = str_enum('NumMode', 'legal', 'decimal')
+SlugMode = str_enum('SlugMode', 'pandoc', 'github')
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"
@@ -91,6 +92,7 @@ def main(
dark_theme: str = "vscode_dark", # Code colors in dark mode
templates: bool = True, # Show mustache `{{tokens}}` as styled pills
auto_ids: bool = True, # Derive ids for headings
+ slug: SlugMode = SlugMode.pandoc, # Rule for derived heading ids: the dialect's, or GitHub's, to match anchors on a GitHub-rendered page
implicit_figures: bool = True, # Promote image-only paragraphs to figures
frontmatter: bool = False, # Recognize leading `key: value` frontmatter: strip it, title the page, prepend a metadata table
**kwargs
@@ -98,7 +100,7 @@ def main(
"Read Markdown and write a finished HTML page"
tmpl = dict(templates=MUSTACHE, callbacks={'template_token': mustache_pill}) if templates else {}
src = to_mdhtml(read_src(file), implicit_figures=implicit_figures, frontmatter=frontmatter, **tmpl, **kwargs)
- html = to_html(src, auto_ids=auto_ids, refs=refs, number_headings=number_headings, toc=toc, hl=None if hl == HlMode.off else hl, code_wrap=_code_wrap)
+ html = to_html(src, auto_ids=auto_ids, slug=slug, refs=refs, number_headings=number_headings, toc=toc, hl=None if hl == HlMode.off else hl, code_wrap=_code_wrap)
for w in [*src.warnings, *html.warnings]: print(w, file=sys.stderr)
if src.meta: html = meta_table(src.meta) + html
title = src.meta.get("title") or (Path(file).stem if file else "mdhtml")
diff --git a/src/export_html.rs b/src/export_html.rs
index 7c9bb7d..6ceed3c 100644
--- a/src/export_html.rs
+++ b/src/export_html.rs
@@ -6,6 +6,7 @@
use std::collections::{HashMap, HashSet};
use fast5ever::{DOCUMENT, Dom, NodeData, NodeId, parse_fragment};
+use unicode_properties::{GeneralCategoryGroup, UnicodeGeneralCategory};
use crate::resolve::{self, HeadingNums, Resolver, target_kind};
@@ -35,6 +36,16 @@ pub enum RefsMode {
Lenient,
}
+/// Which rule `auto_ids` derives heading ids by: `Pandoc` is the dialect's own
+/// (see `slug`), `Github` reproduces GitHub's anchors so links written against
+/// a GitHub-rendered page keep working (see `slug_github`).
+#[derive(PartialEq, Eq, Clone, Copy, Default)]
+pub enum SlugMode {
+ #[default]
+ Pandoc,
+ Github,
+}
+
/// Per-code-block hooks. Errors short-circuit the export; the pyo3 bridge
/// stores the original Python exception and re-raises it.
pub type HlLangHook<'a> = &'a (dyn Fn(&str, Option<&str>) -> Result