md2html

package module
v0.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 25, 2026 License: MIT Imports: 25 Imported by: 0

README

md2html

Turn a folder of Markdown documentation into clean, readable HTML pages.

Point md2html at your docs and it follows the links between them, writes a styled page for each one, and keeps every link working in the result. It is a single static binary: no configuration, no theme to set up, nothing to run beside it.

It is built for documentation written by coding agents as much as by people. Ask an agent for a polished page and it will hand-write HTML — about 1.6× the tokens of the same content in Markdown, re-read on every turn, and easy to break with one careless edit. With md2html the agent writes Markdown, and the page still gets callouts, diagrams, a contents list and properly styled tables.

Install

go install github.com/AdamF-G/md2html/cmd/md2html@latest
md2html ./docs -o ./site

That needs Go 1.26 or later, and converts everything under ./docs, plus anything it links to, into ./site.

The authoring skill for Claude Code

md2html understands more than plain Markdown — callouts, diagrams, status chips, cross-references — and a couple of those features fail silently when the syntax is slightly off. The authoring skill teaches Claude Code the syntax that works and the traps to avoid, so what it writes renders the way it meant. The binary carries the skill and installs it for you:

md2html --install-skill-user      # for you, under ~/.claude/skills
md2html --install-skill-project   # for this repo, under ./.claude/skills

Each writes SKILL.md and a copy of the authoring reference into a md2html-authoring directory, so the guidance always describes the version you installed — which a link to this repo's main would not. Neither creates the .claude directory itself: a missing one means this is not a Claude Code workspace, or you are not standing where you meant to be. Both files carry the same provenance marker as generated HTML, so a later install replaces this tool's own copy silently and refuses a copy you have edited, naming it.

Why use it with agents

An agent edits by string replacement, re-reads the file on every pass, and never sees the rendered page. All three favour Markdown over hand-written HTML:

  • It costs less to write. This repository's own docs take 1.57× the tokens as HTML once code blocks are set aside; a heading alone is nearly 9×. The measurements are below.
  • Read cost recurs. Write cost is paid once. Read cost is paid on every turn that pulls the file back into context.
  • Edits stay local. A Markdown unit — a sentence, a row, a heading — is addressable on its own. Its HTML counterpart is wrapped in tags whose boundaries rarely match the change, so the smallest safe replacement is larger than the edit.
  • Invariants cannot rot. Heading ids, anchor links, rel="noopener noreferrer", table scroll wrappers and .md → .html rewriting are computed on every build. There is no copy of them an edit can leave stale.
  • Malformed structure is unrepresentable. Markdown has no unclosed <div>. A nesting mistake in hand-written HTML renders as something plausible and wrong, which neither a human reviewer nor an agent reliably catches.

The output also suits agent hosts directly: --fragment emits bare HTML with no page shell, ready to drop into a Claude Artifact.

What you get

  • A browsable tree. Links between documents are followed across directories, .md links become .html links, and nothing outside the output directory is ever written.
  • Pages that read well. An embedded stylesheet, anchors on every heading, and tables that scroll instead of overflowing.
  • Diagrams. mermaid fences render as diagrams, and a fig fence describes hand-laid-out boxes, arrows and panels in YAML, for layouts a mermaid graph cannot express.
  • Callouts and containers. Fenced containers (::: callout … :::, or :::callout[With a title]) for callouts, warnings, cards, collapsible asides and examples, and named <nav> landmarks. GitHub alerts (> [!WARNING]) work as an alias.
  • Document furniture. A [[toc]] or [TOC] marker for a per-page contents list, title/subtitle/date front matter, caption="…" on code fences, [proven]-style status chips, and §4.2 cross-references that link to numbered headings.
  • Safety for your files. Every generated file is marked, and a file without the marker is never overwritten.

The full syntax, with the traps, is in docs/authoring.md.

The dialect

md2html reads CommonMark plus fenced divs, bracketed spans, fenced code attributes, header attributes, link attributes, definition lists, footnotes, pipe tables and YAML front matter — which is to say Pandoc's commonmark_x — plus GitHub alerts, status chips, § cross-references, [[toc]] and fig.

That is a measured claim, not an aspiration. compat/ runs each construct through both tools and sorts every construct into one of three buckets: the ones where md2html and pandoc -f commonmark_x produce the same structure, the ones Pandoc passes through untouched, and the ones Pandoc renders as a labelled code block. Task lists are the single divergence inside the shared subset — commonmark_x does not implement them and md2html follows GFM.

