From 6da7fa67867ae2f40ffa8d92d14f69a730e19cb8 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 13 Aug 2026 15:24:52 -0400 Subject: [PATCH] fix(wikilinks): supporte les ancres internes [[#Titre|Texte]] et corrige les IDs de titres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ajoute la reconnaissance de la syntaxe Obsidian [[#Titre|Texte]] pour créer des liens d'ancre vers les titres du document courant. - Corrige _heading_slugify pour ignorer les balises HTML et décoder les entités HTML (ex. &) avant de générer l'ID, évitant ainsi la pollution par les wikilinks placés dans les titres. - Ajoute le style .wikilink-anchor et des tests couvrant ces cas. Fixe le rendu du document TestDir/Agents IA - Panorama Complet 2026.md. --- backend/main.py | 31 +++++++++++++++++++++++++------ frontend/style.css | 9 +++++++-- tests/test_api_main.py | 20 ++++++++++++++++++++ 3 files changed, 52 insertions(+), 8 deletions(-) diff --git a/backend/main.py b/backend/main.py index a3a4af7..15a327b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -824,17 +824,23 @@ def _heading_slugify(text: str) -> str: Matches the JavaScript slugify algorithm exactly using Unicode-aware character classification: - 1. Lowercase - 2. NFD normalize + strip combining marks - 3. Keep only Unicode letters, numbers, spaces, hyphens - 4. Replace spaces with hyphens, collapse multiple hyphens + 1. Strip HTML tags (e.g. wikilink spans rendered inside headings) + 2. Decode HTML entities (e.g. ``&`` → ``&``) + 3. Lowercase + 4. NFD normalize + strip combining marks + 5. Keep only Unicode letters, numbers, spaces, hyphens + 6. Replace spaces with hyphens, collapse multiple hyphens Args: - text: The heading text content. + text: The heading text content (may contain inline HTML). Returns: A URL-safe slug string. """ + # Strip any inline HTML so it does not pollute the slug + text = re.sub(r"<[^>]+>", "", text) + # Decode HTML entities so & becomes & before slugification + text = html_mod.unescape(text) text = text.lower() text = unicodedata.normalize("NFD", text) text = "".join(ch for ch in text if not unicodedata.combining(ch)) @@ -894,7 +900,12 @@ _markdown_renderer = mistune.create_markdown( def _convert_wikilinks(content: str, current_vault: str) -> str: """Convert ``[[wikilinks]]`` and ``[[target|display]]`` to clickable HTML. - Resolved links get a ``data-vault`` / ``data-path`` attribute pair. + Supports: + - Internal file links: ``[[My Note]]`` / ``[[My Note|display]]`` + - Same-document anchors: ``[[#Heading]]`` / ``[[#Heading|display]]`` + + Resolved file links get a ``data-vault`` / ``data-path`` attribute pair. + Anchor links target the slugified heading ID in the current document. Unresolved links are rendered as ````. Args: @@ -907,6 +918,14 @@ def _convert_wikilinks(content: str, current_vault: str) -> str: def _replace(match): target = match.group(1).strip() display = match.group(2).strip() if match.group(2) else target + + # Same-document anchor link: [[#Heading|display]] + if target.startswith("#"): + anchor_text = target[1:].strip() + anchor_slug = _heading_slugify(anchor_text) + link_display = display if display != target else anchor_text + return f'{link_display}' + found = find_file_in_index(target, current_vault) if found: return ( diff --git a/frontend/style.css b/frontend/style.css index ca69ad1..e35c761 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -2076,14 +2076,19 @@ select { } /* Wikilinks */ -.wikilink { +.wikilink, +.wikilink-anchor { color: var(--accent); border-bottom: 1px dashed var(--accent); cursor: pointer; } -.wikilink:hover { +.wikilink:hover, +.wikilink-anchor:hover { opacity: 0.8; } +.wikilink-anchor { + text-decoration: none; +} .wikilink-missing { color: var(--text-muted); border-bottom: 1px dashed var(--text-muted); diff --git a/tests/test_api_main.py b/tests/test_api_main.py index f6fb126..8674348 100644 --- a/tests/test_api_main.py +++ b/tests/test_api_main.py @@ -655,6 +655,26 @@ class TestConvertWikilinks: result = _convert_wikilinks("See ![[image.png]] here", "TestVault") assert "image" in result or "img" in result + def test_convert_wikilink_anchor(self): + from backend.main import _convert_wikilinks + result = _convert_wikilinks("[[#Section importante|voir section]]", "TestVault") + assert 'href="#section-importante"' in result + assert "wikilink-anchor" in result + assert "voir section" in result + + def test_convert_wikilink_anchor_without_alias(self): + from backend.main import _convert_wikilinks + result = _convert_wikilinks("[[#Claude Code]]", "TestVault") + assert 'href="#claude-code"' in result + assert "Claude Code" in result + + +class TestHeadingSlugifyHtmlStripping: + def test_slugify_strips_html_tags(self): + from backend.main import _heading_slugify + result = _heading_slugify('## 1. Agents installés localement ↩') + assert result == "1-agents-installes-localement" + # ═══════════════════════════════════════════════════════════════════ # Secret Redactor