Skip to content
internal/html
On this page

Converting Markdown into a page's body HTML: block rendering, heading anchors, glossary definition sites, link rewriting and the highlight stylesheet.

#internal/html

#internal/html

Package html converts Markdown to the HTML a built page's body carries.

It is the converter half of selfdoc's page rendering: block tokens in, body HTML out, plus the pieces that operate on that HTML afterwards -- heading anchors, the glossary's definition sites and cross-page term links, internal link rewriting, the syntax-highlight stylesheet, and the JavaScript minifier. The page chrome that wraps a body -- navigation, table of contents, breadcrumbs, SEO tags, pickers -- is built on top of this package rather than inside it.

#Heading anchors are decided once

[AssignHeadingAnchors] is the one place a heading's element id is decided. The renderer emits those ids and the search index links to them, so a repeated heading gets "setup", "setup-1", "setup-2" in both. Because the input is the block token list, a "#"-prefixed line inside a fenced code block is code and never becomes an anchor.

#Highlighting

Code blocks are highlighted at build time by chroma, and the stylesheet that paints them is generated by [GeneratePygmentsCSS] as one set of custom properties defined three times over -- the default scheme, an explicitly chosen dark one, and the system fallback for a reader who has recorded no preference -- referenced by one scheme-agnostic set of rules. A token's colour is therefore a value in the token layer, and the two schemes cannot drift apart rule by rule.

Chroma replaces the Pygments this package's Python predecessor used, so the emitted token class names are chroma's. Everything else about the stylesheet's shape is unchanged.

#ChevronIcon

Go go
const ChevronIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ` +

ChevronIcon is the framework's own chevron.

#CloseIcon

Go go
const CloseIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ` +

CloseIcon dismisses a dialog or a notice.

Go go
const MenuIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ` +

MenuIcon opens the mobile sidebar.

#InfoIcon

Go go
const InfoIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ` +

InfoIcon marks the "info" callout kind.

#WarnIcon

Go go
const WarnIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ` +

WarnIcon marks the "warn" and "danger" callout kinds.

#NoteIcon

Go go
const NoteIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ` +

NoteIcon marks the "note" callout kind.

#CheckIcon