The practical consequence is that a document written for md2html mostly survives pandoc -f commonmark_x, and a document written for Pandoc mostly converts here. Where a construct is ours alone, Pandoc degrades it legibly rather than corrupting it.

The one thing a generated page fetches at view time is MermaidJS, and only a page that actually contains a diagram: it loads a pinned build from a CDN. Pages without diagrams reference nothing external, and --fragment output never loads it at all, because a fragment's host — a Claude Artifact, for one — renders mermaid itself. A build that must not reach a CDN can name its own copy — see Options.MermaidURL below.

Why Markdown, not HTML

Every page here could be hand-written as HTML instead. The reason not to is that the Markdown costs less to write, less to revise, and less to review — and the first of those is measurable.

Writing it

Rendering the 13 Markdown documents this repository held at v0.2.0 and counting tokens on both sides:

tokens vs Markdown
Markdown source 121,534 1.00×
Generated HTML, body markup only 166,113 1.37×
Generated HTML, complete pages 216,040 1.78×

Hand-writing this documentation set as finished pages costs about 94,500 more tokens.

The corpus figure understates the gap, because 58% of these documents is fenced source code, which passes through at roughly 1:1 and dilutes everything around it. Strip the fences and compare only prose and structure and the ratio is 1.57×.

It concentrates in exactly the constructs a document is built from:

One instance of Markdown HTML
## Where output goes 4 35 8.75×
a 2×3 table 42 112 2.67×
a code fence with caption= 22 49 2.23×
an internal and an external link 22 43 1.95×
a three-item bullet list 29 51 1.76×
::: callout 26 36 1.38×
a paragraph of prose 39 43 1.10×

Headings are the outlier and a document is full of them: four tokens of Markdown against an id, an anchor and an aria-hidden attribute. Prose is the floor, at 1.10× — <p> and </p> and nothing else to pay for.

The embedded stylesheet is a further 3,766 tokens you never write at all, which is why short pages gain most: CHANGELOG.md is 4.95× as a complete page against 1.45× on body markup alone.

Changing it

Three ordinary revisions to this README — rewording a sentence, adding a row to the flags table, renaming a heading — produce a 325-token diff in the Markdown and a 491-token diff in the HTML. The ratio roughly holds, but the shape of the difference matters more than the size.

Renaming a heading is one edit in Markdown. In HTML it is three, and they have to agree:

-<h3 id="where-output-goes">Where output goes<a class="anchor" href="#where-output-goes" aria-hidden="true">#</a></h3>
+<h3 id="where-the-output-lands">Where the output lands<a class="anchor" href="#where-the-output-lands" aria-hidden="true">#</a></h3>

Miss one and the anchor points at nothing, silently, and so does every inbound link that used the old slug. Derived, the three cannot drift apart.

Adding a table row is worse to read than to write. In Markdown the diff is the row. In HTML it is this:

+</tr>
+<tr>
+<td><code>--quiet</code></td>
+<td>suppress per-file progress; warnings still print</td>

Four lines, and the first of them closes the previous row — because a line-based diff aligns on </tr>, not on the boundary a human sees. The reviewer has to reassemble the change before judging it.

What the numbers assume

They assume the hand-written HTML would be equivalent — that you really would write the ids, the anchors, the rel="noopener noreferrer" and the table wrappers. Drop those and the prose-and-structure ratio falls from 1.57× to 1.43×, so about a seventh of the measured win is work you might have skipped rather than typed.

Pretty-printing, which looks like it ought to matter, does not: packing every newline out from between tags moves 1.57× to 1.56×. The cost is in tag names and attributes, not whitespace.

Counts are cl100k_base. A different tokenizer moves the absolute numbers and leaves the ratios about where they are.

Usage

md2html [flags] <entry> [entry...]

Entries may be files or directories.

md2html ./docs -o ./site                     # whole tree
md2html ./docs --depth 0 -o ./site           # top-level seeds, still follows links
md2html ./docs/index.md ./guides -o ./site   # mixed entries
md2html ./docs                               # in place, beside each source
md2html ./docs -o ./site --fragment          # bare fragments, no page shell
md2html README.md                            # one file, HTML beside it

