Skip to content

Commit 5430dc9

Browse files
committed
docs: publish API reference and synchronize wiki
1 parent 4dec576 commit 5430dc9

14 files changed

Lines changed: 747 additions & 15 deletions
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
#!/usr/bin/env python3
2+
"""Rewrite copied docs/wiki page links for GitHub wiki publishing."""
3+
4+
import argparse
5+
import posixpath
6+
import sys
7+
from pathlib import Path
8+
from urllib.parse import urlparse
9+
10+
11+
def rewrite_wiki_links(wiki_dir, repository, sync_sha, source_dir="docs/wiki", repo_root="."):
12+
wiki_dir = Path(wiki_dir)
13+
source_dir = normalize_posix_path(source_dir)
14+
15+
if not wiki_dir.is_dir():
16+
raise NotADirectoryError(wiki_dir)
17+
18+
page_routes = get_page_routes(wiki_dir)
19+
for markdown_file in sorted(wiki_dir.rglob("*.md")):
20+
relative_file = markdown_file.relative_to(wiki_dir).as_posix()
21+
original = markdown_file.read_text(encoding="utf-8")
22+
rewritten = rewrite_markdown(
23+
original,
24+
relative_file,
25+
page_routes,
26+
source_dir,
27+
)
28+
29+
if rewritten != original:
30+
markdown_file.write_text(rewritten, encoding="utf-8")
31+
32+
33+
def get_page_routes(wiki_dir):
34+
routes = {}
35+
for markdown_file in wiki_dir.rglob("*.md"):
36+
relative_path = markdown_file.relative_to(wiki_dir).as_posix()
37+
route = relative_path[:-3]
38+
routes[relative_path] = route
39+
40+
return routes
41+
42+
43+
def rewrite_markdown(content, relative_file, page_routes, source_dir):
44+
lines = content.splitlines(keepends=True)
45+
rewritten = []
46+
in_fence = False
47+
fence_marker = None
48+
49+
for line_number, line in enumerate(lines, start=1):
50+
fence = get_fence_marker(line)
51+
if fence is not None:
52+
if in_fence and fence.startswith(fence_marker):
53+
in_fence = False
54+
fence_marker = None
55+
elif not in_fence:
56+
in_fence = True
57+
fence_marker = fence
58+
59+
rewritten.append(line)
60+
continue
61+
62+
if in_fence:
63+
rewritten.append(line)
64+
continue
65+
66+
rewritten.append(
67+
rewrite_line(
68+
line,
69+
relative_file,
70+
page_routes,
71+
source_dir,
72+
line_number,
73+
)
74+
)
75+
76+
return "".join(rewritten)
77+
78+
79+
def get_fence_marker(line):
80+
stripped = line.lstrip()
81+
if stripped.startswith("```"):
82+
return "```"
83+
84+
if stripped.startswith("~~~"):
85+
return "~~~"
86+
87+
return None
88+
89+
90+
def rewrite_line(line, relative_file, page_routes, source_dir, line_number):
91+
result = []
92+
index = 0
93+
in_code = False
94+
95+
while index < len(line):
96+
char = line[index]
97+
98+
if char == "`":
99+
tick_count = count_repeated(line, index, "`")
100+
result.append(line[index:index + tick_count])
101+
index += tick_count
102+
in_code = not in_code
103+
continue
104+
105+
if in_code or char != "[" or is_image_link(line, index):
106+
result.append(char)
107+
index += 1
108+
continue
109+
110+
parsed = try_parse_link(line, index)
111+
if parsed is None:
112+
result.append(char)
113+
index += 1
114+
continue
115+
116+
text, target, end_index = parsed
117+
rewritten_target = rewrite_target(
118+
target,
119+
relative_file,
120+
page_routes,
121+
source_dir,
122+
line_number,
123+
)
124+
result.append("[")
125+
result.append(text)
126+
result.append("](")
127+
result.append(rewritten_target)
128+
result.append(")")
129+
index = end_index
130+
131+
return "".join(result)
132+
133+
134+
def count_repeated(text, start, char):
135+
index = start
136+
while index < len(text) and text[index] == char:
137+
index += 1
138+
139+
return index - start
140+
141+
142+
def is_image_link(line, index):
143+
return index > 0 and line[index - 1] == "!"
144+
145+
146+
def try_parse_link(line, start):
147+
close_bracket = line.find("]", start + 1)
148+
if close_bracket < 0 or close_bracket + 1 >= len(line) or line[close_bracket + 1] != "(":
149+
return None
150+
151+
close_paren = line.find(")", close_bracket + 2)
152+
if close_paren < 0:
153+
return None
154+
155+
text = line[start + 1:close_bracket]
156+
target = line[close_bracket + 2:close_paren]
157+
return text, target, close_paren + 1
158+
159+
160+
def rewrite_target(target, relative_file, page_routes, source_dir, line_number):
161+
if should_ignore_target(target):
162+
return target
163+
164+
target_path, fragment = split_fragment(target)
165+
if not target_path or not target_path.endswith(".md"):
166+
return target
167+
168+
current_source_dir = posixpath.dirname(posixpath.join(source_dir, relative_file))
169+
resolved_repo_path = normalize_posix_path(posixpath.join(current_source_dir, target_path))
170+
171+
if not is_within_or_equal(resolved_repo_path, source_dir):
172+
return target
173+
174+
wiki_relative_path = posixpath.relpath(resolved_repo_path, source_dir)
175+
if wiki_relative_path in page_routes:
176+
return page_routes[wiki_relative_path] + fragment
177+
178+
raise FileNotFoundError(
179+
f"{relative_file}:{line_number}: wiki page link target does not exist: {target}"
180+
)
181+
182+
183+
def should_ignore_target(target):
184+
if not target or target.startswith("#"):
185+
return True
186+
187+
if any(character.isspace() for character in target):
188+
return True
189+
190+
parsed = urlparse(target)
191+
return bool(parsed.scheme or parsed.netloc)
192+
193+
194+
def split_fragment(target):
195+
if "#" not in target:
196+
return target, ""
197+
198+
path, fragment = target.split("#", 1)
199+
return path, "#" + fragment
200+
201+
202+
def normalize_posix_path(path):
203+
normalized = posixpath.normpath(str(path).replace("\\", "/"))
204+
if normalized == ".":
205+
return ""
206+
207+
return normalized.lstrip("/")
208+
209+
210+
def is_within_or_equal(path, parent):
211+
return path == parent or path.startswith(parent.rstrip("/") + "/")
212+
213+
214+
def parse_args(argv):
215+
parser = argparse.ArgumentParser(
216+
description="Rewrite copied docs/wiki Markdown links for GitHub wiki publishing."
217+
)
218+
parser.add_argument("wiki_dir", help="Directory containing the copied wiki Markdown files.")
219+
parser.add_argument("repository", help="Accepted for sync workflow compatibility; not used.")
220+
parser.add_argument("sync_sha", help="Accepted for sync workflow compatibility; not used.")
221+
parser.add_argument(
222+
"--source-dir",
223+
default="docs/wiki",
224+
help="Source directory for wiki files in the main repository.",
225+
)
226+
parser.add_argument(
227+
"--repo-root",
228+
default=".",
229+
help="Accepted for sync workflow compatibility; not used.",
230+
)
231+
return parser.parse_args(argv)
232+
233+
234+
def main(argv=None):
235+
args = parse_args(sys.argv[1:] if argv is None else argv)
236+
rewrite_wiki_links(
237+
wiki_dir=args.wiki_dir,
238+
repository=args.repository,
239+
sync_sha=args.sync_sha,
240+
source_dir=args.source_dir,
241+
repo_root=args.repo_root,
242+
)
243+
return 0
244+
245+
246+
if __name__ == "__main__":
247+
raise SystemExit(main())
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import importlib.util
2+
import pathlib
3+
import tempfile
4+
import unittest
5+
6+
7+
SCRIPT_PATH = pathlib.Path(__file__).with_name("rewrite-wiki-links-for-github-wiki.py")
8+
9+
10+
def load_rewriter():
11+
spec = importlib.util.spec_from_file_location("rewrite_wiki_links", SCRIPT_PATH)
12+
module = importlib.util.module_from_spec(spec)
13+
spec.loader.exec_module(module)
14+
return module
15+
16+
17+
class RewriteWikiLinksForGithubWikiTests(unittest.TestCase):
18+
def test_rewrites_fixedmathsharp_wiki_page_links_without_md_extension(self):
19+
rewriter = load_rewriter()
20+
21+
with tempfile.TemporaryDirectory() as temp_dir:
22+
root = pathlib.Path(temp_dir)
23+
wiki_dir = root / "wiki"
24+
repo_root = root / "repo"
25+
wiki_dir.mkdir()
26+
(repo_root / "docs" / "wiki").mkdir(parents=True)
27+
28+
(wiki_dir / "Home.md").write_text("# Home\n", encoding="utf-8")
29+
(wiki_dir / "Overview.md").write_text(
30+
"\n".join(
31+
[
32+
"[Home](Home.md)",
33+
"[Fixed64 Representation](fixed64-representation.md#raw-layout)",
34+
]
35+
),
36+
encoding="utf-8",
37+
)
38+
(wiki_dir / "fixed64-representation.md").write_text(
39+
"# Fixed64 Representation\n",
40+
encoding="utf-8",
41+
)
42+
43+
rewriter.rewrite_wiki_links(
44+
wiki_dir=wiki_dir,
45+
repository="mrdav30/FixedMathSharp",
46+
sync_sha="abc123",
47+
source_dir="docs/wiki",
48+
repo_root=repo_root,
49+
)
50+
51+
content = (wiki_dir / "Overview.md").read_text(encoding="utf-8")
52+
53+
self.assertIn("[Home](Home)", content)
54+
self.assertIn(
55+
"[Fixed64 Representation](fixed64-representation#raw-layout)",
56+
content,
57+
)
58+
59+
def test_leaves_markdown_links_outside_wiki_source_unchanged(self):
60+
rewriter = load_rewriter()
61+
62+
with tempfile.TemporaryDirectory() as temp_dir:
63+
root = pathlib.Path(temp_dir)
64+
wiki_dir = root / "wiki"
65+
repo_root = root / "repo"
66+
wiki_dir.mkdir()
67+
(repo_root / "docs" / "wiki").mkdir(parents=True)
68+
(wiki_dir / "Overview.md").write_text(
69+
"[Readme](../../README.md)",
70+
encoding="utf-8",
71+
)
72+
73+
rewriter.rewrite_wiki_links(
74+
wiki_dir=wiki_dir,
75+
repository="mrdav30/FixedMathSharp",
76+
sync_sha="abc123",
77+
source_dir="docs/wiki",
78+
repo_root=repo_root,
79+
)
80+
81+
content = (wiki_dir / "Overview.md").read_text(encoding="utf-8")
82+
83+
self.assertIn("[Readme](../../README.md)", content)
84+
85+
def test_ignores_external_images_code_and_non_markdown_local_links(self):
86+
rewriter = load_rewriter()
87+
88+
with tempfile.TemporaryDirectory() as temp_dir:
89+
root = pathlib.Path(temp_dir)
90+
wiki_dir = root / "wiki"
91+
repo_root = root / "repo"
92+
wiki_dir.mkdir()
93+
(repo_root / "docs" / "wiki").mkdir(parents=True)
94+
95+
(wiki_dir / "Overview.md").write_text(
96+
"\n".join(
97+
[
98+
"[External](https://example.com/Overview.md)",
99+
"![Image](Overview.md)",
100+
"`[Code](Overview.md)`",
101+
"```",
102+
"[Fence](Overview.md)",
103+
"```",
104+
"[Asset](notes.txt)",
105+
]
106+
),
107+
encoding="utf-8",
108+
)
109+
110+
rewriter.rewrite_wiki_links(
111+
wiki_dir=wiki_dir,
112+
repository="mrdav30/FixedMathSharp",
113+
sync_sha="abc123",
114+
source_dir="docs/wiki",
115+
repo_root=repo_root,
116+
)
117+
118+
content = (wiki_dir / "Overview.md").read_text(encoding="utf-8")
119+
120+
self.assertIn("[External](https://example.com/Overview.md)", content)
121+
self.assertIn("![Image](Overview.md)", content)
122+
self.assertIn("`[Code](Overview.md)`", content)
123+
self.assertIn("[Fence](Overview.md)", content)
124+
self.assertIn("[Asset](notes.txt)", content)
125+
126+
def test_fails_when_wiki_page_link_target_does_not_exist(self):
127+
rewriter = load_rewriter()
128+
129+
with tempfile.TemporaryDirectory() as temp_dir:
130+
root = pathlib.Path(temp_dir)
131+
wiki_dir = root / "wiki"
132+
repo_root = root / "repo"
133+
wiki_dir.mkdir()
134+
(repo_root / "docs" / "wiki").mkdir(parents=True)
135+
(wiki_dir / "Overview.md").write_text(
136+
"[Missing](Missing.md)",
137+
encoding="utf-8",
138+
)
139+
140+
with self.assertRaises(FileNotFoundError):
141+
rewriter.rewrite_wiki_links(
142+
wiki_dir=wiki_dir,
143+
repository="mrdav30/FixedMathSharp",
144+
sync_sha="abc123",
145+
source_dir="docs/wiki",
146+
repo_root=repo_root,
147+
)
148+
149+
150+
if __name__ == "__main__":
151+
unittest.main()

0 commit comments

Comments
 (0)