Go go
const CheckIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ` +

CheckIcon marks the "tip" callout kind.

#NoticeIcon

Go go
const NoticeIcon = WarnIcon

NoticeIcon is the superseded-version banner's glyph. The banner is the "warn" kind, so it carries the warn glyph.

#PygmentsScope

Go go
const PygmentsScope = ".tm-code code"

PygmentsScope is the selector every highlight rule is written under. It matches the markup a rendered code block carries, and nothing outside a code block.

#PygmentsVarPrefix

Go go
const PygmentsVarPrefix = "--sd-hl-"

PygmentsVarPrefix is the prefix every generated highlight custom property carries.

#CalloutKind

Go go
type CalloutKind struct

CalloutKind is how one admonition type is painted: the framework kind it maps to, the glyph it carries, and the ARIA role it takes.

#HeadingAnchor

Go go
type HeadingAnchor struct

HeadingAnchor is one heading and the element id it will carry on the built page.

A renderer walking the same tokens the anchors were assigned from looks each one up by its token index.

#DeclaredTerm

Go go
type DeclaredTerm struct

DeclaredTerm is one author-declared term: the term as written, the id its definition site carries, and the definition's HTML.

#SiteTerm

Go go
type SiteTerm struct

SiteTerm is one term in the site-wide term table: where it is defined, under what id, with what definition, and -- once a glossary page has been synthesized -- the id of its entry on that page.

#SiteTerms

Go go
type SiteTerms struct

SiteTerms is the site-wide term table: every term any page declared, keyed by the lower-cased term and kept in the order the terms were first seen.

The order is part of the contract, not an implementation detail: the passes that consume the table walk it in order, and a Go map's iteration order would make a built site differ between two runs over the same sources. It stands in for the insertion-ordered dict the Python surface passed around.

#AdmonitionTypes

Go go
func AdmonitionTypes() []string

AdmonitionTypes returns, sorted, every admonition name a GitHub-flavored blockquote marker may name -- the "TYPE" in a leading "> [!TYPE]" line.

A blockquote whose marker names anything else is a plain blockquote.

#CalloutKindFor

Go go
func CalloutKindFor(admonitionType string) (CalloutKind, bool)

CalloutKindFor returns how the named admonition type is painted, and whether it is one this build recognizes.

#PageTitleAnchor

Go go
func PageTitleAnchor(title string) string { return Slugify(inlineFormat(title)) }

PageTitleAnchor returns the element id the page-title H1 carries.

Part of the anchor authority: the page title is rendered as an H1 by the page chrome rather than by the body renderer, so both sides ask this function instead of slugifying the title themselves. Like every other heading, the title is slugified from its RENDERED inline form, so "# The build command" anchors the same way whether the words reach the page through frontmatter or through markdown.

#AssignHeadingAnchors

Go go
func AssignHeadingAnchors(tokens []tokenizer.Token, pageTitle *string) []HeadingAnchor

AssignHeadingAnchors assigns the final element id to every heading in tokens.

This is the one place heading anchors are decided. The HTML renderer emits these ids and the search index links to them, so the two cannot drift: a repeated heading gets "setup", "setup-1", "setup-2" in both. Because the input is the block token list, a "#"-prefixed line inside a fenced code block is code and never becomes an anchor.

The first H1 is not rendered in the body -- the page chrome emits it as the page title heading, whose id comes from the page title. Pass pageTitle (the frontmatter title, else the H1 text) to get that id right; it is reported with IsPageTitle true. A nil pageTitle means the H1's own text is the title.

The result is in document order.

#MdToHTML

Go go
func MdToHTML(text string, metadata, cfg map[string]any) string

MdToHTML converts Markdown text to the HTML a page's body carries.

It handles headings, code blocks (with tabs and annotations), inline code, paragraphs, unordered and ordered lists, links, bold, italic and tables.

metadata is the page's frontmatter. Its "auto_steps" and "auto_api" keys override the corresponding global settings from cfg.

cfg is the project config. Its "auto_detect" key -- an object with optional bool keys "steps" and "api_entries" -- controls whether the heuristics run globally; per-page metadata takes precedence. "run_button", "line_numbers" and "code_icons" configure code blocks. Either map may be nil.

#ParseTable

Go go
func ParseTable(tableLines []string) string

ParseTable parses markdown table lines into an HTML

.

It expects lines like:

ParseTable
Header1Header2
Cell1Cell2

The separator line -- the one whose cells hold only "|", "-", ":" and spaces -- separates the header from the body rows, and its alignment markers produce text-align styles on the cells below. An escaped pipe in a cell is a literal pipe character.

#GeneratePygmentsCSS

Go go
func GeneratePygmentsCSS(lightStyle, darkStyle string) (string, error)

GeneratePygmentsCSS generates the syntax-highlight CSS, tokenized across the light/dark split.

Two highlight styles are resolved -- one per colour scheme -- and neither of them reaches a rule as a literal. Every declaration either style makes becomes a custom property defined three times over (the default scheme, an explicitly chosen dark one, and the system fallback for a reader with no choice recorded) and referenced once by a single set of rules. A token's colour is therefore a value in the token layer and the rules that paint it are scheme-agnostic, which is the shape the framework's conformance checker requires and, independent of that, the only spelling where the two schemes cannot drift apart rule by rule.

The three token blocks are spelled ":root", html[data-theme="dark"] and "html:not([data-theme])" inside a prefers-color-scheme query -- the same CSS-only three-state resolution the tinymoon theme uses, so a reader who has expressed no preference and runs no JavaScript still gets the dark palette.

lightStyle and darkStyle are Pygments style names, as a theme's companion JSON declares them; see [chromaStyleNames] for the one name whose chroma spelling differs. An unknown name is an error.

A variable name is derived from a selector, so two selectors that slug the same would share one value and paint one of the two tokens wrong. Nothing in chroma's class vocabulary collides today; a style that introduced one would be a silent miscolouring, so it is an error.

#PathHop

Go go
func PathHop(p, prefix, sitePrefix string) string

PathHop returns the hop that reaches path from the page rendering the reference.

A term can be defined on either side of the mount boundary, so the hop is chosen per target: prefix reaches the project's own pages and sitePrefix the site level, and under a mount those are two different roots. It is the answer for every reference that carries a bare target path and no unversioned marker: cross-page term links, breadcrumb ancestors, the glossary's source links.

Go go
func RewriteInternalLinks(bodyHTML, mdPath string, legacyHTMLLinks bool) string

RewriteInternalLinks rewrites the page references bodyHTML wrote to their emitted addresses.

An author links to checks.md -- a path relative to the source file's own directory in docs/. Under directory addressing the page writing that link is emitted at "/index.html", one level deeper than its source, so the sibling is reached at "../checks/". Every reference is therefore resolved back to a source path, mapped through [MdToHTMLPath], and re-expressed relative to the emitted page's directory: siblings, subdirectory pages, parent pages and the root index all come out right from the one rule.

Fragments are kept through the rewrite ("checks.md#detail" becomes "../checks/#detail"); a bare "#anchor" addresses the page itself and is left as written.

legacyHTMLLinks additionally treats a relative "*.html" reference as naming the same page's Markdown source. It is set only for archive builds, whose content comes from an immutable git tag and can predate this addressing -- links there cannot be fixed at source. A build of the working tree never gets that tolerance: source under edit must name pages the way the build emits them, and a stale ".html" link there is a defect LINK001 reports.

#EmittedRef

Go go
func EmittedRef(mdPath, ref string, legacyHTMLLinks bool) string

EmittedRef returns the href that ref, written on the page whose Markdown source is mdPath, is emitted as.

It is the one rule [RewriteInternalLinks] applies, per reference: a reference naming a page of this docs tree comes back as the address the build gives that page, and every other reference -- another origin, a fragment, a path outside the tree, anything that is not a ".md" -- comes back as written, which is how the renderer emits it.

legacyHTMLLinks is what [RewriteInternalLinks] documents: it additionally treats a relative "*.html" reference as naming the same page's Markdown source, and is set only for archive builds.

#SourceRefs

Go go
func SourceRefs(source, mdPath string) map[string]bool

SourceRefs returns every href a page built now from source, whose Markdown path is mdPath, would emit.

A reference is anything the source addresses: a Markdown link or image destination, a reference definition's target, or a raw href/src attribute written into the Markdown. Each is mapped through [EmittedRef], the same rule the renderer applies to the converted body, so the answer is in the emitted spelling ("../guide/") rather than the authored one ("guide.md").

It exists so a pass over a BUILT tree can tell a reference the current source still writes from one only an older rendering wrote. Comparing an emitted href against the Markdown text directly cannot do that: the two are never spelled the same.

#MdToHTMLPath

Go go
func MdToHTMLPath(mdPath string) string

MdToHTMLPath converts a ".md" path to a directory-index HTML path.

"guide.md" becomes "guide/index.html" (served as "/guide/"). "index.md" stays "index.html" (the root page, not "index/index.html"). Subdirectory pages follow the same rule: "api/endpoints.md" becomes "api/endpoints/index.html".

#HTMLPathToURL

Go go
func HTMLPathToURL(htmlPath string) string

HTMLPathToURL converts an HTML file path to its clean URL form.

"guide/index.html" becomes "guide/", and "index.html" stays "index.html" (the root page). Used for link hrefs, canonical URLs and sitemap entries.

#HTMLToMdPath

Go go
func HTMLToMdPath(htmlPath string) string

HTMLToMdPath is the reverse of [MdToHTMLPath].

"guide/index.html" becomes "guide.md", "index.html" becomes "index.md", and "api/endpoints/index.html" becomes "api/endpoints.md".

#MinifyJS

Go go
func MinifyJS(jsText string) string

MinifyJS removes comments from JavaScript and collapses its whitespace.

The approach is conservative: it does not break a URL containing "//" and it keeps a single space between identifiers so two of them cannot merge into one.

A line whose first non-whitespace characters are "//" is a comment, full stop -- no script this package ships carries a multi-line string literal, so there is nothing else it could be. It is stripped unconditionally. The quote check applies only to a "//" that follows code on the same line, where it really might be inside a string.

That distinction is not a nicety. The check used to apply to line-initial comments too, so a comment containing an apostrophe -- "the framework's combobox shape" -- was left in place, and the whitespace collapse below then pulled the FOLLOWING statement up onto the comment's line and commented it out, along with every block after it in the same assembled script. The symptom was a page whose scripts simply did not run, with no error anywhere.

#Slugify

Go go
func Slugify(text string) string

Slugify converts heading text to a URL-friendly slug for deep linking.

HTML tags are stripped first, then the text is NFKD-normalized so an accented character decomposes, its combining marks are dropped, the result is lower-cased, spaces become hyphens, everything that is neither a letter, a digit, an underscore nor a hyphen is removed, runs of hyphens collapse to one, and the edges are trimmed of hyphens.

Dropping only the combining marks is what preserves CJK and Cyrillic: those characters are letters, so they stay, while "Déploiement" and "Deploiement" slug the same way.

#EscapeHTML

Go go
func EscapeHTML(text string) string { return util.EscapeHTML(text) }

EscapeHTML escapes the HTML special characters "&", "<", ">" and the double quote, and deliberately not the apostrophe.

It is [util.EscapeHTML], re-exported because every emitter in this package and in the page chrome above it escapes through one name.

#TermAnchor

Go go
func TermAnchor(term string) string { return "term-" + Slugify(term) }

TermAnchor returns the id a definition site carries for term.

Terms live in their own "term-" namespace so a term can never take an id a heading already owns -- heading ids come from [AssignHeadingAnchors] and are bare slugs.

#NewSiteTerms

Go go
func NewSiteTerms() *SiteTerms

NewSiteTerms returns an empty term table.

#CollectDeclaredTerms

Go go
func CollectDeclaredTerms(bodyHTML string) []DeclaredTerm

CollectDeclaredTerms returns every author-declared term in bodyHTML, in document order.

There is one result per definition site: a

inside a glossary block, whose definition is its
, or a standalone in a paragraph, whose definition is the paragraph. The anchor is the id the definition site already carries, so a caller linking to it has a real target.

Nothing here is inferred -- a term appears only where an author wrote a , a definition list, or the glossary directive.

#LinkDefinitionSites

Go go
func LinkDefinitionSites(bodyHTML string, siteTerms *SiteTerms, currentPage, glossaryURL string) string

LinkDefinitionSites turns each definition site on currentPage into a glossary link.

The an author wrote keeps its id and gains a tooltip with the definition's first sentence; its text becomes a link to the term's glossary entry. Other occurrences of the term on the same page are left alone -- [ApplyCrossPageTerms] deliberately links only terms defined elsewhere, and a page does not need a forest of links to a term it defines itself.

#ApplyCrossPageTerms

Go go
func ApplyCrossPageTerms(bodyHTML string, siteTerms *SiteTerms, currentPage, prefix, sitePrefix string) string

ApplyCrossPageTerms links the first occurrence of each cross-page term in bodyHTML.

For every term defined on a DIFFERENT page, the first occurrence in bodyHTML that is not inside an , ,

, , 
or heading element is wrapped in an pointing at the definition page. Only the first match per term is linked, to avoid link spam.

A term can be defined on either side of the mount boundary, so the hop is chosen per target: prefix reaches the project's own pages and sitePrefix the site level, and under a mount those are two different roots. A caller with only one root passes it as both.

#GetCSS

Go go
func GetCSS(themeName string) (string, error) { return themes.CSS(themeName) }

GetCSS returns the composed stylesheet for the named theme.

It is [themes.CSS], re-exported so a page renderer reads its stylesheet and its highlight sheet from one package.

#ThemeCSSRel

Go go
func ThemeCSSRel(themeMeta *themes.Metadata) string

ThemeCSSRel returns where this page's stylesheet sits, relative to the output root.

A framework theme's sheet is written in "css/" with "fonts/" beside it, because the framework addresses its faces at "../fonts/". Every other theme keeps "style.css" at the root. Pages that render before a theme is known -- and the tests that build one by hand -- pass nil and get the plain answer.

#SiteTerms.Add

Go go
func (s *SiteTerms) Add(term, page, anchor, definition string) *SiteTerm

Add records term as defined on page, and returns the table's entry for it.

The first page to declare a term owns it: a later declaration of the same term, in any casing, is ignored and the existing entry returned.

#SiteTerms.Get

Go go
func (s *SiteTerms) Get(term string) (*SiteTerm, bool)

Get returns the entry for a term, matched case-insensitively.

#SiteTerms.All

Go go
func (s *SiteTerms) All() []*SiteTerm

All returns every entry, in the order the terms were first seen.

#SiteTerms.Len

Go go
func (s *SiteTerms) Len() int

Len returns how many terms the table holds.

#SiteTerms.Sorted

Go go
func (s *SiteTerms) Sorted() []*SiteTerm

Sorted returns every entry ordered by its lower-cased term, which is the order the glossary page lists them in.

More tools from this site

  • claudestream Drive Claude Code from Python: run it as a subprocess and read its output as typed events, with async and sync sessions, sandbox policies, and tools you define in Python
  • claudewheel A TUI Claude Code Launcher that lets you have more than one profile, manage sessions lifecycle, pick the exact CC version, model to use (even older unlisted ones), pick which GitHub account to use, etc.
  • dirstat Fast, single-binary directory statistics CLI: every file under a tree grouped by format, with counts, sizes, and lines of code, as a colored terminal table or as JSON
  • fastware A batteries-included ASGI framework: msgspec JSON, a managed Granian server, dependency injection, SSE, WebSockets, auth, and a test client
  • go-toml-edit Zero-dep TOML editing library for Go with comment preservation
  • howmuchleft The fastest Claude Code statusline: context window, 5-hour, and weekly limit usage as three customizable gradient bars, rendering in about 6 ms
  • orxtra
  • pgdesign
  • predraw Declarative rendering pipeline: describe a scene in JSON and get SVG, PNG and WebP out, with light and dark style tokens, reusable components and text converted to path outlines
  • reposummary Turn a git repository's history into a Markdown journal: pick a time window or revision range and get a readable digest of what changed, optionally narrated by an LLM
  • rlsbl Release orchestration and project scaffolding CLI that bumps versions, validates a structured JSONL changelog, tags only the commit CI verified, and publishes to npm, PyPI, Go and more
  • safegit git wrapper CLI that gives each commit its own temporary index and retries ref updates on conflict, so concurrent agents share one repository
  • saferm Command-line replacement for rm that archives every deletion with a mandatory reason and the context it ran in, so deleted files can be listed, inspected and restored
  • strictcli
  • stricttest An always-on test-isolation floor: a pytest plugin and a Go env-hygiene module that make a test suite structurally unable to reach real credentials, the real HOME, the network, or the development repository.
  • wesktop A Python framework that turns an ASGI web app into a desktop application, serving it from a local Granian server and displaying it in a native OS window via pywebview
Search