Additional Languages

This content was generated with the assistance of AI and should be verified against the official documentation of each tool before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against DocFX 2.81.0 (the latest release, published 2026-09-25) and checked on 2026-10-26. DocFX reads .NET code and Swagger 2.0 files itself; for any other language you write a small converter that produces files DocFX can build. The example is a Python converter for the Pocket Units library, run together with docfx build in the mcr.microsoft.com/dotnet/sdk:10.0 Docker image (SDK 10.0.403), and the listings show the real output.

What an API docs converter is, and why you need one

DocFX natively generates reference documentation for .NET and for REST APIs. It has no parser for Python, Java, TypeScript, Go or any other language. The official documentation’s answer is an API docs converter: any tool that runs before DocFX and writes files the DocFX build understands. The docfx metadata command for .NET is described as one instance of such a converter, so a custom converter does for your language what metadata does for C#.

The contract is deliberately small. A converter writes a folder that contains:

  • A toc.yml that holds the hierarchy shown in the table of contents. Its structure is the one described in Table of Contents.

  • A set of YAML files that follow the API Page data structure, one file for each page. The path of a generated file corresponds to the URL used to reach the page.

DocFX does not care which language the converter is written in or how it finds the information. Parsing source code, reading a documentation tool’s JSON dump (for example a compiler or doc-extractor output) or calling a language server are all fine, as long as the output files are valid. The wider pipeline, where the converter takes the place of the metadata stage, is described in Basic Concepts and Pipeline.

Python source files read by a converter, which writes API Page YAML and a toc.yml that docfx build renders

The files the converter must produce

The official example uses this layout, where api is the output directory of the custom converter:

docfx.json
api/
    toc.yml
    api-page-1.yml
    api-page-2.yml

To build the output, the converter folder is added to build.content in docfx.json. The metadata section is not used at all, because nothing needs to be extracted from .NET code:

{
  "build": {
    "content": [
      { "files": ["api/**/*.yml", "toc.yml", "index.md"] }
    ],
    "output": "_site",
    "template": ["default", "modern"]
  }
}

The glob api/**/*.yml picks up the page files and also the api/toc.yml. A root toc.yml next to docfx.json links to the generated folder, as for any other TOC (href: api/ makes the first entry of api/toc.yml the landing page of that menu item). The docfx.json keys are explained in docfx.json.

The API Page format

An API page is a YAML file that describes a general API page as a list of typed blocks. The first line of the file must be #YamlMime:ApiPage; the rest follows this structure:

  • title (required): the page title.

  • body (required): a list of blocks.

  • languageId (optional): the default code language identifier for code blocks, using the identifiers known to Visual Studio Code.

  • metadata (optional): opaque key and value pairs written to the page as HTML meta tags.

The body blocks are:

Block Meaning

markdown

A Markdown string.

code

Code text, with an optional languageId that overrides the page default.

h1 to h6

A heading, with an optional id that becomes the URL fragment.

api1 to api4

An API heading with an optional id, deprecated (a boolean or a Markdown reason), preview (a boolean or a disclaimer), src (a source URL) and metadata (written as data- attributes).

facts

A sheet of name and value pairs.

list

A list of inline items.

inheritance

One inheritance chain from base type to derived type, as a list of inline items.

parameters

A list of parameters, each with optional name, type, default, description (Markdown), deprecated and preview.

An inline is a plain string, a link written as text plus an optional url, or a list that mixes both. Facts, lists, inheritance chains and the type of a parameter use inlines, which is how a page links to another one.

The documented schema has no uid property. API Page files therefore are not the target of the <xref:uid> links described in Links and Cross References; a converter links pages with url values, as the example below does.

A converter for Pocket Units in Python

The Python half of Pocket Units has one module, pocket_units/converter.py, with an enum and a function documented in Google-style docstrings. The converter below reads the package with the standard library ast module, so it needs no dependencies and runs on any Python 3.9 or later (it was run with Python 3.9). For each module it writes a module page, one page for each class and one for each public function, then a toc.yml.

