Coverage for website/builder/markdown.py: 85%

350 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-20 10:12 +0000

1""" 

2Markdown Processing - Markdown-to-HTML Conversion. 

3 

4This module handles markdown processing, HTML conversion, 

5and content formatting for the website builder. 

6""" 

7 

8import re 

9 

10 

11class MarkdownProcessor: 

12 """Handles markdown processing and HTML conversion.""" 

13 

14 def markdown_to_html( 

15 self, markdown_content: str, source_file: str = "", output_file: str = "" 

16 ) -> str: 

17 """Convert markdown to HTML with Bootstrap styling.""" 

18 # Normalize empty/whitespace-only content consistently across code paths 

19 if not markdown_content.strip(): 

20 return "" 

21 try: 

22 import markdown 

23 

24 md = markdown.Markdown( 

25 extensions=[ 

26 # Supports fenced code blocks reliably inside list items (superset of fenced_code). 

27 "pymdownx.superfences", 

28 "codehilite", 

29 "tables", 

30 "toc", 

31 "attr_list", 

32 "def_list", 

33 "footnotes", 

34 "md_in_html", 

35 "sane_lists", 

36 ], 

37 extension_configs={ 

38 "pymdownx.superfences": { 

39 "custom_fences": [] # Disable custom fences that might use Pygments 

40 }, 

41 "codehilite": { 

42 "css_class": "codehilite", 

43 "use_pygments": False, # Use simple highlighting without Pygments 

44 "guess_lang": True, 

45 }, 

46 }, 

47 ) 

48 html = md.convert(markdown_content) 

49 

50 # Fix any remaining malformed code blocks 

51 html = self.fix_malformed_code_blocks(html) 

52 

53 # Add Bootstrap classes 

54 html = self.add_bootstrap_classes(html) 

55 

56 # Render GitHub-style task list markers as clickable checkboxes 

57 html = self.render_task_list_checkboxes(html) 

58 

59 # Ensure heading IDs 

60 html = self.ensure_heading_ids(html) 

61 

62 return html 

63 

64 except ImportError: 

65 # Fallback to basic conversion 

66 html = self._basic_markdown_to_html_no_regex(markdown_content) 

67 # Apply Bootstrap classes to fallback HTML too 

68 html = self.add_bootstrap_classes(html) 

69 # Render task lists in fallback mode too 

70 html = self.render_task_list_checkboxes(html) 

71 # Ensure heading IDs 

72 html = self.ensure_heading_ids(html) 

73 return html 

74 

75 def _basic_markdown_to_html_no_regex(self, markdown_content: str) -> str: 

76 """Basic markdown to HTML conversion without regex.""" 

77 content = markdown_content 

78 if not content.strip(): 

79 return "" 

80 

81 def transform_inline(text: str) -> str: 

82 # Bold (strong) and italics (em) 

83 text = re.sub( 

84 r"\*\*([^*]+)\*\*", lambda m: f"<strong>{m.group(1)}</strong>", text 

85 ) 

86 text = re.sub(r"\*([^*]+)\*", lambda m: f"<em>{m.group(1)}</em>", text) 

87 # Inline code 

88 text = re.sub(r"`([^`]+)`", lambda m: f"<code>{m.group(1)}</code>", text) 

89 # Links [text](url) 

90 text = re.sub( 

91 r"\[([^\]]+)\]\(([^)]+)\)", 

92 lambda m: f'<a href="{m.group(2)}">{m.group(1)}</a>', 

93 text, 

94 ) 

95 return text 

96 

97 lines = content.split("\n") 

98 html_lines: list[str] = [] 

99 in_code_block = False 

100 in_list = False 

101 

102 for line in lines: 

103 raw = line.rstrip("\n") 

104 stripped = raw.lstrip() 

105 if stripped.startswith("```"): 

106 if in_code_block: 

107 html_lines.append("</code></pre>") 

108 in_code_block = False 

109 else: 

110 # close any open list before starting code block 

111 if in_list: 

112 html_lines.append("</ul>") 

113 in_list = False 

114 html_lines.append("<pre><code>") 

115 in_code_block = True 

116 continue 

117 

118 if in_code_block: 

119 html_lines.append(raw) 

120 continue 

121 

122 # Headings 

123 if raw.startswith("# "): 

124 if in_list: 

125 html_lines.append("</ul>") 

126 in_list = False 

127 html_lines.append(f"<h1>{transform_inline(raw[2:])}</h1>") 

128 continue 

129 if raw.startswith("## "): 

130 if in_list: 

