md.py (20438B)
1 import os 2 import re 3 4 FILES_LIST = "list.txt" 5 BSC_ROOT = "." 6 DOCS_OUTPUT = "docs" 7 RAW_BASE_URL = "https://raw.usercontent.amit.is-a.dev" 8 9 # Protected paths that should never be overwritten 10 PROTECTED_INDEX_FILES = { 11 os.path.normpath(os.path.join(DOCS_OUTPUT, f"semester_{i}", "index.md")) 12 for i in range(1, 9) 13 } 14 PROTECTED_INDEX_FILES.add(os.path.normpath(os.path.join(DOCS_OUTPUT, "index.md"))) 15 16 ALGO_FOLDER_NAME = "algorithms" # folder name that contains algorithm .md files 17 18 # Directories excluded from index generation 19 IGNORED_FOLDERS = {"stylesheets", "overrides", "assets", ".vitepress", "node_modules", "public"} 20 21 # Language config mapped by file extension 22 SUPPORTED_LANGS = { 23 '.c': {'label': 'C', 'fence': 'c', 'style': 'c'}, 24 '.cpp': {'label': 'C++', 'fence': 'cpp', 'style': 'c'}, 25 '.h': {'label': 'C Header', 'fence': 'c', 'style': 'c'}, 26 '.hpp': {'label': 'C++ Header', 'fence': 'cpp', 'style': 'c'}, 27 '.r': {'label': 'R', 'fence': 'r', 'style': 'hash'}, 28 '.py': {'label': 'Python', 'fence': 'python', 'style': 'hash'}, 29 '.java': {'label': 'Java', 'fence': 'java', 'style': 'c'}, 30 '.js': {'label': 'JavaScript', 'fence': 'javascript', 'style': 'c'}, 31 '.ts': {'label': 'TypeScript', 'fence': 'typescript', 'style': 'c'}, 32 '.sh': {'label': 'Bash', 'fence': 'bash', 'style': 'hash'}, 33 } 34 35 def read_block_comment(lines, start): 36 result = [] 37 i = start 38 n = len(lines) 39 first = lines[i].strip() 40 41 if first.startswith('/*') and '*/' in first: 42 inner = first[2: first.index('*/')].strip().strip('*').strip() 43 if inner: 44 result.append(inner) 45 return result, i + 1 46 47 inner = first[2:].strip().strip('*').strip() 48 if inner: 49 result.append(inner) 50 i += 1 51 52 while i < n: 53 line = lines[i].strip() 54 if '*/' in line: 55 text = line[: line.index('*/')].strip().strip('*').strip() 56 if text: 57 result.append(text) 58 return result, i + 1 59 text = line.strip('*').strip() 60 if text: 61 result.append(text) 62 i += 1 63 64 return result, i 65 66 def esc_yaml(s: str) -> str: 67 return s.replace("'", "''") 68 69 70 def esc_html(s: str) -> str: 71 """Escape characters that break Vue template compilation in VitePress.""" 72 return s.replace('&', '&').replace('<', '<').replace('>', '>') 73 74 def format_author(author: str) -> str: 75 """Return a mailto markdown link if the author string contains an email.""" 76 m = re.search(r'(.*?)\s*<(.*?)>', author) 77 if m: 78 name = esc_html(m.group(1).strip()) 79 email = esc_html(m.group(2).strip()) 80 return f"[{name}](mailto:{email})" 81 return esc_html(author) 82 83 def parse_c_style(content): 84 lines = content.splitlines() 85 n = len(lines) 86 i = 0 87 author = date = repo = license_str = problem_statement = "" 88 89 while i < n and not lines[i].strip(): 90 i += 1 91 92 # Extract metadata block 93 if i < n and lines[i].strip().startswith('/*'): 94 block, i = read_block_comment(lines, i) 95 for line in block: 96 # Handles both newline-per-field and pipe-separated formats 97 parts = [p.strip() for p in line.split('|')] 98 for part in parts: 99 if ':' in part: 100 key, _, val = part.partition(':') 101 key = key.strip().lower() 102 val = val.strip() 103 if 'author' in key: author = val 104 elif 'date' in key: date = val 105 elif 'repo' in key: repo = val 106 elif 'license' in key: license_str = val 107 108 while i < n and not lines[i].strip(): 109 i += 1 110 111 # Extract problem statement block (ensures it's not the actual code starting) 112 if i < n and lines[i].strip().startswith('/*'): 113 peek_block, peek_i = read_block_comment(lines, i) 114 block_text = ' '.join(peek_block) 115 if '#include' not in block_text and 'import ' not in block_text: 116 problem_statement = ' '.join(p for p in peek_block if p).strip() 117 i = peek_i 118 119 # Locate the beginning of actual source code 120 code_start = None 121 for j in list(range(i, n)) + list(range(0, i)): 122 line_strip = lines[j].strip() 123 if line_strip.startswith('#include') or line_strip.startswith('import '): 124 code_start = j 125 break 126 127 code = '\n'.join(lines[code_start:]).strip() if code_start is not None else content.strip() 128 return author, date, repo, license_str, problem_statement, code 129 130 def parse_hash_style(content): 131 lines = content.splitlines() 132 n = len(lines) 133 i = 0 134 author = date = repo = license_str = problem_statement = "" 135 136 while i < n and not lines[i].strip(): 137 i += 1 138 139 # Extract metadata block 140 meta_lines = [] 141 while i < n and lines[i].strip().startswith('#'): 142 meta_lines.append(lines[i].strip()[1:].strip()) 143 i += 1 144 145 for raw_line in meta_lines: 146 for part in raw_line.split('|'): 147 if ':' in part: 148 key, _, val = part.partition(':') 149 key = key.strip().lower() 150 val = val.strip() 151 if 'author' in key: author = val 152 elif 'date' in key: date = val 153 elif 'repo' in key: repo = val 154 elif 'license' in key: license_str = val 155 156 while i < n and not lines[i].strip(): 157 i += 1 158 159 # Extract problem statement block 160 ps_lines = [] 161 while i < n and lines[i].strip().startswith('#'): 162 text = lines[i].strip()[1:].strip() 163 if text: 164 ps_lines.append(text) 165 i += 1 166 167 if ps_lines: 168 problem_statement = ' '.join(ps_lines).strip() 169 170 code = content.strip() 171 return author, date, repo, license_str, problem_statement, code 172 173 174 ALGO_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline; margin-bottom:-2px; margin-right:6px;" class="lucide lucide-binary"><rect x="14" y="14" width="4" height="6" rx="2"/><rect x="6" y="4" width="4" height="6" rx="2"/><path d="M6 20h4"/><path d="M14 10h4"/><path d="M6 14h2v6"/><path d="M14 4h2v6"/></svg>' 175 176 def parse_algo_md(content): 177 """Parse a GitHub-style algorithm .md file and convert to VitePress format.""" 178 lines = content.splitlines() 179 title = "" 180 problem_statement = "" 181 body_lines = [] 182 i = 0 183 n = len(lines) 184 185 # Extract title from first # heading 186 while i < n: 187 line = lines[i] 188 if line.startswith("# "): 189 title = line[2:].strip() 190 i += 1 191 break 192 i += 1 193 194 # Parse rest: find problem statement in > blockquote under ### Problem Statement 195 in_problem_section = False 196 ps_lines = [] 197 198 while i < n: 199 line = lines[i] 200 stripped = line.strip() 201 202 if stripped.lower().startswith("### problem statement"): 203 in_problem_section = True 204 i += 1 205 continue 206 207 if in_problem_section: 208 # Allow blank lines between heading and blockquote 209 if stripped == "": 210 i += 1 211 continue 212 if stripped.startswith("> "): 213 ps_lines.append(stripped[2:].strip()) 214 i += 1 215 continue 216 elif stripped == ">": 217 i += 1 218 continue 219 else: 220 # Non-blockquote, non-blank line ends problem section 221 in_problem_section = False 222 problem_statement = " ".join(ps_lines).strip() 223 body_lines.append(line) 224 else: 225 body_lines.append(line) 226 227 i += 1 228 229 if in_problem_section and ps_lines: 230 problem_statement = " ".join(ps_lines).strip() 231 232 return title, problem_statement, body_lines 233 234 def build_algo_md(filename_base, title, problem_statement, body_lines, source_path=""): 235 """Build VitePress .md from parsed algo content.""" 236 desc = problem_statement if problem_statement else f"Algorithm — {title}" 237 238 fm = [ 239 "---", 240 f"title: '{ALGO_ICON_SVG} {esc_yaml(title)}'", 241 f"description: '{esc_yaml(desc)}'", 242 f"source: '{source_path}'", 243 "---", 244 "", 245 ] 246 247 body = [f"# {title}", ""] 248 249 if problem_statement: 250 body += [ 251 "### Problem Statement", 252 "", 253 "::: tip Problem Statement", 254 esc_html(problem_statement), 255 ":::", 256 "", 257 ] 258 259 # Replace > blockquote problem section in body with nothing (already handled above) 260 # Just append the remaining body lines (Algorithm, Pseudocode, Complexity etc.) 261 skip_next_blockquote = False 262 cleaned = [] 263 blines = body_lines[:] 264 j = 0 265 while j < len(blines): 266 l = blines[j] 267 s = l.strip() 268 if s.lower().startswith("### problem statement"): 269 # skip until blockquote ends 270 j += 1 271 while j < len(blines) and (blines[j].strip().startswith(">") or blines[j].strip() == ""): 272 j += 1 273 continue 274 cleaned.append(l) 275 j += 1 276 277 # Remove leading blank lines from cleaned 278 while cleaned and not cleaned[0].strip(): 279 cleaned.pop(0) 280 281 body += cleaned 282 283 return "\n".join(fm + body) 284 285 286 def build_md(filename, lang_label, fence_lang, author, date, repo, license_str, 287 problem_statement, code, raw_url, github_url, rel_url): 288 289 desc = problem_statement if problem_statement else f"{lang_label} program — {filename}" 290 icon_svg = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:inline; margin-bottom:-2px; margin-right:6px;"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1"/><path d="M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1"/></svg>' 291 292 fm_lines = [ 293 "---", 294 f"title: '{icon_svg} {esc_yaml(filename)}'", 295 f"description: '{esc_yaml(desc)}'", 296 f"source: '{rel_url}'", 297 "---", 298 ] 299 300 body = [ 301 "", 302 f"# {filename}", 303 "" 304 ] 305 306 has_meta = any([author, date, license_str]) 307 if has_meta: 308 body.extend([ 309 "### Metadata", 310 "" 311 ]) 312 313 if author: 314 body.append(f"- **Author** — {format_author(author)}") 315 if date: 316 body.append(f"- **Last updated** — {esc_html(date)}") 317 if license_str: 318 body.append( 319 f"- **License** — [{esc_html(license_str)}]" 320 f"(https://github.com/notamitgamer/bsc/blob/main/LICENSE)" 321 ) 322 323 body.append("") 324 325 if problem_statement: 326 body += [ 327 "", 328 "### Problem Statement", 329 "", 330 f"::: tip {filename}", 331 esc_html(problem_statement), 332 ":::", 333 "", 334 ] 335 336 body += [ 337 "## Source Code", 338 "", 339 f"```{fence_lang} [{filename}]", 340 code, 341 "```", 342 "---", 343 # Action links — minimal, right-aligned feel via HTML 344 '<div style="display:flex;gap:12px;margin-bottom:12px;">', 345 f' <a href="{github_url}" target="_blank" rel="noopener noreferrer"' 346 ' style="display:inline-flex;align-items:center;gap:6px;font-size:0.85rem;' 347 ' text-decoration:none;color:var(--vp-c-brand);">', 348 ' <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24"' 349 ' fill="currentColor" style="vertical-align:middle;">', 350 ' <path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.205 11.387.6.113.82-.258' 351 ' .82-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.757' 352 ' -1.333-1.757-1.089-.745.083-.729.083-.729 1.205.084 1.84 1.236 1.84 1.236' 353 ' 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3' 354 ' -5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523' 355 ' .105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006' 356 ' 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873' 357 ' .12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92' 358 ' .42.36.81 1.096.81 2.22v3.293c0 .319.21.694.825.576C20.565 21.795' 359 ' 24 17.298 24 12c0-6.627-5.373-12-12-12z"/>', 360 ' </svg>', 361 ' View on GitHub', 362 ' </a>', 363 f' <a href="{raw_url}" target="_blank" rel="noopener noreferrer"' 364 ' style="display:inline-flex;align-items:center;gap:6px;font-size:0.85rem;' 365 ' text-decoration:none;color:var(--vp-c-text-2);">', 366 ' <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"' 367 ' fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"' 368 ' stroke-linejoin="round" style="vertical-align:middle;">', 369 ' <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>', 370 ' <polyline points="7 10 12 15 17 10"/>', 371 ' <line x1="12" y1="15" x2="12" y2="3"/>', 372 ' </svg>', 373 ' Download Raw', 374 ' </a>', 375 '</div>', 376 "", 377 "::: info Printing the code", 378 "To print this file, open it on GitHub and click **Raw** before printing, or use the **Download Raw** button above and print directly from that page.", 379 ":::", 380 "", 381 ] 382 383 return '\n'.join(fm_lines + body) 384 385 def _get_md_title(md_path: str) -> str: 386 try: 387 with open(md_path, encoding='utf-8') as f: 388 content = f.read() 389 # Extracts actual title text, ignoring prepended SVGs or icons 390 m = re.search(r'^title:\s*\'(?:.*?</svg>\s*)?(.+)\'$', content, re.MULTILINE) 391 if m: 392 return m.group(1).strip() 393 except OSError: 394 pass 395 return os.path.splitext(os.path.basename(md_path))[0] 396 397 def create_folder_indexes(docs_root): 398 for root, dirs, files in os.walk(docs_root): 399 dirs[:] = [d for d in dirs if d not in IGNORED_FOLDERS] 400 401 folder_name = os.path.basename(root) 402 403 if folder_name in IGNORED_FOLDERS: 404 continue 405 406 index_path = os.path.normpath(os.path.join(root, 'index.md')) 407 408 if index_path in PROTECTED_INDEX_FILES and os.path.exists(index_path): 409 continue 410 411 md_files = sorted( 412 f for f in files 413 if f.endswith('.md') and f.lower() not in {'index.md', 'readme.md', 'default.md', 'home.md', 'tags.md'} 414 ) 415 subdirs = sorted(dirs) 416 417 if root == docs_root: 418 title = "BSc Code Index" 419 intro = "Select a category from the left sidebar or the table below." 420 else: 421 title = folder_name.replace('_', ' ').replace('-', ' ').title() 422 intro = f"Files and sub-folders in **{title}**." 423 424 with open(index_path, 'w', encoding='utf-8') as f: 425 f.write(f"# {title}\n\n{intro}\n\n") 426 427 if subdirs: 428 f.write("## Folders\n\n") 429 f.write("| # | Folder | Link |\n") 430 f.write("|---|---|---|\n") 431 for idx, d in enumerate(subdirs, 1): 432 dir_title = d.replace('_', ' ').replace('-', ' ').title() 433 f.write(f"| {idx} | {dir_title} | [Open]({d}/index.md) |\n") 434 f.write("\n") 435 436 if md_files: 437 f.write("## Files\n\n") 438 f.write("| # | File | Link |\n") 439 f.write("|---|---|---|\n") 440 for idx, md_file in enumerate(md_files, 1): 441 src_name = _get_md_title(os.path.join(root, md_file)) 442 f.write(f"| {idx} | `{src_name}` | [View Code]({md_file}) |\n") 443 f.write("\n") 444 445 def main(): 446 with open(FILES_LIST, 'r', encoding='utf-8') as f: 447 file_paths = [line.strip() for line in f if line.strip()] 448 449 generated = skipped = 0 450 451 for full_path in file_paths: 452 full_path = os.path.normpath(full_path) 453 ext = os.path.splitext(full_path)[1].lower() 454 455 # Handle algorithm .md files 456 if ext == '.md': 457 parent_folder = os.path.basename(os.path.dirname(full_path)) 458 if parent_folder.lower() != ALGO_FOLDER_NAME: 459 print(f"SKIP (non-algo .md): {full_path}") 460 skipped += 1 461 continue 462 463 try: 464 with open(full_path, 'r', encoding='utf-8', errors='ignore') as f: 465 content_md = f.read() 466 except FileNotFoundError: 467 print(f"NOT FOUND: {full_path}") 468 skipped += 1 469 continue 470 471 filename_base = os.path.splitext(os.path.basename(full_path))[0] 472 rel_path = os.path.relpath(full_path, BSC_ROOT) 473 title, problem_statement, body_lines = parse_algo_md(content_md) 474 if not title: 475 title = filename_base 476 rel_url_algo = rel_path.replace('\\', '/') 477 md_content = build_algo_md(filename_base, title, problem_statement, body_lines, rel_url_algo) 478 479 rel_path = os.path.relpath(full_path, BSC_ROOT) 480 md_rel = os.path.splitext(rel_path)[0] + '.md' 481 md_out = os.path.normpath(os.path.join(DOCS_OUTPUT, md_rel)) 482 483 if md_out in PROTECTED_INDEX_FILES and os.path.exists(md_out): 484 print(f"SKIP (Protected Index File): {md_out}") 485 skipped += 1 486 continue 487 488 os.makedirs(os.path.dirname(md_out), exist_ok=True) 489 with open(md_out, 'w', encoding='utf-8') as f: 490 f.write(md_content) 491 492 print(f"OK {md_rel}") 493 generated += 1 494 continue 495 496 if ext not in SUPPORTED_LANGS: 497 print(f"SKIP (unsupported extension): {full_path}") 498 skipped += 1 499 continue 500 501 lang_info = SUPPORTED_LANGS[ext] 502 503 try: 504 rel_path = os.path.relpath(full_path, BSC_ROOT) 505 except ValueError: 506 print(f"SKIP (relpath failed): {full_path}") 507 skipped += 1 508 continue 509 510 rel_url = rel_path.replace('\\', '/') 511 raw_url = f"{RAW_BASE_URL}/{rel_url}" 512 github_url = f"https://github.com/notamitgamer/bsc/blob/main/{rel_url}" 513 514 try: 515 with open(full_path, 'r', encoding='utf-8', errors='ignore') as f: 516 content = f.read() 517 except FileNotFoundError: 518 print(f"NOT FOUND: {full_path}") 519 skipped += 1 520 continue 521 522 filename = os.path.basename(full_path) 523 524 if lang_info['style'] == 'c': 525 author, date, repo, license_str, problem_statement, code = parse_c_style(content) 526 else: 527 author, date, repo, license_str, problem_statement, code = parse_hash_style(content) 528 529 md_content = build_md( 530 filename, lang_info['label'], lang_info['fence'], author, date, repo, license_str, 531 problem_statement, code, raw_url, github_url, rel_url, 532 ) 533 534 md_rel = os.path.splitext(rel_path)[0] + '.md' 535 md_out = os.path.normpath(os.path.join(DOCS_OUTPUT, md_rel)) 536 537 if md_out in PROTECTED_INDEX_FILES and os.path.exists(md_out): 538 print(f"SKIP (Protected Index File): {md_out}") 539 skipped += 1 540 continue 541 542 os.makedirs(os.path.dirname(md_out), exist_ok=True) 543 with open(md_out, 'w', encoding='utf-8') as f: 544 f.write(md_content) 545 546 print(f"OK {md_rel}") 547 generated += 1 548 549 print(f"\nDone - {generated} generated, {skipped} skipped.") 550 print("Generating folder index pages...") 551 create_folder_indexes(DOCS_OUTPUT) 552 print("Folder indexes created.") 553 554 if __name__ == "__main__": 555 main()