converter/py2apipage.py
"""Convert the docstrings of a Python package into DocFX API Page YAML and a toc.yml.

Usage: python3 py2apipage.py <package-dir> <output-dir>
Only the standard library is used.
"""
import ast
import json
import re
import sys
from pathlib import Path

SECTIONS = ("Args", "Returns", "Raises", "Example")


def scalar(value):
    # JSON strings and numbers are valid YAML scalars.
    return json.dumps(value, ensure_ascii=False)


def emit(node, indent=0):
    """Emit lists of dicts and dicts as block-style YAML."""
    pad = " " * indent
    lines = []
    if isinstance(node, dict):
        for key, value in node.items():
            if isinstance(value, (dict, list)) and value:
                lines.append(f"{pad}{key}:")
                lines.extend(emit(value, indent + (0 if isinstance(value, list) else 2)))
            else:
                lines.append(f"{pad}{key}: {scalar(value)}")
    else:
        for item in node:
            if isinstance(item, dict):
                inner = emit(item, indent + 2)
                inner[0] = f"{pad}- " + inner[0].lstrip()
                lines.extend(inner)
            else:
                lines.append(f"{pad}- {scalar(item)}")
    return lines


def to_markdown(text):
    """Turn reStructuredText inline roles and double backticks into Markdown code spans."""
    text = re.sub(r":\w+:`~?([^`]+)`", r"`\1`", text)
    return re.sub(r"``([^`]+)``", r"`\1`", text)


def parse_docstring(doc):
    """Split a Google-style docstring into summary, description and sections."""
    doc = ast.get_docstring(doc, clean=True) if not isinstance(doc, str) else doc
    parts = {"summary": "", "description": "", "sections": {}}
    if not doc:
        return parts
    current = None
    free = []
    for line in doc.splitlines():
        header = line.rstrip(":")
        if line.endswith(":") and header in SECTIONS and not line.startswith(" "):
            current = header
            parts["sections"][current] = []
        elif current:
            parts["sections"][current].append(line)
        else:
            free.append(line)
    text = "\n".join(free).strip()
    summary, _, rest = text.partition("\n\n")
    parts["summary"] = to_markdown(summary.replace("\n", " "))
    parts["description"] = to_markdown(rest.strip())
    return parts


def name_value(lines):
    """Turn 'name: text' lines of an Args or Raises section into pairs."""
    pairs = []
    for line in lines:
        match = re.match(r"^\s{4}(\w+): (.*)$", line)
        if match:
            pairs.append([match.group(1), to_markdown(match.group(2))])
        elif line.strip() and pairs:
            pairs[-1][1] += " " + line.strip()
    return pairs


def dedent(lines):
    return "\n".join(line[4:] if line.startswith("    ") else line for line in lines).strip()


def signature(func):
    args = []
    defaults = [None] * (len(func.args.args) - len(func.args.defaults)) + list(func.args.defaults)
    for arg, default in zip(func.args.args, defaults):
        text = arg.arg
        if arg.annotation is not None:
            text += ": " + ast.unparse(arg.annotation)
        if default is not None:
            text += " = " + ast.unparse(default)
        args.append(text)
    result = f"def {func.name}({', '.join(args)})"
    if func.returns is not None:
        result += " -> " + ast.unparse(func.returns)
    return result