131 html_lines.append("</ul>") 

132 in_list = False 

133 html_lines.append(f"<h2>{transform_inline(raw[3:])}</h2>") 

134 continue 

135 if raw.startswith("### "): 

136 if in_list: 

137 html_lines.append("</ul>") 

138 in_list = False 

139 html_lines.append(f"<h3>{transform_inline(raw[4:])}</h3>") 

140 continue 

141 if raw.startswith("#### "): 

142 if in_list: 

143 html_lines.append("</ul>") 

144 in_list = False 

145 html_lines.append(f"<h4>{transform_inline(raw[5:])}</h4>") 

146 continue 

147 if raw.startswith("##### "): 

148 if in_list: 

149 html_lines.append("</ul>") 

150 in_list = False 

151 html_lines.append(f"<h5>{transform_inline(raw[6:])}</h5>") 

152 continue 

153 if raw.startswith("###### "): 

154 if in_list: 

155 html_lines.append("</ul>") 

156 in_list = False 

157 html_lines.append(f"<h6>{transform_inline(raw[7:])}</h6>") 

158 continue 

159 

160 # Lists 

161 if raw.lstrip().startswith("- "): 

162 if not in_list: 

163 html_lines.append("<ul>") 

164 in_list = True 

165 item_text = raw.lstrip()[2:] 

166 html_lines.append(f"<li>{transform_inline(item_text)}</li>") 

167 continue 

168 else: 

169 if in_list and raw.strip() == "": 

170 html_lines.append("</ul>") 

171 in_list = False 

172 

173 # Paragraphs 

174 if raw.strip(): 

175 html_lines.append(f"<p>{transform_inline(raw)}</p>") 

176 

177 # Close any open list 

178 if in_list: 

179 html_lines.append("</ul>") 

180 

181 # Join and strip extraneous blank lines 

182 html = "\n".join([h for h in html_lines if h is not None]) 

183 # Apply Bootstrap classes and heading IDs 

184 return html 

185 

186 def fix_malformed_code_blocks(self, html_content: str) -> str: 

187 """Fix code blocks that weren't properly converted by markdown.""" 

188 

189 # Fix single-line code snippets that should be code blocks 

190 # Convert paragraphs with inline code containing bash commands to proper code blocks 

191 html_content = re.sub( 

192 r'<p><code class="inline-code">(bash|sh)\s*\n\s*([^<]+)</code></p>', 

193 r'<div class="code-block-wrapper"><pre class="code-block"><code class="language-\1">\2</code></pre></div>', 

194 html_content, 

195 ) 

196 

197 # Fix paragraphs with bash/shell commands (with or without language prefix) 

198 html_content = re.sub( 

199 r'<p><code class="inline-code">(?:bash\s*\n\s*)?([^<]*(?:mkdir|cd|pip|uv|qdrant-loader|mcp-)[^<]*)</code></p>', 

200 r'<div class="code-block-wrapper"><pre class="code-block"><code class="language-bash">\1</code></pre></div>', 

201 html_content, 

202 ) 

203 

204 # Also handle cases where there's no class attribute 

205 html_content = re.sub( 

206 r"<p><code>(?:bash\s*\n\s*)?([^<]*(?:mkdir|cd|pip|uv|qdrant-loader|mcp-)[^<]*)</code></p>", 

207 r'<div class="code-block-wrapper"><pre class="code-block"><code class="language-bash">\1</code></pre></div>', 

208 html_content, 

209 ) 

210 

211 # Clean up stray <p> tags inside code blocks 

212 html_content = re.sub( 

213 r"(<code[^>]*>.*?)</p>\s*<p>(.*?</code>)", 

214 r"\1\n\2", 

215 html_content, 

216 flags=re.DOTALL, 

217 ) 

218 

219 # Fix paragraphs that contain triple backticks (malformed code blocks) 

220 def fix_code_block(match): 

221 content = match.group(1) 

222 # Extract language if present 

223 lines = content.split("\n") 

224 first_line = lines[0].strip() 

225 if first_line.startswith("```"): 

226 language = first_line[3:].strip() 

227 code_content = "\n".join(lines[1:]) 

228 # Remove trailing ``` if present 

229 if code_content.endswith("```"): 

230 code_content = code_content[:-3].rstrip() 

231 return f'<div class="code-block-wrapper"><pre class="code-block"><code class="language-{language}">{code_content}</code></pre></div>' 

232 return match.group(0) 

233 

234 # Match paragraphs containing code blocks 

