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
const ChevronIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ` +ChevronIcon is the framework's own chevron.
#CloseIcon
const CloseIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ` +CloseIcon dismisses a dialog or a notice.
#MenuIcon
const MenuIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ` +MenuIcon opens the mobile sidebar.
#InfoIcon
const InfoIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ` +InfoIcon marks the "info" callout kind.
#WarnIcon
const WarnIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ` +WarnIcon marks the "warn" and "danger" callout kinds.
#NoteIcon
const NoteIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ` +NoteIcon marks the "note" callout kind.
#CheckIcon
const CheckIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ` +CheckIcon marks the "tip" callout kind.
#NoticeIcon
const NoticeIcon = WarnIconNoticeIcon is the superseded-version banner's glyph. The banner is the "warn" kind, so it carries the warn glyph.
#PygmentsScope
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
const PygmentsVarPrefix = "--sd-hl-"PygmentsVarPrefix is the prefix every generated highlight custom property carries.
#CalloutKind
type CalloutKind structCalloutKind is how one admonition type is painted: the framework kind it maps to, the glyph it carries, and the ARIA role it takes.
#HeadingAnchor
type HeadingAnchor structHeadingAnchor 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
type DeclaredTerm structDeclaredTerm is one author-declared term: the term as written, the id its definition site carries, and the definition's HTML.
#SiteTerm
type SiteTerm structSiteTerm 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
type SiteTerms structSiteTerms 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
func AdmonitionTypes() []stringAdmonitionTypes 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
func CalloutKindFor(admonitionType string) (CalloutKind, bool)CalloutKindFor returns how the named admonition type is painted, and whether it is one this build recognizes.
#PageTitleAnchor
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
func AssignHeadingAnchors(tokens []tokenizer.Token, pageTitle *string) []HeadingAnchorAssignHeadingAnchors 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
func MdToHTML(text string, metadata, cfg map[string]any) stringMdToHTML 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
func ParseTable(tableLines []string) stringParseTable parses markdown table lines into an HTML
| Header1 | Header2 |
|---|---|
| Cell1 | Cell2 |
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
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
func PathHop(p, prefix, sitePrefix string) stringPathHop 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.
#RewriteInternalLinks
func RewriteInternalLinks(bodyHTML, mdPath string, legacyHTMLLinks bool) stringRewriteInternalLinks 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 "
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.
#MdToHTMLPath
func MdToHTMLPath(mdPath string) stringMdToHTMLPath 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
func HTMLPathToURL(htmlPath string) stringHTMLPathToURL 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
func HTMLToMdPath(htmlPath string) stringHTMLToMdPath 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
func MinifyJS(jsText string) stringMinifyJS 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
func Slugify(text string) stringSlugify 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
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
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
func NewSiteTerms() *SiteTermsNewSiteTerms returns an empty term table.
#CollectDeclaredTerms
func CollectDeclaredTerms(bodyHTML string) []DeclaredTermCollectDeclaredTerms returns every author-declared term in bodyHTML, in document order.
There is one result per definition site: a
Nothing here is inferred -- a term appears only where an author wrote a , a definition list, or the glossary directive.
#LinkDefinitionSites
func LinkDefinitionSites(bodyHTML string, siteTerms *SiteTerms, currentPage, glossaryURL string) stringLinkDefinitionSites 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
func ApplyCrossPageTerms(bodyHTML string, siteTerms *SiteTerms, currentPage, prefix, sitePrefix string) stringApplyCrossPageTerms 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 , , , ,
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
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
func ThemeCSSRel(themeMeta *themes.Metadata) stringThemeCSSRel 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
func (s *SiteTerms) Add(term, page, anchor, definition string) *SiteTermAdd 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
func (s *SiteTerms) Get(term string) (*SiteTerm, bool)Get returns the entry for a term, matched case-insensitively.
#SiteTerms.All
func (s *SiteTerms) All() []*SiteTermAll returns every entry, in the order the terms were first seen.
#SiteTerms.Len
func (s *SiteTerms) Len() intLen returns how many terms the table holds.
#SiteTerms.Sorted
func (s *SiteTerms) Sorted() []*SiteTermSorted returns every entry ordered by its lower-cased term, which is the order the glossary page lists them in.