def function_blocks(func, level):
    doc = parse_docstring(func)
    sec = doc["sections"]
    blocks = [{f"api{level}": f"{func.name}()", "id": func.name}]
    blocks.append({"code": signature(func), "languageId": "python"})
    if doc["summary"]:
        blocks.append({"markdown": doc["summary"]})
    if doc["description"]:
        blocks.append({"markdown": doc["description"]})
    if "Args" in sec:
        blocks.append({f"h{level + 1}": "Parameters"})
        params = []
        for name, text in name_value(sec["Args"]):
            arg = next((a for a in func.args.args if a.arg == name), None)
            param = {"name": name}
            if arg is not None and arg.annotation is not None:
                param["type"] = ast.unparse(arg.annotation)
            param["description"] = text
            params.append(param)
        blocks.append({"parameters": params})
    if "Returns" in sec:
        blocks.append({f"h{level + 1}": "Returns"})
        blocks.append({"markdown": dedent(sec["Returns"])})
    if "Raises" in sec:
        blocks.append({f"h{level + 1}": "Exceptions"})
        blocks.append({"parameters": [{"name": n, "description": t} for n, t in name_value(sec["Raises"])]})
    if "Example" in sec:
        blocks.append({f"h{level + 1}": "Example"})
        blocks.append({"code": dedent(sec["Example"]), "languageId": "python"})
    return blocks


def class_page(cls, module):
    doc = parse_docstring(cls)
    is_enum = any(ast.unparse(b) == "Enum" for b in cls.bases)
    page_uid = f"{module}.{cls.name}"
    body = [{"h1": f"{cls.name} {'enum' if is_enum else 'class'}"}]
    body.append({"facts": [{"name": "Module", "value": [{"text": module, "url": f"{module}.html"}]},
                           {"name": "Bases", "value": ", ".join(ast.unparse(b) for b in cls.bases) or "object"}]})
    body.append({"code": f"class {cls.name}({', '.join(ast.unparse(b) for b in cls.bases)})", "languageId": "python"})
    if doc["summary"]:
        body.append({"markdown": doc["summary"]})
    members = [n for n in cls.body if isinstance(n, ast.Assign)]
    if is_enum and members:
        body.append({"h2": "Members"})
        body.append({"parameters": [
            {"name": m.targets[0].id, "type": "float", "default": ast.unparse(m.value)}
            for m in members]})
    for method in (n for n in cls.body if isinstance(n, ast.FunctionDef) and not n.name.startswith("_")):
        body.extend(function_blocks(method, 2))
    return page_uid, {"title": page_uid, "languageId": "python", "body": body}


def module_page(path, module):
    tree = ast.parse(path.read_text(encoding="utf-8"))
    summary = parse_docstring(ast.get_docstring(tree) or "")["summary"]
    pages, toc = {}, []
    body = [{"h1": f"{module} module"}, {"markdown": summary}]
    exported = []
    for node in tree.body:
        if isinstance(node, ast.ClassDef):
            uid, page = class_page(node, module)
            pages[uid] = page
            toc.append({"name": node.name, "href": f"{uid}.yml"})
            exported.append({"text": node.name, "url": f"{uid}.html"})
        elif isinstance(node, ast.FunctionDef) and not node.name.startswith("_"):
            uid = f"{module}.{node.name}"
            pages[uid] = {"title": uid, "languageId": "python", "body": function_blocks(node, 1)}
            toc.append({"name": node.name + "()", "href": f"{uid}.yml"})
            exported.append({"text": node.name + "()", "url": f"{uid}.html"})
    body.append({"h2": "Contents"})
    body.append({"list": exported})
    pages[module] = {"title": module, "languageId": "python", "body": body}
    return pages, toc


def main(package_dir, out_dir):
    package = Path(package_dir)
    out = Path(out_dir)
    out.mkdir(parents=True, exist_ok=True)
    root = package.name
    all_pages, items = {}, []
    for path in sorted(package.glob("*.py")):
        if path.name == "__init__.py":
            continue
        module = f"{root}.{path.stem}"
        pages, toc = module_page(path, module)
        all_pages.update(pages)
        items.append({"name": module, "href": f"{module}.yml", "items": toc})
    for uid, page in all_pages.items():
        text = "#YamlMime:ApiPage\n" + "\n".join(emit(page)) + "\n"
        (out / f"{uid}.yml").write_text(text, encoding="utf-8")
    toc_text = "### YamlMime:TableOfContent\n" + "\n".join(emit(items)) + "\n"
    (out / "toc.yml").write_text(toc_text, encoding="utf-8")
    print(f"wrote {len(all_pages)} pages and toc.yml to {out}")