235 html_content = re.sub( 

236 r"<p>(```[^`]*```)</p>", fix_code_block, html_content, flags=re.DOTALL 

237 ) 

238 

239 # Handle multi-paragraph code blocks 

240 html_content = re.sub( 

241 r"<p>```(\w+)\s*</p>\s*<p>(.*?)</p>\s*<p>```</p>", 

242 r'<div class="code-block-wrapper"><pre class="code-block"><code class="language-\1">\2</code></pre></div>', 

243 html_content, 

244 flags=re.DOTALL, 

245 ) 

246 

247 # Handle code blocks split across multiple paragraphs 

248 html_content = re.sub( 

249 r"<p>```(\w+)?\s*(.*?)\s*```</p>", 

250 lambda m: f'<div class="code-block-wrapper"><pre class="code-block"><code class="language-{m.group(1) or ""}">{m.group(2)}</code></pre></div>', 

251 html_content, 

252 flags=re.DOTALL, 

253 ) 

254 

255 return html_content 

256 

257 def ensure_heading_ids(self, html_content: str) -> str: 

258 """Ensure all headings have IDs for anchor links.""" 

259 

260 def slugify(text: str) -> str: 

261 """Convert text to URL-safe slug.""" 

262 import re 

263 

264 slug = re.sub(r"[^\w\s-]", "", text.lower()) 

265 return re.sub(r"[-\s]+", "-", slug).strip("-") 

266 

267 def _extract_text(html: str) -> str: 

268 """Return visible text for a piece of HTML (fall back to img alt).""" 

269 # Remove tags to get visible text 

270 text_only = re.sub(r"<[^>]+>", "", html).strip() 

271 if text_only: 

272 return text_only 

273 # If no visible text, try to get alt from first <img> 

274 m = re.search(r'<img[^>]*alt=["\']([^"\']+)["\']', html) 

275 if m: 

276 return m.group(1).strip() 

277 return "" 

278 

279 def add_id(match: re.Match) -> str: 

280 """Add ID to heading if not present.""" 

281 tag = match.group(1) 

282 attrs = match.group(2) or "" 

283 content = match.group(3) or "" 

284 

285 if "id=" not in attrs: 

286 visible = _extract_text(content) 

287 heading_id = slugify(visible or content) 

288 if attrs: 

289 attrs = f' id="{heading_id}" {attrs.strip()}' 

290 else: 

291 attrs = f' id="{heading_id}"' 

292 

293 return f"<{tag}{attrs}>{content}</{tag}>" 

294 

295 # Match headings even when they contain HTML inside 

296 heading_pattern = r"<(h[1-6])([^>]*)>(.*?)</h[1-6]>" 

297 return re.sub(heading_pattern, add_id, html_content, flags=re.DOTALL) 

298 

299 def add_bootstrap_classes(self, html_content: str) -> str: 

300 """Add Bootstrap classes to HTML elements.""" 

301 

302 def add_classes_to_tag(attrs: str, classes_to_add: str) -> str: 

303 class_match = re.search(r'class="([^"]*)"', attrs) 

304 if class_match: 

305 existing = class_match.group(1).split() 

306 for cls in classes_to_add.split(): 

307 if cls not in existing: 

308 existing.append(cls) 

309 return re.sub( 

310 r'class="([^"]*)"', 

311 f'class="{" ".join(existing)}"', 

312 attrs, 

313 count=1, 

314 ) 

315 return f'{attrs} class="{classes_to_add}"' 

316 

317 # Add Bootstrap header classes 

318 html_content = re.sub( 

319 r"<h1([^>]*)>", 

320 r'<h1\1 class="display-4 fw-bold text-primary mb-4">', 

321 html_content, 

322 ) 

323 html_content = re.sub( 

324 r"<h2([^>]*)>", 

325 r'<h2\1 class="h2 fw-bold text-primary">', 

326 html_content, 

327 ) 

328 html_content = re.sub( 

329 r"<h3([^>]*)>", 

330 r'<h3\1 class="h3 fw-bold text-primary">', 

331 html_content, 

332 ) 

333 html_content = re.sub( 

334 r"<h4([^>]*)>", r'<h4\1 class="h4 fw-bold">', html_content 

335 ) 

336 html_content = re.sub( 

337 r"<h5([^>]*)>", r'<h5\1 class="h5 fw-bold">', html_content 

338 ) 

339 html_content = re.sub( 

340 r"<h6([^>]*)>", r'<h6\1 class="h6 fw-semibold">', html_content 

341 ) 

342 

343 # Add Bootstrap code block classes - clean approach 

344 # First handle codehilite divs 

