Skip to content
Merged
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
121 changes: 105 additions & 16 deletions automation/source-repo-templates/api-docs.rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,47 @@ jobs:
ref: ${{ inputs.ref || github.ref }}
persist-credentials: false

- name: Generate per-crate stub pages
# For each crate in `crates/*`, emit one Markdown page with:
# - the crate name + version + description
# - a link to docs.rs (canonical Rust API reference)
# - the crate's README.md content (the human-facing docs)
# The actual rustdoc API is intentionally NOT duplicated here;
# docs.rs is the source of truth and links from these pages
# take users there.
- name: Install Rust nightly + cargo-doc-md
# cargo-doc-md is the rustdoc-JSON-to-markdown converter that
# produces real API references (modules, structs, traits,
# methods with full doc comments and examples). Rustdoc JSON
# is a nightly-only feature, so install nightly alongside the
# default stable toolchain. The tool is small and pure-Rust;
# `cargo install` rebuilds quickly when its version doesn't
# change between runs because the runner's cache reuses the
# cargo registry.
run: |
rustup toolchain install nightly --profile minimal --no-self-update
cargo install cargo-doc-md --locked
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Running cargo install without a pinned version can lead to non-deterministic builds. Consider pinning the version and using actions/cache to improve efficiency. Additionally, ensure the workflow's concurrency is configured with cancel-in-progress: true to prevent race conditions. When configuring the documentation generator, prefer explicit project lists over automated discovery and include the relative path to the package directory in source code links for correct monorepo resolution.

cargo install cargo-doc-md --locked --version 0.1.4
References
  1. When configuring source code links in documentation generators for monorepos, the base URL must include the relative path to the package directory to ensure 'View source' links resolve correctly to the file locations.
  2. Prefer explicit project lists over automated discovery (e.g., via grep) in CI workflows if the repository structure contains duplicate project configurations or if manual oversight of the public API surface is necessary.
  3. For documentation synchronization workflows that force-push to the same branch, set cancel-in-progress: true to prevent race conditions from concurrent runs.


- name: Generate rustdoc markdown
# Run cargo-doc-md across the workspace. Output goes to
# target/doc-md/<crate_lib_name>/ (snake_case lib name, e.g.
# `resq_dsa`). Crates without a `lib.rs` (binary-only TUIs)
# produce no output here — the fallback step below handles
# those by rendering a README stub instead.
#
# `--no-deps` keeps the docs scoped to first-party crates;
# transitive dep docs are docs.rs's job.
run: |
cargo +nightly doc-md --workspace --no-deps || {
echo "::warning ::cargo-doc-md failed; relying on README stubs"
}
if [ -d target/doc-md ]; then
ls target/doc-md
fi

