axlib 1.0.7
Pages and sections ↓

Converting Markdown to HTML: axlib md2html#

axlib md2html turns Markdown files into HTML pages that open in any browser. Use it to:

The Markdown is read the way GitHub reads it: a document that looks right on GitHub looks the same in the HTML, and links between your Markdown files keep working. This project's own documentation site is built with it (see Publishing a site).

Install#

uv add 'axlib[markdown]'

On a shared server installed with install.sh, the administrator adds it with sudo ./axlib-<version>/install.sh --with-markdown (see INSTALL.md). The markdown extra installs markdown-it-py, linkify-it-py, and mdit-py-plugins, all small, pure-Python packages. Without it, axlib md2html prints axlib md2html: markdown-it-py is not installed: uv add 'axlib[markdown]' and exits with status 2.

Quick start#

One file becomes one page beside it:

$ axlib md2html runbooks/change-plan.md
runbooks/change-plan.md -> runbooks/change-plan.html

A folder becomes a site. -r includes subfolders, -o names the output folder, and --index makes the folder's README the home page:

$ axlib md2html -r runbooks -o site --index runbooks/README.md \
      --site-name "NOC runbooks" --link Wiki=https://wiki.example.net/noc
runbooks/change-plan.md:7: no section #roll-back in runbooks/core/upgrade.md
runbooks/README.md -> site/index.html
runbooks/change-plan.md -> site/change-plan.html
runbooks/core/upgrade.md -> site/core/upgrade.html
copied site/img/topology.png

The first line is a link problem: change-plan.md links to a #roll-back section, but the heading in upgrade.md is "Rollback" (#rollback). The pages are written anyway; --strict makes such problems fail the command. img/topology.png, which change-plan.md shows as an image, is copied so the site works on its own.

Markdown from a pipe becomes a page on standard output, or just the converted HTML with --fragment:

$ printf '# Shift notes\n\n- sw1 **drained**, see https://wiki.example.net/noc\n' | axlib md2html --fragment
<h1 id="shift-notes">Shift notes</h1>
<ul>
<li>sw1 <strong>drained</strong>, see <a href="https://wiki.example.net/noc">https://wiki.example.net/noc</a></li>
</ul>

What is converted#

GitHub-flavored Markdown, as on GitHub:

Every heading gets an id made the way GitHub makes its anchors, so a link such as INSTALL.md#recorded-options that works on GitHub also works in the HTML. GitHub lowercases the heading text, removes punctuation other than - and _, and turns each space into a hyphen; a repeated heading gets -1, -2, and so on. "Scheduled jobs (cron and systemd)" becomes #scheduled-jobs-cron-and-systemd.

HTML written inside the Markdown, such as <details>, is passed through, as CommonMark specifies. Use --escape-html for Markdown from someone else, so any HTML in it is shown as text instead (see Security).

Not supported: emoji short codes (:smile: stays as typed), syntax highlighting (code blocks are shown in one color; the language name is kept as a language-bash class), and diagrams such as Mermaid (shown as code).

What gets converted, and where the HTML goes#

Input Default output With -o
No PATH, or - A page on standard output -o FILE writes the page to FILE
One file NAME.html beside it -o FILE.html, -o DIR/ (an existing folder, or a name ending in /), or -o - for standard output
Several files, or a folder Each page beside its Markdown -o DIR: every page under DIR, in the same layout

The page layout#

Every page has:

The browser tab shows the page title, then the site name. The pages follow the reader's light or dark system setting. On a phone, the document comes first, with a "Pages and sections" link at the top that jumps to the sidebar, which follows the document. Printing leaves out the bar and the sidebar. The layout has no JavaScript and loads nothing from the internet: the styles are written into each page, so a single page can be mailed or opened from a file share and still look right.

When several files are converted together, each link is rewritten and checked:

A link that fails a check is printed to standard error as file:line: message, with the line where the link's paragraph, list item, or table row starts; the file name can be opened as printed. The page is still written. Links inside HTML written in the Markdown (<a href="...">) are neither rewritten nor checked.

Options#