345 html_content = re.sub( 

346 r'<div class="codehilite">', 

347 '<div class="code-block-wrapper">', 

348 html_content, 

349 ) 

350 

351 # Handle standalone pre blocks (not already in wrappers) 

352 html_content = re.sub( 

353 r'(?<!<div class="code-block-wrapper">)<pre>', 

354 '<div class="code-block-wrapper"><pre class="code-block">', 

355 html_content, 

356 ) 

357 

358 # Add code-block class to pre tags that don't have it 

359 html_content = re.sub( 

360 r'<pre(?![^>]*class="code-block")([^>]*)>', 

361 r'<pre class="code-block"\1>', 

362 html_content, 

363 ) 

364 

365 # Close wrapper divs only for pre blocks that we wrapped 

366 html_content = re.sub( 

367 r'(<div class="code-block-wrapper"><pre class="code-block"[^>]*>.*?)</pre>(?!</div>)', 

368 r"\1</pre></div>", 

369 html_content, 

370 flags=re.DOTALL, 

371 ) 

372 

373 # Normalize codehilite/Pygments token spans so code text stays contiguous. 

374 # This keeps HTML stable for tests and lets our client-side highlighter style code. 

375 html_content = re.sub(r"<span[^>]*>", "", html_content) 

376 html_content = re.sub(r"</span>", "", html_content) 

377 # Add Bootstrap inline code classes 

378 # First handle code blocks, then inline code 

379 html_content = re.sub( 

380 r"<code>", 

381 '<code class="inline-code">', 

382 html_content, 

383 ) 

384 # Override inline-code class for code inside pre blocks 

385 html_content = re.sub( 

386 r'(<pre[^>]*>.*?)<code class="inline-code">', 

387 r"\1<code>", 

388 html_content, 

389 flags=re.DOTALL, 

390 ) 

391 

392 # Add Bootstrap link classes 

393 html_content = re.sub( 

394 r'<a([^>]*?)href="([^"]*)"([^>]*?)>', 

395 r'<a\1href="\2"\3 class="text-decoration-none">', 

396 html_content, 

397 ) 

398 

399 # Normalize numbered step paragraphs into ordered-list items. 

400 # Some markdown flows with fenced code blocks are rendered as: 

401 # <p>1. <strong>Step</strong></p> 

402 # ...code block... 

403 # <p>2. <strong>Step</strong></p> 

404 # Converting these to <ol start="N"><li>...</li></ol> preserves the 

405 # existing list-card CSS while keeping numbering stable. 

406 html_content = re.sub( 

407 r"<p>\s*(\d+)\.\s*(<strong>.*?</strong>.*?)</p>", 

408 r'<ol start="\1"><li>\2</li></ol>', 

409 html_content, 

410 flags=re.DOTALL, 

411 ) 

412 

413 # Add Bootstrap list classes 

414 html_content = re.sub( 

415 r"<ul([^>]*)>", 

416 lambda m: f'<ul{add_classes_to_tag(m.group(1), "list-group list-group-flush")}>', 

417 html_content, 

418 ) 

419 html_content = re.sub( 

420 r"<ol([^>]*)>", 

421 lambda m: f'<ol{add_classes_to_tag(m.group(1), "list-group list-group-numbered")}>', 

422 html_content, 

423 ) 

424 html_content = re.sub( 

425 r"<li([^>]*)>", 

426 lambda m: f'<li{add_classes_to_tag(m.group(1), "list-group-item")}>', 

427 html_content, 

428 ) 

429 

430 # Add Bootstrap table classes 

431 html_content = re.sub( 

432 r"<table>", '<table class="table table-striped table-hover">', html_content 

433 ) 

434 

435 # Add Bootstrap alert classes for blockquotes 

436 html_content = re.sub( 

437 r"<blockquote>", '<blockquote class="alert alert-info">', html_content 

438 ) 

439 

440 # Add Bootstrap button classes to links that look like buttons 

441 html_content = re.sub( 

442 r'<a([^>]*?)class="[^"]*btn[^"]*"([^>]*?)>', 

443 r'<a\1class="btn btn-primary"\2>', 

444 html_content, 

445 ) 

446 

447 return html_content 

448 

449 def render_task_list_checkboxes(self, html_content: str) -> str: 

450 """Render markdown task-list markers as checkbox inputs.""" 

451 

452 def add_class(attrs: str, class_name: str) -> str: 

453 class_match = re.search(r'class="([^"]*)"', attrs) 

454 if class_match: 

455 classes = class_match.group(1).split() 

