diff --git a/README.md b/README.md
index f01aa319d0..bfe6d82161 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,7 @@ Related [Project-OSRM](https://github.com/Project-OSRM) repositories:
- [Hosted documentation](http://project-osrm.org)
- [osrm-routed HTTP API documentation](docs/http.md)
- [libosrm API documentation](docs/libosrm.md)
+- [libosrm C++ API reference](https://project-osrm.org/libosrm-api/) (generated from the public headers)
## Contact
diff --git a/docs/libosrm.md b/docs/libosrm.md
index b15d56033d..09f55aee3d 100644
--- a/docs/libosrm.md
+++ b/docs/libosrm.md
@@ -2,6 +2,8 @@
OSRM can be used as a library (libosrm) via C++ instead of using it through the HTTP interface and `osrm-routed`. This allows for fine-tuning OSRM and has much less overhead. Here is a quick introduction into how to use `libosrm` in the current version.
+A generated C++ reference for these types --- every class, member and signature, each linked to the line of the header it was read from --- is published at [project-osrm.org/libosrm-api](https://project-osrm.org/libosrm-api/). Regenerate it with `python tools/api-docs/build.py`.
+
Take a look at the example code that lives in the [example directory](https://github.com/Project-OSRM/osrm-backend/tree/master/example). Here is all you ever wanted to know about `libosrm`, that is a short description of what the types do and where to find documentation on it:
## Important interface objects
diff --git a/tools/api-docs/Doxyfile b/tools/api-docs/Doxyfile
new file mode 100644
index 0000000000..d6cf2f97ec
--- /dev/null
+++ b/tools/api-docs/Doxyfile
@@ -0,0 +1,51 @@
+# Doxygen configuration for the generated libosrm C++ API reference.
+#
+# Only ever run by tools/api-docs/build.py, from inside the throwaway tree that script
+# prepares under build/api-docs/src. XML only: Doxygen's own HTML output is not used, the
+# pages are rendered from this XML by Sourcey.
+#
+# INPUT is deliberately the set of headers docs/libosrm.md points embedders at, plus the
+# types those headers expose in their signatures. Widening it to include/ as a whole pulls
+# in the internal engine, extractor, partitioner and customizer trees, which are not part
+# of the libosrm contract.
+
+PROJECT_NAME = "libosrm"
+PROJECT_BRIEF = "The C++ library interface to the Open Source Routing Machine"
+
+INPUT = include/osrm/osrm.hpp \
+ include/engine/api \
+ include/engine/status.hpp \
+ include/engine/engine_config.hpp \
+ include/engine/approach.hpp \
+ include/engine/bearing.hpp \
+ include/engine/hint.hpp \
+ include/util/coordinate.hpp \
+ include/util/json_container.hpp \
+ include/storage/storage_config.hpp
+RECURSIVE = YES
+STRIP_FROM_PATH = .
+FILE_PATTERNS = *.hpp *.h
+
+EXTRACT_ALL = NO
+EXTRACT_PRIVATE = NO
+EXTRACT_STATIC = NO
+EXTRACT_LOCAL_CLASSES = NO
+HIDE_UNDOC_MEMBERS = YES
+HIDE_UNDOC_CLASSES = YES
+INTERNAL_DOCS = NO
+
+JAVADOC_AUTOBRIEF = YES
+QT_AUTOBRIEF = YES
+MARKDOWN_SUPPORT = YES
+AUTOLINK_SUPPORT = YES
+BUILTIN_STL_SUPPORT = YES
+
+GENERATE_XML = YES
+GENERATE_HTML = NO
+GENERATE_LATEX = NO
+XML_OUTPUT = ../xml
+XML_PROGRAMLISTING = NO
+
+QUIET = YES
+WARN_IF_UNDOCUMENTED = NO
+WARN_AS_ERROR = NO
diff --git a/tools/api-docs/README.md b/tools/api-docs/README.md
new file mode 100644
index 0000000000..4037b6abbf
--- /dev/null
+++ b/tools/api-docs/README.md
@@ -0,0 +1,42 @@
+# libosrm C++ API reference generator
+
+`docs/libosrm.md` has, since 2016, described the libosrm contract by telling readers where to
+find each type on GitHub. This directory turns those same headers into a browsable reference,
+published at .
+
+```sh
+python tools/api-docs/build.py # -> build/api-docs/site
+python tools/api-docs/build.py --output /tmp/api # anywhere else
+```
+
+Requirements: `doxygen` and `node` on `PATH`. Nothing is added to the C++ build, no dependency
+is added to the project, and no header is modified — the renderer is fetched on demand with
+`npx --yes sourcey@3.6.5` and everything intermediate is written under `build/`, which is
+already ignored.
+
+## What runs
+
+| Step | File | What it does |
+|---|---|---|
+| 1 | `promote_comments.py` | Copies the headers into `build/api-docs/src`, turning the `//` prose that already sits above a declaration into `///` so Doxygen picks it up. Line-preserving, so the source links stay exact. Conservative: file banners, tooling directives and commented-out code are left alone. |
+| 2 | `Doxyfile` | XML-only Doxygen run over the public headers. `EXTRACT_ALL = NO`, so an undocumented symbol stays out of the reference rather than showing up as a bare signature. |
+| 3 | `sourcey.config.template.ts` | Rendering config. `__COMMIT__` is substituted with `$GITHUB_SHA`, `--commit`, or `git rev-parse HEAD`, so every symbol links to `blob//#L`. |
+
+## Scope
+
+`INPUT` in the `Doxyfile` is deliberately the set of headers `docs/libosrm.md` points at, plus
+the types those headers expose in their signatures:
+
+`include/osrm/osrm.hpp`, `include/engine/api/**`, `include/engine/{status,engine_config,approach,bearing,hint}.hpp`,
+`include/util/{coordinate,json_container}.hpp`, `include/storage/storage_config.hpp`.
+
+Pointing Doxygen at `include/` as a whole would pull in the extractor, partitioner, customizer
+and internal engine trees, which are not part of the library contract embedders code against.
+
+## Publishing
+
+The output is copied into the `libosrm-api/` directory of
+[`Project-OSRM/project-osrm.github.com`](https://github.com/Project-OSRM/project-osrm.github.com),
+minus `_og/` (GitHub Pages runs Jekyll on that repository and Jekyll drops leading-underscore
+paths; those files are only social-preview images). That repository already stores the built
+documentation for each release, so the reference lives alongside it.
diff --git a/tools/api-docs/build.py b/tools/api-docs/build.py
new file mode 100644
index 0000000000..50e5153d05
--- /dev/null
+++ b/tools/api-docs/build.py
@@ -0,0 +1,121 @@
+#!/usr/bin/env python3
+"""Generate the libosrm C++ API reference from the public headers.
+
+ python tools/api-docs/build.py --output build/api-docs/site
+
+Requires `doxygen` and `node` on PATH. The renderer is fetched with
+`npx --yes sourcey@3.6.5`, pinned to an exact version so the output is reproducible.
+
+The run is hermetic with respect to the working tree: the only thing it writes inside the
+repository is the `--work` directory (default `build/api-docs`, which is already ignored by
+the build/ rule in .gitignore). It never edits a header.
+
+Steps:
+ 1. copy the Doxyfile's INPUT paths into work/src, promoting `//` prose to `///`
+ (see promote_comments.py -- line-preserving, so source links stay exact),
+ 2. run Doxygen there to produce XML,
+ 3. render the XML with Sourcey into --output.
+"""
+from __future__ import annotations
+
+import argparse
+import os
+import re
+import shutil
+import subprocess
+import sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+REPO = os.path.dirname(os.path.dirname(HERE))
+SOURCEY = "sourcey@3.6.5"
+SOURCE_EXTS = {".h", ".hpp", ".hxx", ".ipp"}
+
+sys.path.insert(0, HERE)
+from promote_comments import promote # noqa: E402
+
+
+def run(cmd, cwd=None):
+ print("$", " ".join(cmd), flush=True)
+ proc = subprocess.run(cmd, cwd=cwd, shell=False)
+ if proc.returncode != 0:
+ sys.exit(f"failed ({proc.returncode}): {' '.join(cmd)}")
+
+
+def doxyfile_inputs(path):
+ """Read the INPUT list out of the Doxyfile so it is declared in exactly one place."""
+ text = open(path, encoding="utf-8").read()
+ text = re.sub(r"\\\s*\n", " ", text)
+ for line in text.splitlines():
+ if line.strip().startswith("INPUT ") or line.strip().startswith("INPUT="):
+ return line.split("=", 1)[1].split()
+ sys.exit(f"no INPUT line in {path}")
+
+
+def stage_sources(inputs, dest):
+ copied = 0
+ for rel in inputs:
+ src = os.path.join(REPO, rel)
+ if os.path.isfile(src):
+ entries = [(os.path.dirname(rel), os.path.basename(rel))]
+ elif os.path.isdir(src):
+ entries = []
+ for base, _dirs, files in os.walk(src):
+ r = os.path.relpath(base, REPO)
+ entries += [(r, f) for f in files]
+ else:
+ sys.exit(f"Doxyfile INPUT path does not exist: {rel}")
+ for rel_dir, name in entries:
+ if os.path.splitext(name)[1] not in SOURCE_EXTS:
+ continue
+ text = open(os.path.join(REPO, rel_dir, name), encoding="utf-8",
+ errors="replace").read()
+ out = promote(text)
+ assert out.count("\n") == text.count("\n"), name
+ os.makedirs(os.path.join(dest, rel_dir), exist_ok=True)
+ with open(os.path.join(dest, rel_dir, name), "w", encoding="utf-8",
+ errors="replace", newline="") as fh:
+ fh.write(out)
+ copied += 1
+ return copied
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--output", default=os.path.join(REPO, "build", "api-docs", "site"))
+ ap.add_argument("--work", default=os.path.join(REPO, "build", "api-docs"))
+ ap.add_argument("--commit", default=os.environ.get("GITHUB_SHA"),
+ help="commit to link source lines to; defaults to $GITHUB_SHA or HEAD")
+ args = ap.parse_args()
+
+ commit = args.commit or subprocess.run(
+ ["git", "-C", REPO, "rev-parse", "HEAD"], capture_output=True, text=True,
+ check=True).stdout.strip()
+ if not re.fullmatch(r"[0-9a-f]{40}", commit):
+ sys.exit(f"not a full commit sha: {commit!r}")
+
+ work = os.path.abspath(args.work)
+ src = os.path.join(work, "src")
+ shutil.rmtree(src, ignore_errors=True)
+ shutil.rmtree(os.path.join(work, "xml"), ignore_errors=True)
+ os.makedirs(src, exist_ok=True)
+
+ doxyfile = os.path.join(HERE, "Doxyfile")
+ count = stage_sources(doxyfile_inputs(doxyfile), src)
+ print(f"staged {count} headers from {commit}")
+ shutil.copy(doxyfile, os.path.join(src, "Doxyfile"))
+ run(["doxygen", "Doxyfile"], cwd=src)
+
+ template = open(os.path.join(HERE, "sourcey.config.template.ts"), encoding="utf-8").read()
+ with open(os.path.join(work, "sourcey.config.ts"), "w", encoding="utf-8",
+ newline="\n") as fh:
+ fh.write(template.replace("__COMMIT__", commit))
+
+ out = os.path.abspath(args.output)
+ shutil.rmtree(out, ignore_errors=True)
+ npx = "npx.cmd" if os.name == "nt" else "npx"
+ run([npx, "--yes", SOURCEY, "build", "-o", out], cwd=work)
+ print(f"\nlibosrm API reference written to {out}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/api-docs/promote_comments.py b/tools/api-docs/promote_comments.py
new file mode 100644
index 0000000000..0f88d505dc
--- /dev/null
+++ b/tools/api-docs/promote_comments.py
@@ -0,0 +1,85 @@
+"""Promote a libosrm header's plain `//` prose into Doxygen `///` doc comments.
+
+The public headers document themselves with ordinary `//` comments sitting directly above
+the declaration they describe. Doxygen ignores those, so a plain Doxygen run over the public
+headers yields signatures with no prose. This filter promotes a run of `//` lines to `///`
+**only when the run is immediately followed by a declaration**, so the prose that is already
+in the tree lands on the symbol it already describes. Nothing is invented, and nothing is
+edited in the repository: the promotion happens on a throwaway copy under the build
+directory.
+
+The transform is line-preserving -- every promoted line keeps its original line number -- so
+the source links Doxygen emits still point at the right line of the real header.
+
+Deliberately conservative. A run is left alone when it:
+ * is the file banner (the first comment block in the file, i.e. the BSD licence header),
+ * carries a tooling directive (clang-format / NOLINT / cppcheck / SPDX / @file),
+ * looks like commented-out code,
+ * is not immediately followed by a declaration.
+
+Usage: python tools/api-docs/promote_comments.py # writes the result to stdout
+"""
+
+from __future__ import annotations
+
+import io, re, sys
+
+CODEISH = re.compile(
+ r"^\s*(?:#|}|\)|template\s*<|typedef\b|using\b|return\b|if\b|for\b|while\b|else\b)"
+ r"|[;{}]\s*$")
+DIRECTIVE = re.compile(r"clang-format|NOLINT|cppcheck|SPDX|coverity|codespell|@file", re.I)
+DECL = re.compile(
+ r"^\s*(?:QPDF_DLL\b|QPDF_DLL_CLASS\b|template\s*<|class\b|struct\b|enum\b|union\b|namespace\b"
+ r"|typedef\b|using\b|static\b|virtual\b|explicit\b|constexpr\b|inline\b|friend\b"
+ r"|[A-Za-z_~][A-Za-z0-9_:<>,\s\*&\[\]]*\s*\()")
+COMMENT = re.compile(r"^(\s*)//(?!/)(?!!)(.*)$")
+
+
+def promote(text: str) -> str:
+ lines = text.split("\n")
+ out = list(lines)
+ n = len(lines)
+ i = 0
+ first_block = True
+ while i < n:
+ m = COMMENT.match(lines[i])
+ if not m:
+ if lines[i].strip():
+ first_block = False
+ i += 1
+ continue
+ start = i
+ while i < n and COMMENT.match(lines[i]):
+ i += 1
+ end = i # exclusive
+ block = lines[start:end]
+ nxt = lines[end] if end < n else ""
+
+ if first_block: # file banner / licence
+ first_block = False
+ continue
+ body = " ".join(COMMENT.match(b).group(2) for b in block)
+ if not body.strip():
+ continue
+ if DIRECTIVE.search(body):
+ continue
+ if any(CODEISH.search(COMMENT.match(b).group(2)) for b in block):
+ continue # commented-out code
+ if not nxt.strip() or not DECL.match(nxt):
+ continue # not attached to a declaration
+ for k in range(start, end):
+ g = COMMENT.match(lines[k])
+ out[k] = f"{g.group(1)}///{g.group(2)}"
+ return "\n".join(out)
+
+
+def main() -> None:
+ path = sys.argv[1]
+ with io.open(path, encoding="utf-8", errors="replace") as fh:
+ text = fh.read()
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace", newline="\n")
+ sys.stdout.write(promote(text))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/api-docs/sourcey.config.template.ts b/tools/api-docs/sourcey.config.template.ts
new file mode 100644
index 0000000000..d7c535be7c
--- /dev/null
+++ b/tools/api-docs/sourcey.config.template.ts
@@ -0,0 +1,41 @@
+// Rendering config for the generated libosrm C++ API reference.
+//
+// tools/api-docs/build.py copies this next to the Doxygen XML and substitutes __COMMIT__
+// with the commit being documented, so every symbol links to the exact file and line it was
+// read from.
+//
+// Exported as a plain object rather than through sourcey's `defineConfig` helper: the
+// renderer is invoked with `npx sourcey@3.6.5`, so there is no local node_modules for an
+// `import { defineConfig } from "sourcey"` to resolve against. `defineConfig` is an identity
+// helper that exists for editor types, and the schema below is what sourcey reads either way.
+export default ({
+ name: "libosrm C++ API",
+ repo: "https://github.com/Project-OSRM/osrm-backend",
+ editBranch: "__COMMIT__",
+ navigation: {
+ tabs: [
+ {
+ tab: "API Reference",
+ slug: "api",
+ doxygen: {
+ xml: "xml",
+ language: "cpp",
+ groups: false,
+ index: "flat",
+ sourceUrl:
+ "https://github.com/Project-OSRM/osrm-backend/blob/__COMMIT__/",
+ },
+ },
+ ],
+ },
+ navbar: {
+ links: [
+ { type: "github", href: "https://github.com/Project-OSRM/osrm-backend" },
+ ],
+ },
+ footer: {
+ links: [
+ { type: "github", href: "https://github.com/Project-OSRM/osrm-backend" },
+ ],
+ },
+});