Skip to content

Commit 6ab4479

Browse files
Martin MahnerMartin Mahner
authored andcommitted
Add internal link rewriting for navigation and improve section switching logic
- Automatically rewrite markdown file links to section anchors - Preserve external links and non-section markdown links - Monitor hash links and enable section switching without page reloads - Centralize functionality with added tests for validation
1 parent a5068be commit 6ab4479

File tree

4 files changed

+156
-4
lines changed

4 files changed

+156
-4
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# Changelog
22

3+
## Version 1.1.0 (TBD)
4+
5+
### Added
6+
7+
- **Internal link rewriting** - Automatically converts markdown file links to section navigation
8+
- Links to files that are included as sections (e.g., `[CHANGELOG](CHANGELOG.md)`) are rewritten to section anchors (`#changelog`)
9+
- Preserves external links and links to files that aren't sections
10+
- Centralized hash link monitoring with Alpine.js integration
11+
- Clicking any hash link now properly triggers section switching without page reloads
12+
- Added 4 tests for link rewriting functionality in `test_builder.py`
13+
314
## Version 1.0.0 (2025-11-13)
415

516
🎉 **First stable release!** Microdocs is now production-ready with comprehensive test coverage, CI/CD workflows, and a stable API.

microdocs/builder.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from __future__ import annotations
99

1010
import html
11+
import re
1112
import sys
1213
from datetime import UTC, datetime
1314
from pathlib import Path
@@ -93,6 +94,37 @@ def convert_plain_text_to_html(text_content: str) -> str:
9394
return f"<div>{html_with_breaks}</div>"
9495

9596