456 if class_name not in classes: 

457 classes.append(class_name) 

458 return re.sub(r'class="([^"]*)"', f'class="{" ".join(classes)}"', attrs) 

459 return f'{attrs} class="{class_name}"' 

460 

461 def replace_task_item(match: re.Match) -> str: 

462 attrs = match.group("attrs") or "" 

463 marker = match.group("marker") 

464 body = match.group("body") 

465 checked_attr = " checked" if marker.lower() == "x" else "" 

466 attrs = add_class(attrs, "task-list-item") 

467 return ( 

468 f"<li{attrs}>" 

469 f'<input class="form-check-input me-2" type="checkbox"{checked_attr} disabled>' 

470 f"{body}</li>" 

471 ) 

472 

473 return re.sub( 

474 r"<li(?P<attrs>[^>]*)>\s*\[(?P<marker>[ xX])\]\s*(?P<body>.*?)</li>", 

475 replace_task_item, 

476 html_content, 

477 flags=re.DOTALL, 

478 ) 

479 

480 def extract_title_from_markdown(self, markdown_content: str) -> str: 

481 """Extract title from markdown content.""" 

482 lines = markdown_content.split("\n") 

483 for line in lines: 

484 line = line.strip() 

485 if line.startswith("# "): 

486 return line[2:].strip() 

487 return "Documentation" # Default fallback title 

488 

489 def basic_markdown_to_html(self, markdown_content: str) -> str: 

490 """Basic markdown to HTML conversion - alias for compatibility.""" 

491 return self.markdown_to_html(markdown_content) 

492 

493 def convert_markdown_links_to_html( 

494 self, content: str, source_file: str = "", target_dir: str = "" 

495 ) -> str: 

496 """Convert markdown links to HTML format.""" 

497 

498 # Convert [text](link.md) to [text](link.html) - markdown style 

499 def replace_md_links(match): 

500 text = match.group(1) 

501 link = match.group(2) 

502 link = self._process_link_path(link, source_file) 

503 return f"[{text}]({link})" 

504 

505 # Convert href="link.md" to href="link.html" - HTML style 

506 def replace_href_links(match): 

507 prefix = match.group(1) 

508 link = match.group(2) 

509 suffix = match.group(3) 

510 link = self._process_link_path(link, source_file) 

511 return f"{prefix}{link}{suffix}" 

512 

513 # Apply conversions - expanded patterns to catch more file types 

514 # Catch .md files and well-known files without extensions 

515 well_known_link_pattern_md = ( 

516 r"\[([^\]]+)\]\(((?:(?:\.\./)+|\./|/)?" 

517 r"(?:LICENSE|README|CHANGELOG|CONTRIBUTING)(?:/[^)]*)?(?:#[^)]*)?)\)" 

518 ) 

519 well_known_link_pattern_href = ( 

520 r'(href=")((?:(?:\.\./)+|\./|/)?' 

521 r'(?:LICENSE|README|CHANGELOG|CONTRIBUTING)(?:/[^"]*)?(?:#[^"]*)?)(")' 

522 ) 

523 

524 content = re.sub( 

525 r"\[([^\]]+)\]\(([^)]+\.md(?:#[^)]*)?)\)", replace_md_links, content 

526 ) 

527 content = re.sub( 

528 well_known_link_pattern_md, 

529 replace_md_links, 

530 content, 

531 ) 

532 content = re.sub( 

533 r'(href=")([^"]+\.md(?:#[^"]*)?)(")', replace_href_links, content 

534 ) 

535 content = re.sub( 

536 well_known_link_pattern_href, 

537 replace_href_links, 

538 content, 

539 ) 

540 

541 # The following normalizations are only applied during site builds (when source_file is provided). 

542 # Unit tests expect relative paths to be preserved. 

543 if source_file: 

544 # Normalize links that incorrectly include an extra "/docs/" prefix inside /docs pages 

545 # e.g., href="docs/users/..." when already under /docs/ -> make it absolute "/docs/users/..." 

546 content = re.sub(r'(href=")(docs/[^"]+)(")', r"\1/\2\3", content) 

547 content = re.sub(r"\]\((docs/[^)]+)\)", r"](/\1)", content) 

548 

549 # Collapse accidental duplicate docs/docs prefixes 

550 content = re.sub( 

551 r'(href=")/?docs/docs/([^"]+)(")', r"\1/docs/\2\3", content 

552 ) 

553 content = re.sub(r"\]\(/?docs/docs/([^\)]+)\)", r"](/docs/\1)", content) 

554 