# The maintained set, plus one scratch directory for this run only,
# with a vendored subtree another tool owns left strictly alone.
md2html -o ./site ./docs ./scratch/notes --exclude vendor

# Skip every document named AUDIT_*, wherever it sits.
md2html -o ./site ./docs --exclude 'AUDIT_*'
Two kinds of reach

Links between documents are followed across directories, unbounded by default. --depth and --link-depth bound two different things: --depth limits how deep into a directory md2html looks for unlinked files to seed from; --link-depth limits how many hops from a seed link-following may travel. Both read the same way: -1 is unlimited (the default for each), and a non-negative number is the bound itself, so --link-depth 0 follows no links at all. --depth 0 seeds only the Markdown sitting directly in the directory — and, with --link-depth left at its default, still follows every link out of it, however far that leads.

Where output goes

With -o, everything lands inside it. Documents under the entry points mirror their relative paths; documents pulled in from elsewhere on disk go under _external/, mirroring their absolute path. Nothing is ever written outside -o.

Without -o, HTML is written beside each source, and links that would escape the entry tree are refused rather than followed.

Assets are linked, never copied

An image stays where it is. Its link is rewritten to point back at the original, climbing out of the output directory as far as needed:

docs/api/auth.md  ->  site/api/auth.html
  ./img/flow.png  ->  ../../docs/api/img/flow.png

So the output tree is self-contained for documents but not for assets. Move site/ on its own and images break; move it alongside the sources and they do not. That is the deliberate trade for never duplicating your files.

It will not overwrite your work

Every generated file starts with:

<!-- generated by https://github.com/AdamF-G/md2html v0.6.0 - edits will be overwritten -->

Re-runs replace files carrying that marker silently. A file without it is never touched — md2html warns, skips it, and exits non-zero. There is no override flag. The marker carries the full repository URL, so output from unrelated tools that share the name is never claimed.

Flags

