Skip to content

Commit 66477d5

Browse files
fix(scripts): reconcile linked redirect stubs by removing stale files and harden config validation
1 parent 07607de commit 66477d5

1 file changed

Lines changed: 102 additions & 32 deletions

File tree

scripts/generate_linked_redirects.py

Lines changed: 102 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
#!/usr/bin/env python3
22
"""
33
Generate redirect stub pages for linked projects.
4-
Canonical pages are generated under projects/linked, with legacy redirects
5-
preserved under docs/projects/linked for backward compatibility.
4+
5+
Behavior:
6+
- Create or update canonical redirects under `projects/linked`.
7+
- Create or update legacy redirects under `docs/projects/linked`.
8+
- Remove stale `.md` files in those directories that are no longer configured.
69
"""
710

11+
from __future__ import annotations
12+
813
import json
914
from pathlib import Path
1015

@@ -13,42 +18,107 @@
1318
LEGACY_OUT_DIR = Path("docs/projects/linked")
1419

1520

16-
def main():
17-
with CONFIG.open("r", encoding="utf-8") as f:
18-
data = json.load(f)
21+
def yaml_string(value: str) -> str:
22+
"""Return a YAML-safe scalar using JSON quoting (valid YAML)."""
23+
return json.dumps(value, ensure_ascii=False)
24+
25+
26+
def render_front_matter(lines: list[str]) -> str:
27+
return "---\n" + "\n".join(lines) + "\n---\n"
28+
29+
30+
def write_if_changed(path: Path, content: str) -> bool:
31+
if path.exists():
32+
existing = path.read_text(encoding="utf-8")
33+
if existing == content:
34+
return False
35+
path.write_text(content, encoding="utf-8")
36+
return True
37+
38+
39+
def remove_stale_files(out_dir: Path, keep_filenames: set[str]) -> list[Path]:
40+
removed: list[Path] = []
41+
for path in out_dir.glob("*.md"):
42+
if path.name not in keep_filenames:
43+
path.unlink()
44+
removed.append(path)
45+
return removed
46+
47+
48+
def main() -> None:
49+
with CONFIG.open("r", encoding="utf-8") as handle:
50+
data = json.load(handle)
1951

2052
projects = data.get("linked_projects", [])
53+
2154
CANONICAL_OUT_DIR.mkdir(parents=True, exist_ok=True)
2255
LEGACY_OUT_DIR.mkdir(parents=True, exist_ok=True)
2356

24-
for i, proj in enumerate(projects, 1):
25-
name = proj["name"]
26-
title = proj.get("title", name)
27-
url = proj["url"]
28-
canonical_path = CANONICAL_OUT_DIR / f"{name}.md"
29-
legacy_path = LEGACY_OUT_DIR / f"{name}.md"
30-
31-
canonical_content = f"""---
32-
layout: redirect
33-
title: {title}
34-
parent: "Linked Projects 🔗"
35-
grand_parent: Projects
36-
nav_order: {i}
37-
redirect_to: {url}
38-
---
39-
"""
40-
legacy_content = f"""---
41-
layout: redirect
42-
title: {title}
43-
nav_exclude: true
44-
redirect_to: {url}
45-
---
46-
"""
57+
seen_names: set[str] = set()
58+
canonical_keep: set[str] = set()
59+
legacy_keep: set[str] = set()
60+
61+
for index, project in enumerate(projects, 1):
62+
if not isinstance(project, dict):
63+
raise TypeError(f"linked_projects[{index - 1}] must be an object")
64+
65+
raw_name = project.get("name")
66+
name = str(raw_name).strip() if raw_name is not None else ""
67+
if not name:
68+
raise ValueError("Each linked project must define a non-empty 'name'.")
69+
if Path(name).name != name or name in {".", ".."}:
70+
raise ValueError(
71+
f"Linked project '{name}' has an invalid name; use a simple file-safe slug."
72+
)
73+
if name in seen_names:
74+
raise ValueError(f"Duplicate linked project name: {name}")
75+
seen_names.add(name)
76+
77+
raw_title = project.get("title")
78+
title = str(raw_title).strip() if raw_title is not None else name
79+
if not title:
80+
title = name
81+
82+
raw_url = project.get("url")
83+
url = str(raw_url).strip() if raw_url is not None else ""
84+
if not url:
85+
raise ValueError(f"Linked project '{name}' is missing required 'url'.")
86+
87+
filename = f"{name}.md"
88+
canonical_keep.add(filename)
89+
legacy_keep.add(filename)
90+
91+
canonical_path = CANONICAL_OUT_DIR / filename
92+
legacy_path = LEGACY_OUT_DIR / filename
93+
94+
canonical_content = render_front_matter(
95+
[
96+
"layout: redirect",
97+
f"title: {yaml_string(title)}",
98+
f'parent: {yaml_string("Linked Projects 🔗")}',
99+
"grand_parent: Projects",
100+
f"nav_order: {index}",
101+
f"redirect_to: {yaml_string(url)}",
102+
]
103+
)
104+
legacy_content = render_front_matter(
105+
[
106+
"layout: redirect",
107+
f"title: {yaml_string(title)}",
108+
"nav_exclude: true",
109+
f"redirect_to: {yaml_string(url)}",
110+
]
111+
)
112+
113+
if write_if_changed(canonical_path, canonical_content):
114+
print(f"Generated redirect: {canonical_path}")
115+
if write_if_changed(legacy_path, legacy_content):
116+
print(f"Generated legacy redirect: {legacy_path}")
47117

48-
canonical_path.write_text(canonical_content.strip() + "\n", encoding="utf-8")
49-
legacy_path.write_text(legacy_content.strip() + "\n", encoding="utf-8")
50-
print(f"Generated redirect: {canonical_path}")
51-
print(f"Generated legacy redirect: {legacy_path}")
118+
for removed_path in remove_stale_files(CANONICAL_OUT_DIR, canonical_keep):
119+
print(f"Removed stale redirect: {removed_path}")
120+
for removed_path in remove_stale_files(LEGACY_OUT_DIR, legacy_keep):
121+
print(f"Removed stale legacy redirect: {removed_path}")
52122

53123

54124
if __name__ == "__main__":

0 commit comments

Comments
 (0)