555 # Rewrite relative ./docs/... links to absolute /docs/ (HTML and Markdown) 

556 content = re.sub( 

557 r'(href=")\./docs/([^"#]*)(#[^"]*)?(")', r"\1/docs/\2\3\4", content 

558 ) 

559 content = re.sub( 

560 r"\]\(\./docs/([^\)#]*)(#[^\)]*)?\)", r"](/docs/\1\2)", content 

561 ) 

562 

563 # Rewrite relative ../../docs/... links to absolute /docs/ (HTML and Markdown) 

564 content = re.sub( 

565 r'(href=")(?:\.{2}/)+docs/([^"#]*)(#[^"]*)?(")', 

566 r"\1/docs/\2\3\4", 

567 content, 

568 ) 

569 content = re.sub( 

570 r"\]\((?:\.{2}/)+docs/([^\)#]*)(#[^\)]*)?\)", r"](/docs/\1\2)", content 

571 ) 

572 

573 # Convert .md (with optional anchors) to .html in both HTML and Markdown links 

574 content = re.sub( 

575 r'(href=")([^"\s]+)\.md(#[^"]*)?(")', 

576 lambda m: f"{m.group(1)}{m.group(2)}.html{m.group(3) or ''}{m.group(4)}", 

577 content, 

578 ) 

579 content = re.sub( 

580 r"\]\(([^\)\s]+)\.md(#[^\)]*)?\)", 

581 lambda m: f"]({m.group(1)}.html{m.group(2) or ''})", 

582 content, 

583 ) 

584 

585 # Normalize developers relative links to directory indexes 

586 content = re.sub( 

587 r'(href=")\./(architecture|testing|deployment|extending)\.html(")', 

588 r"\1./\2/\3", 

589 content, 

590 ) 

591 # Normalize absolute developers/*.html to directory indexes 

592 content = re.sub( 

593 r'(href=")([^"\s]*/developers/)(architecture|testing|deployment|extending)\.html(")', 

594 r"\1\2\3/\4", 

595 content, 

596 ) 

597 content = re.sub( 

598 r"\]\(([^\)\s]*/developers/)(architecture|testing|deployment|extending)\.html\)", 

599 r"](\1\2/)", 

600 content, 

601 ) 

602 # Normalize parent-relative developers links like ../extending.html to ../extending/ 

603 content = re.sub( 

604 r'(href=")([^"#]*/developers/)(architecture|testing|deployment|extending)\.html(#[^"]*)?(")', 

605 r"\1\2\3/\4\5", 

606 content, 

607 ) 

608 # Normalize sibling links such as ../extending.html -> ../extending/ 

609 content = re.sub( 

610 r'(href=")\.\./(architecture|testing|deployment|extending)\.html(#[^"]*)?(")', 

611 r"\1../\2/\3\4", 

612 content, 

613 ) 

614 content = re.sub( 

615 r"\]\(\.\./(architecture|testing|deployment|extending)\.html(#[^\)]*)?\)", 

616 r"](../\1/\2)", 

617 content, 

618 ) 

619 

620 # Ensure well-known repo root files under /docs have .html extension 

621 content = re.sub( 

622 r'(href=")(/docs/(?:LICENSE|README|CHANGELOG|CONTRIBUTING))(#[^"]*)?(")', 

623 r"\1\2.html\3\4", 

624 content, 

625 ) 

626 

627 # If a target output path is provided, convert absolute /docs/... links to relative ones 

628 if target_dir: 

629 try: 

630 import posixpath 

631 

632 base_dir = target_dir 

633 if not base_dir.endswith("/"): 

634 base_dir = posixpath.dirname(base_dir) + "/" 

635 

636 def _to_relative_html(match: re.Match) -> str: 

637 prefix, path_part, anchor, suffix = ( 

638 match.group(1), 

639 match.group(2), 

640 match.group(3) or "", 

641 match.group(4), 

642 ) 

643 abs_path = "docs/" + path_part 

644 rel = posixpath.relpath(abs_path, base_dir.rstrip("/")) 

645 return f'{prefix}{rel}{anchor or ""}{suffix}' 

646 

647 def _to_relative_md(match: re.Match) -> str: 

648 path_part, anchor = match.group(1), match.group(2) or "" 

649 abs_path = "docs/" + path_part 

650 rel = posixpath.relpath(abs_path, base_dir.rstrip("/")) 

651 return f"]({rel}{anchor})" 

652 

653 content = re.sub( 

654 r'(href=")/docs/([^"#]+)(#[^"]*)?(")', 

655 _to_relative_html, 

656 content, 

657 ) 