if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])

A few design points are worth copying into your own converter:

  • The page file name is the fully qualified name (pocket_units.converter.convert.yml), so the URL of a page is predictable and two pages cannot collide.

  • JSON strings and numbers are valid YAML scalars, so the small emitter quotes every scalar with json.dumps and never has to worry about YAML escaping of colons, quotes or line breaks inside docstrings. A library such as PyYAML would work equally well.

  • Docstring text is turned into Markdown, because markdown blocks and parameter descriptions are rendered as Markdown. Here the reStructuredText roles and double backticks used in the Pocket Units docstrings become single backtick code spans.

  • Parameters and exceptions both use the parameters block, since the format has no dedicated block for exceptions.

Running the converter and the build

The converter runs first, then DocFX. The root toc.yml and index.md are ordinary hand-written files:

- name: Home
  href: index.md
- name: Python API
  href: api/
python3 converter/py2apipage.py pocket_units api
docker run --rm -v "$PWD":/w -w /w mcr.microsoft.com/dotnet/sdk:10.0 bash -c \
  'dotnet tool install -g docfx --version 2.81.0 && export PATH=$PATH:/root/.dotnet/tools \
   && docfx build docfx.json'

The converter printed wrote 3 pages and toc.yml to api, and the build used the API Page processor for the three pages:

Building 3 file(s) in ApiPageDocumentProcessor()...
Building 1 file(s) in ConceptualDocumentProcessor(BuildConceptualDocument=>ValidateConceptualDocumentMetadata)...
Building 2 file(s) in TocDocumentProcessor(BuildTocDocument)...
Applying templates to 6 model(s)...
XRef map exported.

Build succeeded.

    0 warning(s)
    0 error(s)

The generated files

The function page written for convert (a top-level function uses an api1 heading, which gives the page its h1 element with the id from the file):

#YamlMime:ApiPage
title: "pocket_units.converter.convert"
languageId: "python"
body:
- api1: "convert()"
  id: "convert"
- code: "def convert(value: float, from_unit: LengthUnit, to_unit: LengthUnit) -> float"
  languageId: "python"
- markdown: "Convert a length between two units."
- h2: "Parameters"
- parameters:
  - name: "value"
    type: "float"
    description: "The magnitude to convert."
  - name: "from_unit"
    type: "LengthUnit"
    description: "The unit of `value`."
  - name: "to_unit"
    type: "LengthUnit"
    description: "The target unit."
- h2: "Returns"
- markdown: "The converted magnitude."
- h2: "Exceptions"
- parameters:
  - name: "TypeError"
    description: "If a unit is not a `LengthUnit`."
- h2: "Example"
- code: ">>> round(convert(10, LengthUnit.KILOMETRE, LengthUnit.MILE), 3)\n6.214"
  languageId: "python"

The enum page uses facts for the module and the base class, and parameters for the members with their defaults:

#YamlMime:ApiPage
title: "pocket_units.converter.LengthUnit"
languageId: "python"
body:
- h1: "LengthUnit enum"
- facts:
  - name: "Module"
    value:
    - text: "pocket_units.converter"
      url: "pocket_units.converter.html"
  - name: "Bases"
    value: "Enum"
- code: "class LengthUnit(Enum)"
  languageId: "python"
- markdown: "Length units supported by `convert`."
- h2: "Members"
- parameters:
  - name: "METRE"
    type: "float"
    default: "1.0"
  - name: "KILOMETRE"
    type: "float"
    default: "1000.0"
  - name: "FOOT"
    type: "float"
    default: "0.3048"
  - name: "MILE"
    type: "float"
    default: "1609.344"

