Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,36 @@ on:
- main

jobs:
docs:
name: Versioned API documentation
permissions:
contents: read
if: |
github.event_name != 'pull_request' ||
github.event.pull_request.author_association == 'COLLABORATOR' ||
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.user.login == 'dependabot[bot]' ||
contains(github.event.pull_request.labels.*.name, 'safe to test')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
filter: tree:0
- uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install Doxygen
run: sudo apt-get install doxygen
- name: Test documentation tooling
run: |
python3 -m unittest discover -s tests -p 'test_versioned_docs.py'
node --test tests/test_version_selector.js
- name: Build versioned documentation
env:
GH_TOKEN: ${{ github.token }}
run: python3 scripts/generate_versioned_api_docs.py

tests:
name: Unit tests

Expand Down
15 changes: 13 additions & 2 deletions .github/workflows/publish-docs.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
name: Build and publish docs

on:
release:
types: [published]

push:
branches: ['main']

Expand All @@ -26,20 +29,28 @@ jobs:
with:
filter: tree:0
fetch-depth: 0
ref: main

- name: Setup Pages
uses: actions/configure-pages@v5

- name: Install Doxygen
run: sudo apt-get install doxygen

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'

- name: Build docs
run: bash ./scripts/generate_api_docs.sh
env:
GH_TOKEN: ${{ github.token }}
run: python3 scripts/generate_versioned_api_docs.py

- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: 'api-docs/_build/html/'
path: 'api-docs/_build/versioned/'

- name: Deploy to GitHub Pages
id: deployment
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ compile_commands.json

# Doxygen docs
/api-docs/_build
__pycache__/

# Generated files
/src/c2pa.cpp
Expand Down
42 changes: 38 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,13 +175,17 @@ ctest --test-dir build/debug --output-on-failure

### Building API documentation