658 content = re.sub( 

659 r"\]\(/docs/([^\)#]+)(#[^\)]*)?\)", _to_relative_md, content 

660 ) 

661 except Exception: 

662 # Fallback silently if relative conversion fails 

663 pass 

664 

665 return content 

666 

667 def _process_link_path(self, link: str, source_file: str = "") -> str: 

668 """Process a link path for conversion.""" 

669 # Preserve anchor fragments while processing 

670 anchor = "" 

671 if "#" in link: 

672 link, anchor = link.split("#", 1) 

673 anchor = "#" + anchor 

674 

675 # Only rewrite to absolute /docs when building from a source file context 

676 if source_file: 

677 # ../../docs/... -> /docs/... 

678 link = re.sub(r"^(?:\.{2}/)+docs/", "/docs/", link) 

679 # ./docs/... -> /docs/... 

680 link = re.sub(r"^\./docs/", "/docs/", link) 

681 # docs/... (relative) -> /docs/... 

682 if link.startswith("docs/"): 

683 link = "/" + link 

684 

685 # Decide whether to convert .md to .html (preserving anchors) 

686 should_convert_md = True 

687 if anchor and "/" not in link and not source_file: 

688 # Preserve bare filename.md#anchor in tests (no source context) 

689 should_convert_md = False 

690 

691 if link.endswith(".md") and should_convert_md: 

692 link = link[:-3] + ".html" 

693 else: 

694 # Handle well-known files without extensions 

695 filename = link.split("/")[-1] 

696 if ( 

697 filename.upper() in ["LICENSE", "README", "CHANGELOG", "CONTRIBUTING"] 

698 and "." not in filename 

699 ): 

700 # Ensure these resolve under /docs when referenced from packages 

701 if ( 

702 source_file 

703 and not link.startswith("/docs/") 

704 and filename.upper() 

705 in ["LICENSE", "README", "CHANGELOG", "CONTRIBUTING"] 

706 ): 

707 # Nudge to /docs root for repo-wide files 

708 link = "/docs/" + filename 

709 link = link + ".html" 

710 

711 # Collapse accidental duplicate /docs/docs prefixes 

712 link = re.sub(r"^/docs/docs/", "/docs/", link) 

713 link = link.replace("docs/docs/", "docs/") 

714 

715 # Ensure absolute /docs/ links are normalized (only when building) 

716 if source_file and link.startswith("docs/"): 

717 link = "/" + link 

718 

719 return link + anchor 

720 

721 def render_toc(self, html_content: str) -> str: 

722 """Generate table of contents from HTML headings.""" 

723 # Find all headings (capture inner HTML, allow multiline) 

724 heading_pattern = r'<(h[1-6])[^>]*id="([^\"]+)"[^>]*>(.*?)</h[1-6]>' 

725 headings = re.findall(heading_pattern, html_content, flags=re.DOTALL) 

726 

727 if not headings: 

728 return "" 

729 

730 toc_html = '<div class="toc"><h3>Table of Contents</h3>' 

731 

732 # Build hierarchical structure 

733 current_level = 0 

734 open_lists = 0 

735 

736 import html as _html 

737 

738 # Default leaf icon (small, neutral color) 

739 default_svg = ( 

740 '<svg class="toc-icon" style="width:1rem;height:1rem;object-fit:contain;vertical-align:middle;margin-right:0.35rem;" ' 

741 'width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">' 

742 '<path d="M10 7.5C9.50555 7.5 9.0222 7.64662 8.61108 7.92133C8.19995 8.19603 7.87952 8.58648 7.6903 9.04329C7.50108 9.50011 7.45157 10.0028 7.54804 10.4877C7.6445 10.9727 7.8826 11.4181 8.23223 11.7678C8.58187 12.1174 9.02732 12.3555 9.51228 12.452C9.99723 12.5484 10.4999 12.4989 10.9567 12.3097C11.4135 12.1205 11.804 11.8 12.0787 11.3889C12.3534 10.9778 12.5 10.4945 12.5 10C12.5 9.33696 12.2366 8.70107 11.7678 8.23223C11.2989 7.76339 10.663 7.5 10 7.5ZM10 11.25C9.75277 11.25 9.5111 11.1767 9.30554 11.0393C9.09998 10.902 8.93976 10.7068 8.84515 10.4784C8.75054 10.2499 8.72579 9.99861 8.77402 9.75614C8.82225 9.51366 8.9413 9.29093 9.11612 9.11612C9.29093 8.9413 9.51366 8.82225 9.75614 8.77402C9.99861 8.72579 10.2499 8.75054 10.4784 8.84515C10.7068 8.93976 10.902 9.09998 11.0393 9.30554C11.1767 9.5111 11.25 9.75277 11.25 10C11.25 10.3315 11.1183 10.6495 10.8839 10.8839C10.6495 11.1183 10.3315 11.25 10 11.25Z" fill="#343330" />' 

743 "</svg>" 

744 ) 