- name: Generate per-crate pages (rustdoc + README fallback)
# For each crate in `crates/*`:
# - If cargo-doc-md emitted markdown for it, copy that tree
# into OUTPUT_DIR/<crate>/ and inject a version banner.
# - Otherwise (binary-only crate, or rustdoc-failed crate),
# fall back to a README-stub: one Markdown file with
# name + version + description + docs.rs link + the
# crate's README content.
# Stubs land at OUTPUT_DIR/<crate>.md; rich crates at
# OUTPUT_DIR/<crate>/index.md plus per-module siblings. The
# splice handles both forms naturally.
run: |
mkdir -p "$OUTPUT_DIR"
rm -rf "${OUTPUT_DIR:?}"/*
Expand Down Expand Up @@ -158,6 +191,22 @@ jobs:
if not crates_dir.is_dir():
raise SystemExit("missing crates/ directory; not a Rust workspace?")

# cargo-doc-md uses snake_case lib names (e.g. `resq_dsa`)
# for output dirs. Cargo crate names are hyphenated
# (`resq-dsa`); convert with `-` → `_` to look them up.
rustdoc_md_dir = pathlib.Path("target") / "doc-md"

def banner_for(meta: dict) -> str:
docs_rs_url = f"https://docs.rs/{meta['name']}/{meta['version']}"
crates_io_url = f"https://crates.io/crates/{meta['name']}"
return (
f"> **Version:** `v{meta['version']}` · "
f"**License:** `{meta['license']}` · "
f"**Crate:** [crates.io]({crates_io_url}) · "
f"**API docs:** [docs.rs]({docs_rs_url})\n\n"
)

import shutil
rendered = 0
for crate_dir in sorted(crates_dir.iterdir()):
cargo_toml = crate_dir / "Cargo.toml"
Expand All @@ -167,6 +216,31 @@ jobs:
if not meta["name"]:
continue

snake_name = meta["name"].replace("-", "_")
rustdoc_src = rustdoc_md_dir / snake_name
if rustdoc_src.is_dir():
# Rich rustdoc output exists. Copy the whole tree
# into OUTPUT_DIR/<crate>/ and inject the version
# banner after the H1 of the landing index.md.
dest = out_root / meta["name"]
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(rustdoc_src, dest)
landing = dest / "index.md"
if landing.is_file():
text = landing.read_text(encoding="utf-8")
h1 = re.match(r"(# [^\n]+\n+)", text)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The use of re.match is fragile; re.search with re.MULTILINE is more robust for finding the H1 header. Furthermore, when processing Markdown/MDX files to escape curly braces for JSX compatibility, ensure the logic ignores content within inline code spans (backticks) to prevent breaking code snippets.

h1 = re.search(r"^#\s+[^\n]+\n+", text, re.M)
References
  1. When processing Markdown/MDX files to escape curly braces for JSX compatibility, ensure the logic ignores content within inline code spans (backticks) to prevent breaking code snippets.

banner = banner_for(meta)
if h1:
text = text[: h1.end()] + banner + text[h1.end():]
else:
text = banner + text
landing.write_text(text, encoding="utf-8")
rendered += 1
print(f" rustdoc: {meta['name']}")
continue

# Fallback: README stub at OUTPUT_DIR/<crate>.md.
docs_rs_url = f"https://docs.rs/{meta['name']}/{meta['version']}"
crates_io_url = f"https://crates.io/crates/{meta['name']}"

Expand Down Expand Up @@ -207,6 +281,7 @@ jobs:
dest = out_root / f"{meta['name']}.md"
dest.write_text(page, encoding="utf-8")
rendered += 1
print(f" stub: {meta['name']}")

print(f"rendered {rendered} crate pages")
if rendered == 0:
Expand Down Expand Up @@ -312,19 +387,33 @@ jobs:
)
print()
print(
"Each entry below lists a crate published from the workspace. "
"Click through for the README plus a link to the canonical "
"[docs.rs](https://docs.rs) API reference."
"Each entry below lists a crate from the workspace. "
"Library crates link to their full rendered API reference; "
"binary-only crates show their README and link to "
"[docs.rs](https://docs.rs)."
)
print()
print("## Crates")
print()
for md in sorted(out.glob("*.md")):
name = md.stem
text = md.read_text(encoding="utf-8")

def version_of(text: str) -> str:
m = re.search(r"\*\*Version:\*\*\s+`v([^`]+)`", text)
version = m.group(1) if m else "unknown"
print(f"- `{name}` — `v{version}`")
return m.group(1) if m else "unknown"

# Stub crates land as `<name>.md` at the top level.
# Rich crates land as `<name>/index.md` in their own dir.
# Collect both forms, dedupe by name, sort alphabetically.
entries: dict[str, str] = {}
for md in out.glob("*.md"):
entries[md.stem] = version_of(md.read_text(encoding="utf-8"))
for sub in out.iterdir():
if not sub.is_dir():
continue
landing = sub / "index.md"
if landing.is_file():
entries[sub.name] = version_of(landing.read_text(encoding="utf-8"))
for name in sorted(entries):
print(f"- `{name}` — `v{entries[name]}`")
PY

- name: Build pages index
Expand Down
Loading