97+
def rewrite_internal_links(html_content: str, section_ids: set[str]) -> str:
98+
"""
99+
Rewrite links to markdown files into section navigation links.
100+
101+
If a link points to a file that exists as a section (e.g., CHANGELOG.md),
102+
rewrite it to navigate to that section instead of loading the file.
103+
104+
Args:
105+
html_content: HTML content with links
106+
section_ids: Set of section IDs (lowercased file stems)
107+
108+
Returns:
109+
HTML with rewritten internal links
110+
111+
"""
112+
113+
def replace_link(match: re.Match[str]) -> str:
114+
href = match.group(1)
115+
# Check if link is to a markdown file
116+
if href.endswith((".md", ".markdown")):
117+
# Extract the stem (filename without extension) and lowercase it
118+
link_stem = Path(href).stem.lower()
119+
# If this matches a section ID, rewrite to section link
120+
if link_stem in section_ids:
121+
return f'href="#{link_stem}"'
122+
return match.group(0)
123+
124+
# Match href attributes in anchor tags
125+
return re.sub(r'href="([^"]+)"', replace_link, html_content)
126+
127+
96128
def build_documentation(
97129
input_files: Sequence[Path],
98130
output_path: Path,
@@ -134,6 +166,9 @@ def build_documentation(
134166
sys.stdout.write(f"Reading CSS from {css_path}\n")
135167
inlined_css = css_path.read_text(encoding="utf-8")
136168

169+
# First pass: collect section IDs
170+
section_ids = {input_file.stem.lower() for input_file in input_files}
171+
137172
# Process each markdown file
138173
converted_sections = []
139174
extracted_title = None
@@ -156,6 +191,9 @@ def build_documentation(
156191
sys.stdout.write(f"Converting {input_file.name} as plain text...\n")
157192
html_content = convert_plain_text_to_html(file_content)
158193

194+
# Rewrite internal links to point to sections instead of files
195+
html_content = rewrite_internal_links(html_content, section_ids)
196+
159197
# Build section data
160198
section_id = input_file.stem.lower()
161199
converted_sections.append(

microdocs/templates/default.html

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,29 @@
3131
document.documentElement.setAttribute('data-theme', value);
3232
});
3333
document.documentElement.setAttribute('data-theme', theme);
34+
35+
// Monitor hash link clicks and update activeSection
36+
document.addEventListener('click', (e) => {
37+
const link = e.target.closest('a[href^=\'#\']');
38+
if (link) {
39+
const hash = link.getAttribute('href');
40+
const sectionId = hash.substring(1);
41+
const sectionIds = [{% for section in sections %}'{{ section.id }}'{% if not loop.last %}, {% endif %}{% endfor %}];
42+
if (sectionIds.includes(sectionId)) {
43+
e.preventDefault();
44+
activeSection = sectionId;
45+
}
46+
}
47+
});
3448
">
3549
<!-- Header -->
3650
<header class="z-50 sticky top-0 bg-doc-bg border-b border-doc-border">
3751
<div class="max-w-5xl mx-auto px-4 py-3 sm:px-6 sm:py-4">
3852
<!-- Desktop Layout -->
3953
<div class="hidden sm:flex sm:gap-8 sm:items-center">
4054
<!-- Logo/Title -->
41-
<h1 class="font-heading font-semibold text-doc-heading text-xl cursor-pointer" @click="activeSection = '{{ sections[0].id }}'">
42-
<a @click="activeSection = '{{ sections[0].id }}'" href="">{{ title }}</a>
55+
<h1 class="font-heading font-semibold text-doc-heading text-xl">
56+
<a href="#{{ sections[0].id }}" class="cursor-pointer">{{ title }}</a>
4357
</h1>
4458

4559
<!-- Navigation -->
@@ -86,8 +100,8 @@ <h1 class="font-heading font-semibold text-doc-heading text-xl cursor-pointer" @
86100
<div class="sm:hidden">
87101
<!-- Top Row: Title and Icons -->
88102
<div class="flex gap-4 items-center justify-between mb-3">
89-
<h1 class="font-heading font-semibold text-doc-heading text-lg cursor-pointer" @click="activeSection = '{{ sections[0].id }}'">
90-
<a @click="activeSection = '{{ sections[0].id }}'" href=".">{{ title }}</a>
103+
<h1 class="font-heading font-semibold text-doc-heading text-lg">
104+
<a href="#{{ sections[0].id }}" class="cursor-pointer">{{ title }}</a>
91105
</h1>
92106

93107
<div class="flex shrink-0 gap-3 items-center">

microdocs/tests/test_builder.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,3 +406,92 @@ def test_build_documentation_file_not_found(temp_output_path: Path) -> None:
406406
input_files=[Path("/nonexistent/file.md")],
407407
output_path=temp_output_path,
408408
)
409+
410+
411+
# Tests for rewrite_internal_links
412+
413+
414+
def test_rewrite_internal_links_rewrites_section_links(tmp_path: Path) -> None:
415+
"""Test that links to markdown files that are sections get rewritten."""
416+
readme = tmp_path / "README.md"
417+
readme.write_text(
418+
"# Main\n\nSee the [CHANGELOG](CHANGELOG.md) for details.",
419+
encoding="utf-8",
420+
)
421+
422+
changelog = tmp_path / "CHANGELOG.md"
423+
changelog.write_text("# Changelog\n\n## v1.0", encoding="utf-8")
424+
425+
output = tmp_path / "output.html"
426+
build_documentation(
427+
input_files=[readme, changelog],
428+
output_path=output,
429+
)
430+
431+
content = output.read_text(encoding="utf-8")
432+
# Link should be rewritten to section anchor
433+
assert 'href="#changelog"' in content
434+
# Original file link should not be present
435+
assert "CHANGELOG.md" not in content or 'href="#changelog"' in content
436+
437+
438+
def test_rewrite_internal_links_preserves_external_links(tmp_path: Path) -> None:
439+
"""Test that external links are not rewritten."""
440+
readme = tmp_path / "README.md"
441+
readme.write_text(
442+
"# Main\n\nVisit [GitHub](https://github.com)",
443+
encoding="utf-8",
444+
)
445+
446+
output = tmp_path / "output.html"
447+
build_documentation(
448+
input_files=[readme],
449+
output_path=output,
450+
)
451+
452+
content = output.read_text(encoding="utf-8")
453+
# External link should remain unchanged
454+
assert 'href="https://github.com"' in content
455+
456+
457+
def test_rewrite_internal_links_preserves_non_section_markdown_links(
458+
tmp_path: Path,
459+
) -> None:
460+
"""Test that links to markdown files that aren't sections remain unchanged."""
461+
readme = tmp_path / "README.md"
462+
readme.write_text(
463+
"# Main\n\nSee [other doc](other.md)",
464+
encoding="utf-8",
465+
)
466+
467+
output = tmp_path / "output.html"
468+
build_documentation(
469+
input_files=[readme],
470+
output_path=output,
471+
)
472+
473+
content = output.read_text(encoding="utf-8")
474+
# Link to non-section file should remain as-is
475+
assert 'href="other.md"' in content
476+
477+
478+
def test_rewrite_internal_links_case_insensitive(tmp_path: Path) -> None:
479+
"""Test that link rewriting is case-insensitive."""
480+
readme = tmp_path / "README.md"
481+
readme.write_text(
482+
"# Main\n\nSee [Action](ACTION.md)",
483+
encoding="utf-8",
484+
)
485+
486+
action = tmp_path / "ACTION.md"
487+
action.write_text("# Action\n\nDetails", encoding="utf-8")
488+
489+
output = tmp_path / "output.html"
490+
build_documentation(
491+
input_files=[readme, action],
492+
output_path=output,
493+
)
494+
495+
content = output.read_text(encoding="utf-8")
496+
# Link should be rewritten to lowercase section anchor
497+
assert 'href="#action"' in content

0 commit comments

Comments
 (0)