745 

746 for idx, (tag, heading_id, text) in enumerate(headings): 

747 level = int(tag[1]) # Extract number from h1, h2, etc. 

748 

749 # Determine if this heading has child headings (deeper level) before next sibling 

750 has_child = False 

751 for next_tag, _next_id, _next_text in headings[idx + 1 :]: 

752 next_level = int(next_tag[1]) 

753 if next_level > level: 

754 has_child = True 

755 break 

756 if next_level <= level: 

757 break 

758 

759 # Handle level changes 

760 if level > current_level: 

761 # Open new nested lists for deeper levels 

762 while current_level < level: 

763 if current_level == 0: 

764 toc_html += "<ul>" 

765 else: 

766 toc_html += "<ul>" 

767 open_lists += 1 

768 current_level += 1 

769 elif level < current_level: 

770 # Close lists for shallower levels 

771 while current_level > level: 

772 toc_html += "</ul>" 

773 open_lists -= 1 

774 current_level -= 1 

775 

776 # Extract first <img> if present and sanitize it for TOC display 

777 icon_html = "" 

778 img_match = re.search(r"(<img[^>]*>)", text, flags=re.DOTALL) 

779 if img_match: 

780 icon_html = img_match.group(1) 

781 # Remove any on* handlers and javascript: hrefs for safety 

782 icon_html = re.sub( 

783 r"\s(on\w+)\s*=\s*(\"[^\"]*\"|'[^']*')", "", icon_html 

784 ) 

785 icon_html = re.sub( 

786 r"javascript:\s*", "", icon_html, flags=re.IGNORECASE 

787 ) 

788 # Remove any existing size/style attributes so we can normalize appearance 

789 icon_html = re.sub( 

790 r"\s(width|height)=\s*(\"[^\"]*\"|'[^']*')", "", icon_html 

791 ) 

792 icon_html = re.sub(r"\sstyle=\s*(\"[^\"]*\"|'[^']*')", "", icon_html) 

793 # Ensure a small consistent size and spacing for TOC icons 

794 # Add class toc-icon (append if class exists) 

795 if re.search(r"\sclass=\s*\"[^\"]+\"", icon_html): 

796 icon_html = re.sub( 

797 r"\sclass=\s*\"([^\"]+)\"", 

798 lambda m: f' class="{m.group(1)} toc-icon"', 

799 icon_html, 

800 ) 

801 elif re.search(r"\sclass=\s*'[^']+'", icon_html): 

802 icon_html = re.sub( 

803 r"\sclass=\s*'([^']+)'", 

804 lambda m: f" class='{m.group(1)} toc-icon'", 

805 icon_html, 

806 ) 

807 else: 

808 # inject class and inline style before the closing > 

809 icon_html = ( 

810 icon_html.rstrip(">") 

811 + ' class="toc-icon" style="width:1rem;height:1rem;object-fit:contain;vertical-align:middle;margin-right:0.35rem;">' 

812 ) 

813 

814 # Derive display text: strip HTML, or use img alt, or fallback to id 

815 display_text = re.sub(r"<[^>]+>", "", text).strip() 

816 if not display_text: 

817 m = re.search(r'<img[^>]*alt=["\']([^"\']+)["\']', text) 

818 if m: 

819 display_text = m.group(1).strip() 

820 else: 

821 display_text = heading_id 

822 

823 display_text = _html.escape(_html.unescape(display_text)) 

824 

825 # If no icon and this is a level-3 leaf heading (and not already a numbered item), use the default SVG 

826 starts_with_number = bool(re.match(r"^\d+\.", display_text)) 

827 if ( 

828 not icon_html 

829 and not has_child 

830 and level == 3 

831 and not starts_with_number 

832 ): 

833 icon_html = default_svg 

834 

835 toc_html += f'<li class="list-group-item"><a href="#{heading_id}">{icon_html}{display_text}</a></li>\n' 

836 # Close all remaining open lists 

837 while open_lists > 0: 

838 toc_html += "</ul>" 

839 open_lists -= 1 

840 

841 toc_html += "</div>" 

842 

843 return toc_html