Flag Effect
-o DIR output directory; default writes beside each source
--depth N directory levels to seed from a directory entry; -1 (default) unlimited
--link-depth N hops from a seed that link-following may travel; -1 (default) unlimited, 0 follows none
--fragment emit bare HTML fragments (for hosts such as Claude Artifacts) instead of full pages
--css FILE replace the embedded stylesheet
--lang TAG language of every page, such as de or pt-BR; a document's own lang: front matter overrides it. Default en. Ignored with --fragment
--no-table-scroll do not wrap tables
--no-anchors do not add heading anchors
--no-external-links do not mark external links
--no-md-links do not rewrite .md links
--no-assets do not rewrite asset links
--exclude DIR|GLOB never enter, seed, follow into, or write to DIR (relative to the base — the common ancestor of the entry points — or absolute). A value containing *, ? or [ is instead a name glob, matched against every file and directory name below the base: --exclude 'AUDIT_*' skips AUDIT_2026.md at any depth. Quote it so the shell does not expand it. Repeatable, or comma-separated
--version print the version and exit; the same version the provenance marker carries
--install-skill-user install the Claude Code authoring skill under ~/.claude/skills and exit
--install-skill-project install it under ./.claude/skills instead

Writing docs for it

Callouts, diagrams, heading attributes, footnotes and definition lists all work, and one of them fails silently if you get the syntax wrong. See docs/authoring.md, or install the authoring skill so Claude Code has it to hand.

Library use

import "github.com/AdamF-G/md2html"

out, err := md2html.Convert(src, md2html.Options{SourcePath: "doc.md"})

Options.Warn, when set, receives one message per non-fatal problem found while converting a document. Three things report so far: a front matter block that isn't flat key: value, a container naming a kind that doesn't exist, and a fig fence whose body doesn't parse or doesn't validate. All three arrive whatever Transforms holds — a builtin that reports is rebuilt against this sink before it runs, so appending to Builtins() costs you no diagnostics. Convert never writes to stderr itself; the callback runs synchronously on the calling goroutine.

Options.MermaidURL names the ES module a page with a diagram imports at view time, for a docs build that must not reach a CDN. Empty keeps the pinned build.

Add a transform by writing a func(*html.Node) error:

ts := append(md2html.Builtins(), md2html.Transform{
    Name: "myRule",
    Fn: func(root *html.Node) error { /* walk and mutate */ return nil },
})
out, err := md2html.Convert(src, md2html.Options{Transforms: ts})

Transforms run over a real HTML tree, so they reach hand-written raw HTML in your Markdown as well as generated markup.

Development

just install builds the binary and installs it to ~/.local/bin, then prints the version it just installed:

just install                        # to ~/.local/bin
just bindir=/somewhere/else install # anywhere else

It refuses to install into a directory that does not already exist, rather than creating one: go install will happily create a missing GOBIN and every level above it, which on a fresh machine puts the binary somewhere nothing on PATH will ever read and still reports success.

just test (or go test ./...) needs no browser. A separate suite drives real headless Chrome to exercise the click-to-expand JavaScript runtimes end to end:

just e2e     # or: cd e2e && go test ./...

A third suite characterises the Markdown dialect against Pandoc:

just compat   # or: cd compat && go test ./...

It needs pandoc on PATH and skips itself without one.

The browser suite requires Chrome or Chromium installed locally, and lives in e2e/ as its own Go module — as compat/ does, for the same reason. That boundary is the only opt-in — there is no build tag — and it is what keeps chromedp out of the library: a test-only import would sit in this module's own go.mod as a direct requirement and be compiled by anyone who ran its tests. Nothing in a build or test of the library compiles it now. (It does still appear in go.mod as an indirect entry: go.abhg.dev/goldmark/mermaid requires it for a server-side renderer md2html never imports. That one is inherited, and the split cannot remove it.)

GitHub Actions runs all three suites on every push and pull request, along with gofmt, go vet and go mod tidy -diff. Pandoc is pinned there, for the reason the mermaid build is pinned: the compat suite records how two tools agree, so an unannounced upgrade of the other one would report a change in md2html that never happened. CHANGELOG.md records what has changed.

License

MIT — see LICENSE. internal/fences is vendored from goldmark-fences and keeps its own MIT licence; testdata/vendor holds a test-only copy of MermaidJS under its MIT licence, with the notices of the libraries it bundles left in place.

Documentation

Overview

Package md2html converts Markdown documents to HTML with tree-level transforms that reach hand-written raw HTML as well as generated markup.

Index

Constants

View Source
const MarkerPrefix = "<!-- generated by https://github.com/AdamF-G/md2html"

MarkerPrefix is the stable portion of the provenance marker. Detection matches this prefix only, so files written by older versions are still recognized. It carries the full repository URL rather than the bare tool name, so output from an unrelated tool also called md2html is never mistaken for ours.

View Source
const Version = "v0.6.0"

Version is stamped into the provenance marker.

Variables

This section is empty.

Functions

func Convert

func Convert(src []byte, opt Options) ([]byte, error)

Convert renders Markdown to HTML, running transforms over the parsed tree.

func InstallSkill added in v0.3.0

func InstallSkill(parent string) ([]string, error)

InstallSkill writes the Claude Code authoring skill into a "md2html-authoring" directory below parent, creating it as needed, and returns the paths written in a stable order.

parent is a skills directory — "<something>/.claude/skills". Whether that something ought to exist is the caller's question to answer, not this one's: the CLI refuses to invent a missing .claude, because a skill installed under a directory nobody reads is worse than no skill at all.

Every file carries this tool's provenance marker, so re-installing over an earlier version is silent while a copy someone has edited is refused by name. The refusal is whole: every destination is checked before anything is written, so a run that refuses leaves the directory exactly as it found it rather than holding one file from this version beside one the user wrote. That check is the reason this lives here rather than in the command — a caller assembling the same install from a bag of file contents would have to know to do it, and a caller who forgot would get half-written skill directories with no sign anything was wrong.

func IsLangTag added in v0.6.0

func IsLangTag(s string) bool

IsLangTag reports whether s is shaped like a BCP 47 language tag, the check Options.Lang is held to. It is exported so a caller taking a language from its own user, as the CLI's --lang does, can refuse a bad one up front instead of getting a warning per document.

func IsMarkdownPath

func IsMarkdownPath(p string) bool

IsMarkdownPath reports whether p names a Markdown document.

func IsOurs

func IsOurs(path string) (bool, error)

IsOurs reports whether the file at path carries our provenance marker within its first 512 bytes. A missing file is not ours, without error.

func Marker

func Marker() string

Marker returns the full provenance comment written at the top of every generated file.

Types

type CrawlOptions

type CrawlOptions struct {
	// Entries are the starting points: files, directories, or both.
	Entries []string
	// OutDir is the output root. Empty means in-place.
	OutDir string
	// Depth bounds how many directory levels a directory entry seeds.
	// 0 seeds only files directly inside it; -1 is unlimited. It never
	// affects link traversal; LinkDepth, below, bounds that instead.
	Depth int
	// NoMdLinks leaves document links unrewritten.
	NoMdLinks bool
	// NoAssets leaves asset links unrewritten.
	NoAssets bool
	// Exclude lists what must never be entered. A value containing *, ? or
	// [ is a filepath.Match pattern tested against each file and directory
	// name below the resolved base, so "AUDIT_*" names documents at any
	// depth; any other value is a directory prefix, either absolute or
	// relative to base. A path at or beneath a prefix, or with a name
	// matching a pattern, is never seeded, never followed as a link target,
	// and never written to; a link pointing at one keeps its href exactly as
	// written, the same handling a link escaping base gets in in-place mode.
	//
	// This exists for subtrees some other tool already owns — a slide-deck
	// renderer, a vendored dependency's own generated docs, a frozen
	// archive. Without it the only way to keep the crawler out of one is to
	// move it out of the source tree, which is rarely possible.
	Exclude []string
	// LinkDepth bounds how far link-following may travel from a seed,
	// counted in hops: a document a seed links to is one hop, one it links
	// to in turn is two.
	//
	// It reads exactly as Depth does: a non-negative value is the bound
	// itself, and -1 is unlimited. 0 therefore follows no links at all,
	// which is what makes a zero-valued CrawlOptions coherent rather than
	// merely conservative — Depth's zero already seeds a directory's own
	// files and none of its subdirectories, so both fields describe the
	// smallest possible run, and the CLI asks for unlimited on both.
	LinkDepth int
}

CrawlOptions configures a discovery pass.

type CrawlResult

type CrawlResult struct {
	// Docs is the emit set: every document reachable from the entry points.
	Docs []Doc
	// Base is the deepest directory containing every entry point.
	Base string
	// External lists the resolved absolute paths of documents pulled in
	// from outside Base.
	External []string
	// Warnings are the non-fatal problems found during the crawl.
	Warnings []Warning
}

CrawlResult is the outcome of a discovery pass.

func Crawl

func Crawl(opt CrawlOptions) (*CrawlResult, error)

Crawl discovers every document reachable from the entry points.

type Doc

type Doc struct {
	// Src is the resolved absolute source path.
	Src string
	// Out is the absolute destination path for the generated HTML.
	Out string
	// LinkMap maps an href exactly as written in this document to its
	// replacement. Populated by buildLinkMaps.
	LinkMap map[string]string
}

Doc is one document in the emit set.

type Link struct {
	// Href is the value exactly as written in the source.
	Href string
	// Kind classifies the link target.
	Kind LinkKind
	// Abs is the resolved absolute path, for LinkDoc and LinkAsset only.
	// Any #fragment has been stripped.
	Abs string
	// Node is the element carrying the link, so a transform can rewrite it.
	Node *html.Node
	// Attr is the attribute on Node carrying the link ("href" or "src").
	Attr string
}

Link is one href or src found in a document.

func ExtractLinks(root *html.Node, srcDir string) []Link

ExtractLinks returns every local or remote link in the tree. srcDir is the absolute directory of the document being examined, used to resolve relative hrefs.

type LinkKind

type LinkKind int

LinkKind classifies a link target.

const (
	// LinkDoc is a local Markdown document.
	LinkDoc LinkKind = iota
	// LinkAsset is any other local file: images, diagrams, downloads.
	LinkAsset
	// LinkRemote is an off-machine or inline URL, left untouched.
	LinkRemote
	// LinkFragment is a same-document anchor, left untouched.
	LinkFragment
)

type Options

type Options struct {
	// Fragment emits Artifact shape (marker, title, style, body) instead of
	// a full HTML document.
	Fragment bool
	// Title overrides the derived title. Empty means derive from the first
	// <h1>, falling back to SourcePath's base name.
	Title string
	// SourcePath is the absolute path of the source document. Used for the
	// title fallback and diagnostics.
	SourcePath string
	// CSS replaces the embedded default stylesheet. Empty uses the default.
	CSS string
	// MermaidURL is the ES module a page containing a mermaid diagram
	// imports at view time. Empty uses the pinned CDN build.
	//
	// It is the only thing a generated page ever fetches, so this is the
	// knob for a docs build that must not reach a CDN: point it at a copy
	// you serve yourself. Nothing else changes — a page with no diagram
	// still imports nothing, and Fragment output never imports at all,
	// because Artifacts render mermaid themselves.
	MermaidURL string
	// Lang is the page's language, written as <html lang>. A document's own
	// "lang:" front matter key overrides it; empty means "en". A value not
	// shaped like a BCP 47 language tag is warned about and skipped in
	// favor of the next source. Fragment output has no <html> element, so
	// it ignores both.
	Lang string
	// Transforms to run. Nil means the default list.
	//
	// A list supplied here is used as given, except that any builtin in it
	// that reports diagnostics is rebuilt against Warn — so appending to
	// Builtins() keeps every warning a default conversion would have
	// raised, without the caller naming the sink twice. The caller's own
	// slice is never written to.
	//
	// Transforms and LinkMap are two alternative routes to link rewriting,
	// not complementary ones: either place LinkRewrite in Transforms
	// yourself, or set LinkMap and let Convert do it. Doing both rewrites
	// every href twice — see LinkMap.
	Transforms []Transform
	// LinkMap maps an href exactly as written in the source to its
	// replacement. Populated by the crawler; nil for standalone conversion.
	//
	// Setting this makes Convert append LinkRewrite(LinkMap) to the
	// transform list itself, so callers must not also put LinkRewrite in
	// Transforms. Two passes over one map corrupt output whenever a
	// replacement is itself a key: a document linking both ./a.md (mapped
	// to a.html) and an existing ./a.html asset (mapped to the original
	// file) would have the first rewrite turned into the second.
	LinkMap map[string]string
	// Warn, when non-nil, receives one message per non-fatal problem found
	// while converting this document. Things that report so far: a front
	// matter block that is not flat key: value, a language that is not a
	// language tag, a container naming a kind that does not exist or
	// missing the definition list it is defined to hold, and a fig fence
	// whose body does not parse or does not validate.
	//
	// Convert never writes to stderr itself: it is a library, and the CLI
	// emits every document's output in parallel, so a transform printing
	// directly would interleave with other documents' lines. The callback
	// is invoked synchronously on the calling goroutine, so a caller may
	// append to an unsynchronized per-document slice.
	//
	// Every source reaches it by the same route whatever Transforms holds:
	// Convert raises the front matter warning itself, the fig fence warning
	// comes from the renderer, and a transform that reports is rebuilt
	// against this sink before it runs (see Transforms).
	Warn func(string)
}

Options controls a single document conversion.

type Transform

type Transform struct {
	// Name identifies the transform, e.g. for logging or diagnostics.
	Name string
	// Fn is the function applied to the parsed HTML tree.
	Fn func(*html.Node) error
}

Transform mutates a parsed HTML tree in place. Fn receives the synthetic root node whose children are the document's top-level elements.

func Alerts added in v0.3.0

func Alerts() Transform

Alerts converts GitHub alert blockquotes into the shipped containers.

> [!WARNING]
> Overwrites state.

becomes exactly what "::: warning" produces.

This is the one callout spelling GitHub, Obsidian, Typora and Pandoc's gfm and commonmark_x readers all understand, and the only one that renders correctly in the place md2html's input actually lives: a repository. "::: warning" shows up on GitHub as the literal text "::: warning".

It degrades the right way too. A renderer that does not know the syntax shows an ordinary blockquote with a visible marker, rather than the unstyled div a stray ::: fence leaves behind.

func Builtins

func Builtins() []Transform

Builtins returns the transforms enabled by default.

It takes no arguments and reports nothing: it is the documented public door for callers assembling their own transform list (see README), and changing its signature would break them. A list built from it still reports — Convert rebuilds the entries in warnAware against Options.Warn before running them — so the nil sink here costs a caller nothing.

func Chips

func Chips() Transform

Chips turns a fixed set of bracketed tokens into small styled badges, everywhere inline Markdown is rendered — body text and headings alike, since a status marker on a heading is the case the convention exists for.

A heading's marker is kept out of its slug by HeadingAnchors, which is why this transform must run before it.

func Containers

func Containers(warn func(string)) Transform

Containers normalizes fenced containers. Acceptance of the brace-free "::: kind" and label "kind[Title]" forms happens upstream, in the parser; this transform maps a kind name handed to it via data-fence-kind onto the shipped kind's element and stylesheet classes, warns when that name is not one of them, and drops the fence library's internal data-fence attribute.

warn may be nil.

func ExternalLinks() Transform

ExternalLinks marks off-site links so they open in a new tab without leaking the referring window. rel="noopener noreferrer" is added to any rel tokens the link already has, and a target the author chose is left alone.

func HeadingAnchors

func HeadingAnchors() Transform

HeadingAnchors gives every heading a stable id and a linkable anchor. An id already present — from a {#custom-id} attribute — is left alone.

Status chips are excluded from the slug: a marker is metadata about the section, not part of its name, and relabeling one later must not rot an anchor other documents already link to.

func LinkAttrs added in v0.6.0

func LinkAttrs(warn func(string)) Transform

LinkAttrs applies Pandoc's link_attributes: a {#id .class key=value} block written directly after a link or an image sets those attributes on the <a> or <img>, and is removed from the text.

goldmark has no parser for it, so the block reaches the tree as the start of the text node after the element. Working on the tree is also what lets an aria-* attribute through: goldmark's own attribute allowlist drops them, and this sets attributes directly.

The block must touch the element, as in Pandoc; with a space between them it is prose. One holding no attributes, or a name that is not a safe attribute name, stays as literal text, as "[x]{}" does for a bracketed span.

href, src and srcset are refused, with a warning. LinkRewrite looks links up by their target exactly as the source wrote it, so a block replacing one would route the link around the .md-to-.html rewrite. The block itself can never become part of that key: it is a separate text node, never in the attribute.

warn may be nil.

func LinkRewrite

func LinkRewrite(m map[string]string) Transform

LinkRewrite replaces hrefs and srcs using a map keyed by the link exactly as written in the source document. Links absent from the map are left untouched, which is how remote URLs, fragments, and deliberately unrewritten links survive.

func SectionLinks() Transform

SectionLinks autolinks bare "§N.M" references to the heading in the same document numbered N.M.

It is a transform rather than a goldmark extension because it needs the finished heading set, ids included, to resolve against — which only exists after HeadingAnchors has run.

Left alone: a § inside a code span, code block or existing link (handled by rewriteText, via splitMatches only ever touching prose text nodes); a § whose number matches no heading here; and a possessive reference scoping the section to another document, as in "the design doc's §7". That last rule is narrow — it catches the possessive phrasing and nothing else — so a cross-document reference written any other way still needs a code span to opt out.

func TOC

func TOC() Transform

TOC replaces a marker paragraph with a flat list of the current document's own headings.

Flat, not nested by heading level: a document that jumps h2 to h4 would otherwise produce either invalid list nesting or a silently wrong tree. Level travels as a class on the list item, so the stylesheet can indent without the markup having to be a hierarchy.

This is a table of contents for one page and nothing more. Cross-document navigation, a sidebar and a site index stay out of scope — see docs/specs/2026-09-11-extended-content-model.md, item 6.

func TableScroll

func TableScroll() Transform

TableScroll wraps every table in a horizontally scrollable container, so wide tables never force the page body to scroll sideways. It reaches hand-written tables in raw HTML as well as generated ones.

type Warning

type Warning struct {
	// Src is the document the warning was raised while processing, or, for
	// a warning with no document to point at (an --exclude value that
	// matches nothing on disk), the offending value itself.
	Src string
	// Message describes the problem.
	Message string
}

Warning is a non-fatal problem found during a run.

type WriteResult

type WriteResult int

WriteResult reports what SafeWrite did.

const (
	// WriteCreated means no file existed at the destination.
	WriteCreated WriteResult = iota
	// WriteOverwritten means an earlier generated file was replaced.
	WriteOverwritten
	// WriteRefused means a file we did not write was left untouched.
	WriteRefused
)

func SafeWrite

func SafeWrite(path string, data []byte) (WriteResult, error)

SafeWrite writes data to path, refusing to destroy any file this tool did not generate. Parent directories are created as needed.

A refusal is a WriteRefused result, not an error: the caller continues with other files and reports the refusal in the run summary.

func (WriteResult) String

func (r WriteResult) String() string

String implements fmt.Stringer.

Directories

Path Synopsis
cmd
md2html command
Command md2html converts Markdown documentation trees to HTML.
Command md2html converts Markdown documentation trees to HTML.
internal
fences
Package fences implements the ::: fenced container syntax.
Package fences implements the ::: fenced container syntax.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL