-
Notifications
You must be signed in to change notification settings - Fork 0
761 lines (692 loc) · 31.5 KB
/
Copy pathapi-docs.yml
File metadata and controls
761 lines (692 loc) · 31.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
name: API docs (Rust)
# Auto-generates per-crate package pages from this Cargo workspace
# and opens a PR against resq-software/docs. Each crate gets a small
# stub page (metadata + README + link to docs.rs) rather than a full
# rustdoc dump, because the Rust ecosystem treats docs.rs as the
# canonical home for API reference and duplicating it would split
# source-of-truth.
on:
workflow_dispatch:
inputs:
ref:
description: Tag or branch to document
required: false
type: string
push:
tags:
- 'v*'
- '*-v*'
permissions:
contents: read
concurrency:
# Tag pushes can trigger workflow_dispatch in flight; cancel in
# progress so the docs PR always reflects the most recent ref.
group: api-docs-${{ github.ref }}
cancel-in-progress: true
jobs:
generate:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
OUTPUT_DIR: generated-docs
DOCS_TARGET: sdks/rust/api
steps:
- name: Resolve ref metadata
# Single source of truth for the ref this run documents.
# workflow_dispatch can pass an alternate ref via inputs.ref;
# fall back to github.ref_name (already stripped of refs/...).
#
# The ref is routed through env: instead of being inlined via
# ${{ }}. Inlining at template-expansion time would interpolate
# the raw string into the shell literal, so a tag name with a
# single quote (Git allows it) could break out of the quoted
# context. Env indirection keeps user-controlled data on the
# variable side of the shell parser, where it cannot escape.
env:
REF_RAW: ${{ inputs.ref || github.ref_name }}
run: |
raw="$REF_RAW"
raw="${raw#refs/tags/}"
raw="${raw#refs/heads/}"
slug="${raw//\//-}"
slug="${slug//\@/-}"
{
echo "DOCS_REF_NAME=$raw"
echo "DOCS_REF_SLUG=$slug"
} >> "$GITHUB_ENV"
- name: Checkout source repo
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref || github.ref }}
persist-credentials: false
- 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.
#
# The version is pinned because this binary runs with the
# repo's checkout in scope and the DOCS_REPO_PR_TOKEN secret in
# env. `--locked` alone only locks transitive resolution; it
# does NOT pin the cargo-doc-md package itself. Bumping
# requires reviewing the upstream release diff.
run: |
rustup toolchain install nightly --profile minimal --no-self-update
cargo install --locked --version 0.11.0 cargo-doc-md
- 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:?}"/*
python3 - <<'PY'
import os
import pathlib
import re
out_root = pathlib.Path(os.environ["OUTPUT_DIR"])
out_root.mkdir(parents=True, exist_ok=True)
# Workspace fallbacks for fields a crate may inherit via
# `<field>.workspace = true`. Section-scoped extraction (only
# read keys inside [workspace.package]) avoids matching
# `version` lines inside other tables further down the file.
def section(text: str, header: str) -> str:
# Match from the [section] header to the next [...]
# header (or end of file). Anchored to start-of-line so
# `[dependencies.foo]` doesn't match `[foo]`.
m = re.search(
rf"^\[{re.escape(header)}\]\s*\n(.*?)(?=^\[|\Z)",
text,
re.S | re.M,
)
return m.group(1) if m else ""
def field_in(section_text: str, name: str) -> str:
m = re.search(
rf'^\s*{re.escape(name)}\s*=\s*"([^"]+)"',
section_text,
re.M,
)
return m.group(1) if m else ""
ref_name = os.environ.get("DOCS_REF_NAME", "main")
repo = os.environ.get("GITHUB_REPOSITORY", "")
# Markdown link rewriter: turn relative paths into absolute
# GitHub blob URLs anchored at the crate's source path so
# `../../LICENSE` (etc.) keeps working when the README is
# embedded into the docs site. Skips links that are
# already absolute (http(s)://, mailto:), in-page anchors
# (#...), or images (data:).
link_re = re.compile(r"(\!?)\[([^\]]*)\]\(([^)]+)\)")
def rewrite_relative_links(body: str, crate_dir: pathlib.Path) -> str:
# crate_dir is e.g. `crates/resq-cli`; build the GitHub
# blob prefix once per crate.
base_url = f"https://github.com/{repo}/blob/{ref_name}"
def repl(m: re.Match) -> str:
bang, label, target = m.group(1), m.group(2), m.group(3).strip()
# Drop optional title: `(path "title")` -> path
url_part = target.split(None, 1)[0]
if re.match(r"^(https?:|mailto:|#|data:)", url_part):
return m.group(0)
# Resolve target against the crate dir, then make
# it relative to the repo root for the URL path.
try:
resolved = (crate_dir / url_part).resolve().relative_to(
pathlib.Path.cwd().resolve()
)
except (ValueError, OSError):
return m.group(0)
new_url = f"{base_url}/{resolved.as_posix()}"
return f"{bang}[{label}]({new_url})"
return link_re.sub(repl, body)
ws_text = pathlib.Path("Cargo.toml").read_text(encoding="utf-8")
ws_pkg = section(ws_text, "workspace.package")
ws_version = field_in(ws_pkg, "version") or "unknown"
ws_license = field_in(ws_pkg, "license") or "Apache-2.0"
def parse_crate_meta(cargo_toml: pathlib.Path) -> dict:
text = cargo_toml.read_text(encoding="utf-8")
pkg = section(text, "package")
lib = section(text, "lib")
pkg_name = field_in(pkg, "name")
# Cargo's `[lib].name` overrides the default snake_case
# of the package name (e.g. resq-bin's lib is named
# `bin_explorer`). Use the explicit lib name when set;
# otherwise convert the package name `-` → `_` per
# Cargo's default.
lib_name = field_in(lib, "name") or pkg_name.replace("-", "_")
return {
"name": pkg_name,
"lib_name": lib_name,
"version": field_in(pkg, "version") or ws_version,
"description": field_in(pkg, "description"),
# Cargo allows `license.workspace = true` for
# inheritance — when the crate doesn't pin a string
# license, fall through to the workspace value.
"license": field_in(pkg, "license") or ws_license,
}
crates_dir = pathlib.Path("crates")
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"
# Smart sidebar-label formatting (mirrors the splice
# step's helper) so leaf stub pages also read with proper
# acronyms and brand casing.
ACRONYMS = {
"ai", "cli", "dsa", "tui", "api", "sdk", "ui", "mcp",
"ipfs", "grpc", "http", "url", "json", "yaml", "xml",
"sql", "css", "html", "io",
}
BRAND = {"resq": "ResQ"}
def label_for(name: str) -> str:
if "." in name:
return name
out = []
for part in name.split("-"):
lower = part.lower()
if lower in BRAND:
out.append(BRAND[lower])
elif lower in ACRONYMS:
out.append(part.upper())
else:
out.append(part.capitalize())
return " ".join(out)
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"
if not cargo_toml.is_file():
continue
meta = parse_crate_meta(cargo_toml)
if not meta["name"]:
continue
rustdoc_src = rustdoc_md_dir / meta["lib_name"]
if rustdoc_src.is_dir():
# Rich rustdoc output exists. Copy the whole tree
# into OUTPUT_DIR/<crate>/, then REPLACE the
# auto-generated index.md with a hybrid landing:
#
# 1. version banner
# 2. cleaned README.md from the crate source
# (mermaid diagrams, badges, install
# instructions, etc. — the human-readable
# intro that cargo-doc-md doesn't produce)
# 3. ## Modules section linking to each
# cargo-doc-md per-module page
#
# The original cargo-doc-md index.md content (just
# the lib.rs //! doc + a flat module list) is
# dropped because the README provides the same
# information more richly, and the per-module pages
# keep the full API surface intact.
dest = out_root / meta["name"]
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(rustdoc_src, dest)
module_files = sorted(
p for p in dest.iterdir()
if p.is_file()
and p.suffix == ".md"
and p.stem not in ("index", meta["lib_name"])
)
module_lines = [
f"- [`{p.stem}`](./{p.stem})"
for p in module_files
]
readme = crate_dir / "README.md"
body_parts = [f"# {meta['name']}", "", banner_for(meta).rstrip(), ""]
if readme.is_file():
body = readme.read_text(encoding="utf-8")
body = re.sub(r"^\s*<!--.*?-->\s*", "", body, count=1, flags=re.S)
body = re.sub(r"^#\s+[^\n]+\n+", "", body, count=1)
body = rewrite_relative_links(body, crate_dir)
body_parts.extend([body.rstrip(), ""])
if module_lines:
body_parts.extend([
"## Modules",
"",
"Click through to each module for the full "
"rustdoc-rendered API surface (types, traits, "
"functions, methods).",
"",
*module_lines,
"",
])
(dest / "index.md").write_text(
"\n".join(body_parts) + "\n", 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']}"
# Frontmatter sidebarTitle gives the leaf a clean
# display label like "ResQ Clean" / "ResQ Deploy" so
# binary-only stubs match the style of library
# crates' rustdoc groups in the sidebar.
parts = [
"---",
f"sidebarTitle: '{label_for(meta['name'])}'",
"---",
"",
f"# {meta['name']}",
"",
f"> **Version:** `v{meta['version']}` · "
f"**License:** `{meta['license']}` · "
f"**Crate:** [crates.io]({crates_io_url}) · "
f"**API docs:** [docs.rs]({docs_rs_url})",
"",
]
if meta["description"]:
parts.extend([meta["description"], ""])
# Embed the crate's README.md if present. Strip the
# leading HTML comment (Apache-2.0 license header) and
# the first H1 because the page already has both above.
# Relative links (`../LICENSE`, `./design.md`) won't
# resolve in the docs site, so rewrite them to absolute
# GitHub URLs anchored at the crate's source path.
readme = crate_dir / "README.md"
if readme.is_file():
body = readme.read_text(encoding="utf-8")
body = re.sub(r"^\s*<!--.*?-->\s*", "", body, count=1, flags=re.S)
body = re.sub(r"^#\s+[^\n]+\n+", "", body, count=1)
body = rewrite_relative_links(body, crate_dir)
parts.extend(["## Overview", "", body.rstrip(), ""])
else:
parts.extend([
"_No README.md in source repo._",
"",
f"See [docs.rs]({docs_rs_url}) for full API reference.",
"",
])
page = "\n".join(parts) + "\n"
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:
raise SystemExit("no crates found; aborting empty doc PR")
PY
- name: Prefix bare relative .md links with ./
# cargo-doc-md emits cross-references as bare relative paths
# (`config.md`, `commands/audit.md`). Mintlify only resolves
# explicitly-relative paths (`./`, `../`) and absolute paths
# (`/...`) — bare paths are flagged as broken.
#
# Same logic as the TypeScript template's prefix step: skip
# targets that are already qualified, prefix everything else
# with `./`.
working-directory: ${{ env.OUTPUT_DIR }}
run: |
python3 - <<'PY'
import pathlib
import re
link_re = re.compile(r'(?<!\!)\[([^\]]*)\]\(([^)]+)\)')
def is_qualified(target: str) -> bool:
t = target.strip().split(None, 1)[0]
if t.startswith(("./", "../", "/", "#")):
return True
if ":" in t.split("/", 1)[0]:
return True
return False
for f in pathlib.Path(".").rglob("*.md"):
text = f.read_text(encoding="utf-8")
def fix(m: re.Match) -> str:
label, target = m.group(1), m.group(2)
if is_qualified(target):
return m.group(0)
if not target.endswith(".md") and ".md#" not in target:
return m.group(0)
return f"[{label}](./{target})"
new = link_re.sub(fix, text)
if new != text:
f.write_text(new, encoding="utf-8")
PY
- name: Strip .md extension from internal link targets
# Mintlify routes URLs without the `.md` extension. A
# markdown link like `[label](./foo.md)` is treated as a
# literal path that 404s in the rendered site, even when
# the target file exists. Drop `.md` (and collapse
# `/index.md` to its parent dir form) so links resolve
# natively. cargo-doc-md generally uses in-page anchors,
# not file links, but README content embedded into landing
# pages routinely carries `.md` suffixes.
working-directory: ${{ env.OUTPUT_DIR }}
run: |
python3 - <<'PY'
import pathlib
import re
link_re = re.compile(r'(?<!\!)\[([^\]]*)\]\(([^)]+)\)')
def strip_md(target: str) -> str:
first_seg = target.split("/", 1)[0]
if ":" in first_seg:
return target
path, _, frag = target.partition("#")
frag = ("#" + frag) if frag else ""
if path.endswith("/index.md"):
path = path[: -len("/index.md")] or "."
elif path.endswith(".md"):
path = path[:-3]
return path + frag
for f in pathlib.Path(".").rglob("*.md"):
text = f.read_text(encoding="utf-8")
def fix(m: re.Match) -> str:
return f"[{m.group(1)}]({strip_md(m.group(2))})"
new = link_re.sub(fix, text)
if new != text:
f.write_text(new, encoding="utf-8")
PY
- name: Escape MDX-special characters outside code regions
# Mintlify parses .md as MDX. Literal `{ ... }` in prose is
# interpreted as a JSX expression; literal `<X>` (e.g.
# `Result<T>`, `Option<u8>`) is parsed as a JSX component
# reference, which fails when the "component" isn't defined.
# Backtick-span detection (same walker as the Python template
# — see api-docs.python.yml) keeps code spans verbatim.
#
# Unlike the Python template, this one is more aggressive
# about `<`: it escapes `<` followed by anything except `!`
# (HTML comment) or `/` (closing tag). The Python template
# passes through `<X` where X is a letter because
# pydoc-markdown emits `<a id="...">` HTML anchors that need
# to render. We don't generate those here, and Rust prose is
# full of bare `Result<T>` references that would otherwise
# break the build.
working-directory: ${{ env.OUTPUT_DIR }}
run: |
find . -type f -name '*.md' -print0 | while IFS= read -r -d '' f; do
awk '
BEGIN { in_fence = 0 }
/^```/ { in_fence = !in_fence; print; next }
{
if (in_fence) { print; next }
s = $0
out = ""
n = length(s)
i = 1
while (i <= n) {
c = substr(s, i, 1)
if (c == "`") {
runlen = 0
while (i + runlen <= n && substr(s, i + runlen, 1) == "`") runlen++
j = i + runlen
close_at = 0
while (j <= n) {
if (substr(s, j, 1) == "`") {
k = 0
while (j + k <= n && substr(s, j + k, 1) == "`") k++
if (k == runlen) { close_at = j; break }
j += k
} else {
j++
}
}
if (close_at > 0) {
end = close_at + runlen - 1
out = out substr(s, i, end - i + 1)
i = end + 1
} else {
out = out substr(s, i, runlen)
i += runlen
}
} else if (c == "{") {
out = out "{"; i++
} else if (c == "}") {
out = out "}"; i++
} else if (c == "<") {
next_c = (i + 1 <= n) ? substr(s, i + 1, 1) : ""
# Pass through HTML comments (<!--) and closing
# tags (</foo>); escape everything else,
# including `<X>` template syntax.
if (next_c == "!" || next_c == "/") {
out = out c; i++
} else {
out = out "<"; i++
}
} else {
out = out c; i++
}
}
print out
}
' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
done
- name: Write top-level index
# Stub pages sort alphabetically; the README.mdx lists them
# all so users get a single overview of the crate workspace
# at the top of the Rust SDK section.
run: |
python3 - <<'PY' > "$OUTPUT_DIR/README.mdx"
import os
import pathlib
import re
ref_name = os.environ.get("DOCS_REF_NAME", "main")
repo = os.environ.get("GITHUB_REPOSITORY", "")
out = pathlib.Path(os.environ["OUTPUT_DIR"])
print("# ResQ Rust SDK")
print()
print(
f"Auto-generated reference for "
f"[`{repo}`](https://github.com/{repo}) "
f"at ref `{ref_name}`."
)
print()
print(
"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()
def version_of(text: str) -> str:
m = re.search(r"\*\*Version:\*\*\s+`v([^`]+)`", text)
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
# Flat JSON array of generated Markdown paths (without
# extension) so the docs repo can splice them into docs.json.
working-directory: ${{ env.OUTPUT_DIR }}
run: |
find . -name '*.md' -type f \
| sed 's|^\./||; s|\.md$||' \
| sort > _pages.txt
jq -R -s 'split("\n") | map(select(length > 0))' _pages.txt > _pages.json
rm _pages.txt
- name: Checkout docs repo
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: resq-software/docs
path: docs-checkout
token: ${{ secrets.DOCS_REPO_PR_TOKEN }}
persist-credentials: false
- name: Sync generated Markdown into docs checkout
run: |
target="docs-checkout/${DOCS_TARGET}"
mkdir -p "$target"
rm -rf "${target:?}"/*
cp -R "${OUTPUT_DIR}/." "$target/"
- name: Splice _pages.json into docs.json nav
# Mintlify only routes pages registered in docs.json. Build
# hierarchical groups so each library crate becomes its own
# collapsible sidebar entry (`resq-dsa` → bloom, count_min,
# graph, ...) instead of flat siblings under the language
# group. Single-file stub crates (binary-only TUIs) stay as
# leaf pages directly under Rust.
working-directory: docs-checkout
run: |
python3 - <<'PYINNER'
import json
import pathlib
PREFIX = "sdks/rust/api"
LANG_LABEL = "Rust"
docs_path = pathlib.Path("docs.json")
docs = json.loads(docs_path.read_text())
pages_path = pathlib.Path(PREFIX) / "_pages.json"
if not pages_path.exists():
raise SystemExit(f"missing {pages_path}")
raw = json.loads(pages_path.read_text())
tree: dict = {}
def insert_file(node, parts, full_id):
if len(parts) == 1:
node.setdefault("_files", []).append((parts[0], full_id))
return
head, *rest = parts
insert_file(
node.setdefault("_dirs", {}).setdefault(head, {}),
rest,
full_id,
)
def insert_landing(node, parts, full_id):
# Drill down to the dir node for `parts` and store the
# landing page id there. Stored on the dir itself
# rather than as a sibling _files leaf so the dir and
# its landing aren't both rendered in the parent group
# (the duplicate-entries bug fixed in #61).
cur = node
for part in parts:
cur = cur.setdefault("_dirs", {}).setdefault(part, {})
cur["_landing"] = full_id
for p in raw:
if p in ("README", "index"):
continue
if p.endswith("/index") or p.endswith("/README"):
bare = p.rsplit("/", 1)[0]
full_id = f"{PREFIX}/{bare}"
parts = bare.split("/")
insert_landing(tree, parts, full_id)
else:
full_id = f"{PREFIX}/{p}"
parts = p.split("/")
insert_file(tree, parts, full_id)
# Smart group-label formatting so the sidebar reads
# "ResQ DSA" / "ResQ CLI" instead of the raw "resq-dsa" /
# "resq-cli" dir names. Acronyms upper-cased; the brand
# "ResQ" preserved with its mixed case; everything else
# title-cased. Result matches the brand voice and is
# consistent across all language sub-groups.
ACRONYMS = {
"ai", "cli", "dsa", "tui", "api", "sdk", "ui", "mcp",
"ipfs", "grpc", "http", "url", "json", "yaml", "xml",
"sql", "css", "html", "io",
}
BRAND = {"resq": "ResQ"}
def label_for(name):
# Pass dotted names through (e.g. .NET-style
# `ResQ.Blockchain` already carry canonical casing).
if "." in name:
return name
out = []
for part in name.split("-"):
lower = part.lower()
if lower in BRAND:
out.append(BRAND[lower])
elif lower in ACRONYMS:
out.append(part.upper())
else:
out.append(part.capitalize())
return " ".join(out)
def to_mintlify(node, group_name):
pages = []
if "_landing" in node:
pages.append(node["_landing"])
for fname, full_id in sorted(node.get("_files", [])):
pages.append(full_id)
for dname, sub in sorted(node.get("_dirs", {}).items()):
pages.append(to_mintlify(sub, dname))
if group_name is None:
return pages
return {"group": label_for(group_name), "pages": pages}
new_pages = [f"{PREFIX}/README"] + to_mintlify(tree, None)
en = next(l for l in docs["navigation"]["languages"] if l["language"] == "en")
sdks_tab = next(t for t in en["tabs"] if t["tab"] == "SDKs")
gen_group = next(g for g in sdks_tab["groups"] if g["group"] == "Generated Package References")
for sub in gen_group["pages"]:
if isinstance(sub, dict) and sub.get("group") == LANG_LABEL:
sub["pages"] = new_pages
break
else:
gen_group["pages"].append({"group": LANG_LABEL, "pages": new_pages})
docs_path.write_text(json.dumps(docs, indent=2, ensure_ascii=False))
print(f"Updated {LANG_LABEL} sub-group with {len(raw)} pages")
PYINNER
- name: Open PR in docs repo
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
path: docs-checkout
token: ${{ secrets.DOCS_REPO_PR_TOKEN }}
author: 'resq-sw <engineer@resq.software>'
committer: 'resq-sw <engineer@resq.software>'
commit-message: |
docs(rust): sync API reference for ${{ env.DOCS_REF_NAME }}
title: 'docs(rust): API reference ${{ env.DOCS_REF_NAME }}'
body: |
Auto-generated by `${{ github.workflow }}` in
`${{ github.repository }}` for ref `${{ env.DOCS_REF_NAME }}`
(run: ${{ github.run_id }}).
Regenerated stub pages under `sdks/rust/api/`. Each page
embeds the crate's README and links to docs.rs for the
canonical API reference.
branch: auto/rust-api-${{ env.DOCS_REF_SLUG }}
base: main
delete-branch: true
add-paths: |
sdks/rust/api/**
docs.json
labels: |
automated
docs:api-ref
language:rust