Doxygen automatically builds API documentation on each PR.
The [API documentation](https://contentauth.github.io/c2pa-cpp/) is built with
Doxygen from selected published releases. Use the version selector to switch
releases. The first tag in [api-docs/versions.json](api-docs/versions.json) is
the default at the existing root URL; each release is also available at
`https://contentauth.github.io/c2pa-cpp/versions/<tag>/`.

To generate API docs locally, these are the main files:

- Configuration file: `c2pa-cpp/Doxyfile`
- Script: `c2pa-cpp/scripts/generate_api_docs.sh`
- Output directory: `docs/_build/html`
- Output directory: `api-docs/_build/html`

Install Doxygen if needed:

Expand All @@ -196,9 +200,39 @@ To generate docs, enter the command:
./scripts/generate_api_docs.sh
```

Or run `make -C docs`.
Open `api-docs/_build/html/index.html` to see the working-tree documentation.
This local build does not include a version selector.

Open `_build/html/index.html` to see the results.
To build the versioned site, install Python 3.10 or newer and the
[GitHub CLI](https://cli.github.com/), authenticate with `gh auth login` (or
set `GH_TOKEN`), and ensure the selected release tags are available locally
with `git fetch --tags`. Then run:

```sh
python3 scripts/generate_versioned_api_docs.py
```

Open `api-docs/_build/versioned/index.html` to see the default release.
Version-specific pages are under `api-docs/_build/versioned/versions/<tag>/`.
The build verifies that each configured tag has a published GitHub release and
reads that tag's headers, C sources, and README without running release code.
It uses the current branch's Doxygen configuration and styling.

To publish documentation for another release, add its tag to
[api-docs/versions.json](api-docs/versions.json), putting the desired default
first. Publishing a release rebuilds the configured versions but does not
automatically add it to this list. Pushes to `main` and manual workflow runs
also rebuild and deploy the site.

Run the documentation tooling tests with:

```sh
python3 -m unittest discover -s tests -p 'test_versioned_docs.py'
node --test tests/test_version_selector.js
```

The selector tests require Node.js 18 or newer. CI runs these tests and builds
the versioned documentation on pull requests.

## License

Expand Down
6 changes: 6 additions & 0 deletions api-docs/versions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"versions": [
"v0.26.14",
"v0.26.13"
]
}
5 changes: 5 additions & 0 deletions docs/doxygen-extra.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@
display: none;
}

.docs-version-selector {
display: block;
margin: 0.5em 0;
font-size: 14px;
}
30 changes: 30 additions & 0 deletions docs/version-selector.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
(function () {
"use strict";

const script = document.currentScript;
const versions = JSON.parse(script.dataset.versions);
const assets = new URL(".", script.src);
const root = new URL(script.dataset.prefix ? "../../" : "./", assets);
const container = document.getElementById("projectalign");
if (!container) {
throw new Error("Cannot find the Doxygen project title for the version selector.");
}

const label = document.createElement("label");
label.className = "docs-version-selector";
label.textContent = "Version: ";
const select = document.createElement("select");
select.setAttribute("aria-label", "API documentation version");
for (const tag of versions) {
const option = document.createElement("option");
option.value = new URL("versions/" + tag + "/index.html", root).href;
option.textContent = tag;
option.selected = tag === script.dataset.version;
select.appendChild(option);
}
select.addEventListener("change", function () {
window.location.assign(select.value);
});
label.appendChild(select);
container.appendChild(label);
})();
3 changes: 1 addition & 2 deletions scripts/generate_api_docs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@ if ! command -v doxygen >/dev/null 2>&1; then
fi

OUT_DIR="api-docs/_build/html"
rm -rf "api-docs/_build" || true
rm -rf "$OUT_DIR" "api-docs/_build/xml"

doxygen Doxyfile | cat

echo "Doxygen HTML docs: $OUT_DIR/index.html"

151 changes: 151 additions & 0 deletions scripts/generate_versioned_api_docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Build Doxygen documentation from explicitly selected published releases."""

import html
import io
import json
from pathlib import Path, PurePosixPath
import re
import shutil
import stat
import subprocess
import tempfile
import zipfile


ROOT = Path(__file__).resolve().parents[1]
TAG_PATTERN = re.compile(r"v[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9][A-Za-z0-9.-]*)?")


def load_versions(path: Path) -> list[str]:
config = json.loads(path.read_text(encoding="utf-8"))
versions = config.get("versions") if isinstance(config, dict) else None
if not isinstance(versions, list) or not versions:
raise ValueError("Documentation versions must be a non-empty list.")
for tag in versions:
if not isinstance(tag, str) or not TAG_PATTERN.fullmatch(tag):
raise ValueError("Documentation versions must be release tags such as v0.26.14.")
if len(set(versions)) != len(versions):
raise ValueError("Documentation versions must not contain duplicate tags.")
return versions


def verify_release(tag: str) -> None:
result = subprocess.run(
["gh", "api", f"repos/contentauth/c2pa-cpp/releases/tags/{tag}"],
check=True, stdout=subprocess.PIPE, text=True,
)
release = json.loads(result.stdout)
if (
not isinstance(release, dict)
or release.get("tag_name") != tag
or release.get("draft") is not False
):
raise ValueError(f"{tag} is not a published GitHub release.")


def export_sources(root: Path, tag: str, destination: Path) -> None:
# Only export documentation inputs; never execute code from a release.
commit = subprocess.run(
["git", "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}"],
cwd=root, check=True, capture_output=True, text=True,
).stdout.strip()
archive = subprocess.run(
["git", "archive", "--format=zip", commit, "include", "src", "README.md"],
cwd=root, check=True, capture_output=True,
).stdout
with zipfile.ZipFile(io.BytesIO(archive)) as sources:
headers = 0
for entry in sources.infolist():
path = PurePosixPath(entry.filename)
if (
path.is_absolute()
or ".." in path.parts
or any("\\" in part or ":" in part for part in path.parts)
or not path.parts
or path.parts[0] not in ("include", "src", "README.md")
or (path.parts[0] == "README.md" and len(path.parts) != 1)
or stat.S_ISLNK(entry.external_attr >> 16)
):
raise ValueError(f"Unsafe source archive in release {tag}.")
if entry.is_dir() or (
path != PurePosixPath("README.md")
and path.suffix not in (".h", ".hpp", ".c")
):
continue
target = destination.joinpath(*path.parts)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(sources.read(entry))
if path.parts[0] == "include" and path.suffix in (".h", ".hpp"):
headers += 1
if not headers or not (destination / "README.md").is_file():
raise ValueError(f"Release {tag} is missing API headers or README.md.")


def build_html(stage: Path, output: Path, tag: str, versions: list[str], prefix: str) -> None:
header = stage / "header.html"
subprocess.run(
["doxygen", "-w", "html", str(header), str(stage / "footer.html"),
str(stage / "doxygen.css"), "Doxyfile"],
cwd=stage, check=True,
)
template = header.read_text(encoding="utf-8")
if "</head>" not in template:
raise ValueError("Doxygen's default HTML header has no closing head element.")
script = (
'<script defer src="$relpath^version-selector.js"'
f' data-version="{html.escape(tag, quote=True)}"'
f' data-versions="{html.escape(json.dumps(versions), quote=True)}"'
f' data-prefix="{html.escape(prefix, quote=True)}"></script>\n'
)
header.write_text(template.replace("</head>", script + "</head>"), encoding="utf-8")
config = (stage / "Doxyfile").read_text(encoding="utf-8")
config += (
f'\nOUTPUT_DIRECTORY = "{output.as_posix()}"\n'
f'PROJECT_NUMBER = "{tag}"\n'
'HTML_HEADER = "header.html"\n'
'HTML_EXTRA_FILES = "docs/version-selector.js"\n'
'GENERATE_XML = NO\n'
)
subprocess.run(["doxygen", "-"], input=config, text=True, cwd=stage, check=True)
if not (output / "html/index.html").is_file():
raise RuntimeError(f"Doxygen did not generate an index page for {tag}.")


def build_site() -> Path:
versions = load_versions(ROOT / "api-docs/versions.json")
for tag in versions:
verify_release(tag)

build = ROOT / "api-docs/_build"
build.mkdir(parents=True, exist_ok=True)
output = build / "versioned"
with tempfile.TemporaryDirectory(prefix="versioned-docs-", dir=build) as temporary:
workspace = Path(temporary)
site = workspace / "site"
for tag in versions:
stage = workspace / tag
stage.mkdir()
export_sources(ROOT, tag, stage)
shutil.copy2(ROOT / "Doxyfile", stage)
(stage / "docs").mkdir()
for asset in ("doxygen-extra.css", "version-selector.js"):
shutil.copy2(ROOT / "docs" / asset, stage / "docs")
destination = stage / "output"
build_html(stage, destination, tag, versions, f"versions/{tag}")
shutil.copytree(destination / "html", site / "versions" / tag)

# Rebuild the default so selector links also work at the existing root URL.
default = workspace / versions[0]
destination = default / "default-output"
build_html(default, destination, versions[0], versions, "")
shutil.copytree(destination / "html", site, dirs_exist_ok=True)
(site / ".nojekyll").touch()
if output.exists():
shutil.rmtree(output)
shutil.move(str(site), str(output))
return output


if __name__ == "__main__":
print(f"Versioned API docs generated at: {build_site()}")
Loading