11#!/usr/bin/env python3
2+ """
3+ README Table of Contents Generator
4+
5+ This script automatically generates and updates the Table of Contents (TOC)
6+ in the README.md file based on project directories in the repository.
7+ """
28import os
9+ import sys
310import math
411import re
512import urllib .parse
13+ from pathlib import Path
614from itertools import zip_longest
715
816# Configuration
917README = "README.md"
10- EXCLUDE = {".git" , ".github" , "scripts" , "__pycache__" }
18+ EXCLUDE = {".git" , ".github" , "scripts" , "__pycache__" , "wiki" }
1119# Override mapping: directory name -> desired header display
1220OVERRIDES = {
1321 "Dump_c++" : "Dump C++" ,
1422 # can add any other overrides
1523}
1624
17- # Derive a human-friendly display name for TOC entries
25+
1826def display_name (name ):
27+ """Derive a human-friendly display name for TOC entries."""
1928 if name in OVERRIDES :
2029 return OVERRIDES [name ]
2130 # Insert spaces before camelCase boundaries, then replace underscores
2231 s = re .sub (r'(?<=[a-z0-9])(?=[A-Z])' , ' ' , name )
2332 s = s .replace ('_' , ' ' )
2433 return s .strip ()
2534
26- # Create a GitHub-style slug for IDs
35+
2736def slugify (name ):
37+ """Create a GitHub-style slug for anchor IDs."""
2838 base = OVERRIDES .get (name , name )
2939 # Split camelCase and underscores
3040 s = re .sub (r'(?<=[a-z0-9])(?=[A-Z])' , ' ' , base )
@@ -37,84 +47,137 @@ def slugify(name):
3747 # Collapse spaces into hyphens
3848 return re .sub (r"\s+" , "-" , s .strip ())
3949
40- # Convert slug back to a Title Case display without hyphens
50+
4151def header_display_from_slug (slug ):
52+ """Convert slug back to a Title Case display without hyphens."""
4253 disp = slug .replace ('-' , ' ' )
4354 return disp .title ()
4455
45- # Compute column header ranges
56+
4657def header_range (col ):
58+ """Compute column header ranges (e.g., 'A-M')."""
4759 if not col :
4860 return ""
4961 first = slugify (col [0 ])[0 ].upper ()
5062 last = slugify (col [- 1 ])[0 ].upper ()
5163 return f"{ first } –{ last } "
5264
53- # Collect project directories
54- projects = sorted (
55- [d for d in os .listdir ('.' )
56- if os .path .isdir (d )
57- and d not in EXCLUDE
58- and not d .startswith ('.' )],
59- key = str .lower
60- )
61-
62- # Split into 3 roughly even columns
63- cols = 3
64- chunk = math .ceil (len (projects ) / cols )
65- columns = [projects [i * chunk :(i + 1 )* chunk ] for i in range (cols )]
66-
67- # Build HTML table
68- lines = [
69- '<table style="width:100%; border-collapse: collapse; border:1px solid #ddd;">' ,
70- ' <thead>' ,
71- ' <tr>'
72- ]
73- for col in columns :
74- lines .append (
75- f' <th style="padding:4px 8px; text-align:center; font-weight:bold; border:1px solid #ddd;">{ header_range (col )} </th>'
65+
66+ def collect_projects (base_path ):
67+ """Collect and return sorted list of project directories."""
68+ base = Path (base_path )
69+ if not base .is_dir ():
70+ raise ValueError (f"Base path '{ base_path } ' is not a valid directory" )
71+
72+ projects = sorted (
73+ [d .name for d in base .iterdir ()
74+ if d .is_dir ()
75+ and d .name not in EXCLUDE
76+ and not d .name .startswith ('.' )],
77+ key = str .lower
7678 )
77- lines += [' </tr>' , ' </thead>' , ' <tbody>' ]
78-
79- for row in zip_longest (* columns , fillvalue = "" ):
80- lines .append (' <tr>' )
81- for cell in row :
82- if cell :
83- slug = slugify (cell )
84- disp = display_name (cell )
85- lines .append (
86- f' <td style="padding:4px 8px; text-align:center; border:1px solid #ddd;"><a href="#' + slug + f'">{ disp } </a></td>'
87- )
79+ return projects
80+
81+
82+ def build_toc_table (projects ):
83+ """Build HTML table for the Table of Contents."""
84+ # Split into 3 roughly even columns
85+ cols = 3
86+ chunk = math .ceil (len (projects ) / cols ) if projects else 1
87+ columns = [projects [i * chunk :(i + 1 )* chunk ] for i in range (cols )]
88+
89+ # Build HTML table
90+ lines = [
91+ '<table style="width:100%; border-collapse: collapse; border:1px solid #ddd;">' ,
92+ ' <thead>' ,
93+ ' <tr>'
94+ ]
95+ for col in columns :
96+ lines .append (
97+ f' <th style="padding:4px 8px; text-align:center; font-weight:bold; border:1px solid #ddd;">{ header_range (col )} </th>'
98+ )
99+ lines += [' </tr>' , ' </thead>' , ' <tbody>' ]
100+
101+ for row in zip_longest (* columns , fillvalue = "" ):
102+ lines .append (' <tr>' )
103+ for cell in row :
104+ if cell :
105+ slug = slugify (cell )
106+ disp = display_name (cell )
107+ lines .append (
108+ f' <td style="padding:4px 8px; text-align:center; border:1px solid #ddd;"><a href="#' + slug + f'">{ disp } </a></td>'
109+ )
110+ else :
111+ lines .append (
112+ ' <td style="padding:4px 8px; text-align:center; border:1px solid #ddd;"></td>'
113+ )
114+ lines .append (' </tr>' )
115+ lines += [' </tbody>' , '</table>' ]
116+ return "\n " .join (lines )
117+
118+
119+ def normalize_headers (content ):
120+ """Normalize all headers to slug-based Title Case (no underscores or hyphens)."""
121+ pattern = re .compile (r'^(### \[)([^\]]+)(\]\(\./([^\)]+)\))' , re .MULTILINE )
122+
123+ def repl (match ):
124+ raw = match .group (4 )
125+ # Decode URL-encoded directory name
126+ decoded = urllib .parse .unquote (raw )
127+ # Generate slug and display
128+ slug = slugify (decoded )
129+ disp = header_display_from_slug (slug )
130+ # Keep original raw in link
131+ return f"### [{ disp } ](./{ raw } )"
132+
133+ return pattern .sub (repl , content )
134+
135+
136+ def update_toc_in_content (content , new_toc ):
137+ """Replace TOC region in content with new TOC."""
138+ toc_re = re .compile (r"(<!-- TOC START -->)(.*?)(<!-- TOC END -->)" , re .S )
139+ return toc_re .sub (rf"\1\n{ new_toc } \n\3" , content )
140+
141+
142+ def main ():
143+ """Main entry point for the TOC generator."""
144+ readme_path = Path (README )
145+
146+ # Verify README exists
147+ if not readme_path .is_file ():
148+ print (f"Error: { README } not found in current directory" , file = sys .stderr )
149+ sys .exit (1 )
150+
151+ try :
152+ # Collect project directories
153+ projects = collect_projects ('.' )
154+
155+ if not projects :
156+ print ("Warning: No project directories found" , file = sys .stderr )
157+
158+ # Build new TOC
159+ new_toc = build_toc_table (projects )
160+
161+ # Read README.md
162+ content = readme_path .read_text (encoding = "utf-8" )
163+
164+ # Normalize headers
165+ content = normalize_headers (content )
166+
167+ # Replace TOC region
168+ updated = update_toc_in_content (content , new_toc )
169+
170+ # Write back if changed
171+ if updated != content :
172+ readme_path .write_text (updated , encoding = "utf-8" )
173+ print (f"✅ { README } updated successfully" )
88174 else :
89- lines .append (
90- ' <td style="padding:4px 8px; text-align:center; border:1px solid #ddd;"></td>'
91- )
92- lines .append (' </tr>' )
93- lines += [' </tbody>' , '</table>' ]
94- new_toc = "\n " .join (lines )
95-
96- # Read README.md
97- with open (README , "r" , encoding = "utf-8" ) as f :
98- content = f .read ()
99-
100- # Normalize all headers to slug-based Title Case (no underscores or hyphens)
101- pattern = re .compile (r'^(### \[)([^\]]+)(\]\(\./([^\)]+)\))' , re .MULTILINE )
102- def repl (match ):
103- raw = match .group (4 )
104- # Decode URL-encoded directory name
105- decoded = urllib .parse .unquote (raw )
106- # Generate slug and display
107- slug = slugify (decoded )
108- disp = header_display_from_slug (slug )
109- # Keep original raw in link
110- return f"### [{ disp } ](./{ raw } )"
111- content = pattern .sub (repl , content )
112-
113- # Replace TOC region
114- toc_re = re .compile (r"(<!-- TOC START -->)(.*?)(<!-- TOC END -->)" , re .S )
115- updated = toc_re .sub (rf"\1\n{ new_toc } \n\3" , content )
116-
117- # Write back if changed
118- if updated != content :
119- with open (README , "w" , encoding = "utf-8" ) as f :
120- f .write (updated )
175+ print (f"ℹ️ { README } already up to date" )
176+
177+ except (OSError , ValueError ) as e :
178+ print (f"Error: { e } " , file = sys .stderr )
179+ sys .exit (1 )
180+
181+
182+ if __name__ == "__main__" :
183+ main ()
0 commit comments