The module page lists its contents with links (url), which is how pages point at each other:

#YamlMime:ApiPage
title: "pocket_units.converter"
languageId: "python"
body:
- h1: "pocket_units.converter module"
- markdown: "Conversion between length units."
- h2: "Contents"
- list:
  - text: "LengthUnit"
    url: "pocket_units.converter.LengthUnit.html"
  - text: "convert()"
    url: "pocket_units.converter.convert.html"

And the generated api/toc.yml, in which each child entry is a page file:

### YamlMime:TableOfContent
- name: "pocket_units.converter"
  href: "pocket_units.converter.yml"
  items:
  - name: "LengthUnit"
    href: "pocket_units.converter.LengthUnit.yml"
  - name: "convert()"
    href: "pocket_units.converter.convert.yml"

The build wrote pocket_units.converter.html, pocket_units.converter.LengthUnit.html and pocket_units.converter.convert.html into _site/api, each file name matching the YAML file name. In the rendered function page the visible text was the convert() heading, the def convert(...) signature, the summary, then the Parameters, Returns, Exceptions and Example sections with the docstring text, and the enum page listed the four members with their default values. The href: api/ entry in the root TOC resolved to api/pocket_units.converter.html, with the sidebar taken from api/toc.html. The pages were generated and inspected as HTML text; they were not opened in a browser.

Choosing the template

API Page files are rendered by the modern template. When the build was run with only the default template, it still finished, but with the warning UnknownContentTypeForTemplate: There is no template processing document type(s): ApiPage and no page was written for the YAML files. Keep the template list as default plus modern, or modern alone, in docfx.json; see Templates and Customization.

What happens when the output is wrong

The converter owns the validity of its files, and DocFX reports problems when it loads them. Each case below was produced on purpose by editing one generated page.

Mistake Result of the build

title removed from a page

error InvalidInputFile: Unable to load file ... via processor 'ApiPageDocumentProcessor': JSON deserialization for type 'Docfx.Build.ApiPage.ApiPage' was missing required properties including: 'title'. The build fails.

A block with an unknown key (banner)

error InvalidInputFile: ... Cannot deserialize into one of the supported types for Docfx.Build.ApiPage.Block. The build fails.

The #YamlMime:ApiPage first line removed

The file is not handed to the API Page processor and no page is written. The build succeeds with the warning Unable to find file "pocket_units.converter.convert.yml" for Href referenced by TOC file "api/toc.yml".

The last row is the quiet one: the build is green apart from a warning about the TOC link, so run builds with --warningsAsErrors in continuous integration. The option is described in CLI.

Alternatives to API Page

API Page is the richest target, but it is not the only one. The docfx metadata command can itself write CommonMark files with outputFormat: markdown, and the files listed in build.content then include *.md as well as the YAML. A converter for a language with simple needs can do the same: write one Markdown file for each module or class and a toc.yml, and DocFX builds them as conceptual documents (see Markdown). You lose the structured blocks (facts, parameters) but gain full control over the text. If the language already has a documentation tool, the other route is to publish its HTML separately and link to it from a DocFX site.

Pitfalls and version notes

  • Run the converter before docfx build, in every environment. DocFX does not call it; a missing step shows up as stale pages or a TOC warning, not as an error.

  • A converter output folder is generated: add it to .gitignore unless you commit generated documentation on purpose.

  • The file name is the URL, so keep names deterministic. Sort the input (the example sorts files) so the output does not change between runs.

  • Without a uid, API Page files do not take part in xref resolution; internal links go through relative url values that end in .html.

  • The official page describes the contract only; it ships no converters. The converter above is a minimal example, not a full Python documentation generator: it ignores class attributes other than enum members, nested classes, decorators, type aliases and inheritance chains (the inheritance block exists for those).

  • Pin the DocFX version in the build (dotnet tool install -g docfx --version 2.81.0) and run the converter output through the build again when you upgrade.