Option Meaning
PATH ... Markdown files and folders, in sidebar order. None, or -, reads standard input.
-o, --output OUTPUT Where the HTML goes; see the table above.
-r, --recursive Also convert Markdown in the folders' subfolders.
--index FILE Write FILE as index.html and list it first.
--site-name NAME Name in the top bar and the browser tab.
--link LABEL=URL A link in the top bar of every page. Repeatable.
--source-url URL Base URL of the Markdown sources, for the footer link.
--title TITLE Page title for standard input (default: the first # heading, else "Document").
--fragment Write only the converted Markdown (standard input or one file).
--escape-html Show HTML in the Markdown as text.
--strict Exit with status 1 when a link problem is found.
--json Print a JSON report instead of the list of pages.
-q, --quiet Do not list the pages written.

The same command is available as axlib-md2html and python -m axlib.md2html.

Output and exit status#

Each page written is listed on standard output as SOURCE -> HTML, and each copied file as copied PATH, relative to the current folder when they are under it. Nothing is listed when a page goes to standard output. --json prints this report instead (problems are then in the report, not on standard error); it cannot be combined with a page written to standard output:

{
  "pages": [
    {
      "source": "runbooks/README.md",
      "output": "site/index.html"
    },
    {
      "source": "runbooks/change-plan.md",
      "output": "site/change-plan.html"
    },
    {
      "source": "runbooks/core/upgrade.md",
      "output": "site/core/upgrade.html"
    }
  ],
  "copied": [
    "site/img/topology.png"
  ],
  "problems": [
    {
      "source": "runbooks/change-plan.md",
      "line": 7,
      "url": "core/upgrade.md#roll-back",
      "message": "no section #roll-back in runbooks/core/upgrade.md"
    }
  ]
}
Status Meaning
0 Converted. Link problems, if any, were printed.
1 A link problem was found and --strict was given. The pages were still written.
2 Nothing was converted: a missing file, a folder with no Markdown files, a file that is not UTF-8, markdown-it-py not installed, an option that does not fit the input, or a file that could not be read or written.

Without a PATH, the command reads standard input, so typed on its own it waits for Markdown to be typed (end with Ctrl+D).

Python API#

from axlib.md2html import SiteOptions, convert_paths, markdown_to_html

html = markdown_to_html("# Change plan\n\n- [ ] drain **sw1**")

report = convert_paths(
    ["runbooks"],
    "site",
    recursive=True,
    index="runbooks/README.md",
    options=SiteOptions(
        site_name="NOC runbooks",
        links=(("Wiki", "https://wiki.example.net/noc"),),
    ),
)
for problem in report.problems:
    print(problem)  # runbooks/change-plan.md:7: no section #roll-back in ...
Name Purpose
markdown_to_html(text, *, escape_html=False) Markdown to an HTML fragment, as --fragment writes it.
convert_text(text, *, title=None, options=None) Markdown to a complete page, as from standard input.
convert_paths(paths, output_dir=None, *, recursive=False, index=None, options=None) Convert and write files and folders; returns a ConversionReport.
build_site(paths, *, output_dir=None, recursive=False, index=None, options=None) The same, without writing: returns a Site with each page's html and body, the files to copy, and the problems.
write_site(site, output_dir=None) Write a Site; returns a ConversionReport.
SiteOptions(site_name=None, links=(), source_url=None, escape_html=False) Settings for every page. links is a tuple of (label, url) pairs.
ConversionReport pages ((source, output) pairs), copied, problems, and to_dict() for JSON.
LinkProblem source, line, url, message; str() gives file:line: message.
parse_markdown(text, *, escape_html=False) A Document: tokens, headings, title, and slugs.
Heading level, text, slug.
slugify(text) A heading's GitHub anchor.
render_page(...), NavEntry The page layout, for building pages yourself.
Md2HtmlError, MarkdownDependencyError Raised when the input cannot be converted, or markdown-it-py is missing.

Every function that parses Markdown raises MarkdownDependencyError when the markdown extra is not installed. convert_paths and build_site raise Md2HtmlError for a missing path, a folder with no Markdown files, a file that is not UTF-8, an --index file that is not converted, or two files that would become the same page (README.md given as --index next to an index.md); they raise OSError when a file cannot be read or written.

Publishing a site#

The pages need no server-side software: copy the output folder to any web server, or open index.html from a file share. Links between pages are relative, so the site works from any folder or URL path.

This project publishes its own documentation this way. make docs runs:

axlib md2html --strict --index README.md -o site \
    --site-name "axlib $(uv version --short)" \
    --link GitHub=https://github.com/robert-patrick-texas/axlib \
    --link PyPI=https://pypi.org/project/axlib/ \
    --source-url https://github.com/robert-patrick-texas/axlib/blob/main/ \
    README.md docs/INSTALL.md ... CHANGELOG.md

.github/workflows/docs.yml runs make docs from each published release's tag and deploys site/ to GitHub Pages, at https://robert-patrick-texas.github.io/axlib/. --strict stops the publication if any link is broken. DEVELOPMENT.md describes the setup.

Security#

Troubleshooting#

Message Cause and fix
markdown-it-py is not installed: uv add 'axlib[markdown]' The markdown extra is missing. Run the command shown, or ask the administrator to rerun install.sh --with-markdown.
FILE:LINE: no section #NAME on this page No heading on the page has that anchor, often because a heading was renamed. Anchors follow GitHub's rules; the # link that appears on hover beside a heading in the page shows the right one.
FILE:LINE: no section #NAME in OTHER.md The same, for a heading in another converted file.
FILE:LINE: PATH does not exist The link or image points at a missing file. Paths are relative to the Markdown file containing the link.
FILE:LINE: PATH is outside FOLDER, so it is not copied A link reaches above the folder the files have in common, so the copy in the output folder will be missing. Move the file under that folder, or convert from a higher folder.
FILE:LINE: PATH is hidden, so it is not copied The link points into a hidden file or folder. Move what you mean to publish out of it, or link to it on a web server instead.
FILE:LINE: PATH is a folder with no converted index.md or README.md A folder link works on disk but has no page in the site. Add a README.md to the folder and convert it too, or link to a file inside it.
A and B would both become index.html --index README.md is used next to an index.md (or two files share a name and folder). Rename one, or choose another --index.
FILE: not UTF-8 text The file was saved in another encoding. Convert it: iconv -f latin1 -t utf-8 FILE > FILE.new.
several pages cannot be written to standard output -o - takes one page. Give an output folder instead.
--fragment needs standard input or one file A fragment has no layout to hold several pages.
--title is for standard input; ..., -r and --index need files, ..., --json needs -o FILE: ..., --json cannot be used with -o -: ... The option does not apply to this kind of input or output; see the table above.
A bare URL stays plain text, or - [ ] shows as text linkify-it-py or mdit-py-plugins is missing from the environment: reinstall with uv add 'axlib[markdown]'.
The command waits and prints nothing With no PATH, it reads standard input. Give a file, or press Ctrl+D.