diff --git a/src/formats/html.ts b/src/formats/html.ts index 539f94ff..8601fba0 100644 --- a/src/formats/html.ts +++ b/src/formats/html.ts @@ -11,6 +11,7 @@ import { FormatImporter } from '../format-importer'; import { ImportContext } from '../main'; import { extensionForMime } from '../mime'; import { parseHTML, stringToUtf8 } from '../util'; +import { fixDocumentHeadingLinks } from './html/heading-links'; export class HtmlImporter extends FormatImporter { attachmentSizeLimit: number; @@ -166,6 +167,7 @@ export class HtmlImporter extends FormatImporter { const htmlContent = await file.readText(); const dom = parseHTML(htmlContent); + fixDocumentHeadingLinks(dom); fixDocumentUrls(dom); // Find all the attachments and download them diff --git a/src/formats/html/heading-links.ts b/src/formats/html/heading-links.ts new file mode 100644 index 00000000..292019ea --- /dev/null +++ b/src/formats/html/heading-links.ts @@ -0,0 +1,46 @@ +export function fixDocumentHeadingLinks(el: Element) { + const headingTextById = new Map(); + for (const heading of el.findAll('h1, h2, h3, h4, h5, h6')) { + const id = heading.getAttribute('id'); + const headingText = normalizeHeadingText(heading.textContent ?? ''); + if (id && headingText && !headingTextById.has(id)) { + headingTextById.set(id, headingText); + } + } + if (headingTextById.size === 0) return; + + for (const anchor of el.findAll('a')) { + const href = anchor.getAttribute('href'); + if (href === null) continue; + + const rewrittenHref = rewriteSameDocumentHeadingHref(href, headingTextById); + if (rewrittenHref !== null) { + anchor.setAttribute('href', rewrittenHref); + } + } +} + +export function rewriteSameDocumentHeadingHref(href: string, headingTextById: ReadonlyMap): string | null { + if (!href.startsWith('#') || href === '#') return null; + + const fragment = href.slice(1); + const headingText = headingTextById.get(fragment) + ?? headingTextById.get(safeDecodeURIComponent(fragment)); + if (!headingText) return null; + + const rewrittenHref = `#${headingText}`; + return rewrittenHref === href ? null : rewrittenHref; +} + +function normalizeHeadingText(text: string) { + return text.replace(/\s+/gu, ' ').trim(); +} + +function safeDecodeURIComponent(value: string) { + try { + return decodeURIComponent(value); + } + catch { + return value; + } +} diff --git a/tests/html/heading-links.test.ts b/tests/html/heading-links.test.ts new file mode 100644 index 00000000..ea084d90 --- /dev/null +++ b/tests/html/heading-links.test.ts @@ -0,0 +1,77 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { fixDocumentHeadingLinks, rewriteSameDocumentHeadingHref } from '../../src/formats/html/heading-links'; + +const headingTextById = new Map([ + ['lexing', 'Lexing'], + ['lexical-analysis', '1.1 - Lexical Analysis'], + ['start-of-a-repl', '1.5 - Start of a REPL'], + ['encoded heading', 'Encoded Heading'], +]); + +test('rewrites same-document heading id links to Obsidian heading text links', () => { + assert.equal( + rewriteSameDocumentHeadingHref('#lexical-analysis', headingTextById), + '#1.1 - Lexical Analysis' + ); + assert.equal( + rewriteSameDocumentHeadingHref('#start-of-a-repl', headingTextById), + '#1.5 - Start of a REPL' + ); +}); + +test('rewrites single-word heading ids to their exact heading text', () => { + assert.equal(rewriteSameDocumentHeadingHref('#lexing', headingTextById), '#Lexing'); +}); + +test('looks up percent-encoded same-document heading ids', () => { + assert.equal(rewriteSameDocumentHeadingHref('#encoded%20heading', headingTextById), '#Encoded Heading'); +}); + +test('leaves non-heading and non-local hashes unchanged', () => { + assert.equal(rewriteSameDocumentHeadingHref('#missing', headingTextById), null); + assert.equal(rewriteSameDocumentHeadingHref('#', headingTextById), null); + assert.equal(rewriteSameDocumentHeadingHref('book.html#lexical-analysis', headingTextById), null); + assert.equal(rewriteSameDocumentHeadingHref('https://example.com#lexical-analysis', headingTextById), null); +}); + +test('updates same-document HTML anchors using matching heading ids', () => { + const heading = new TestElement({ id: 'lexical-analysis' }, '1.1 - Lexical Analysis'); + const localAnchor = new TestElement({ href: '#lexical-analysis' }); + const externalAnchor = new TestElement({ href: 'book.html#lexical-analysis' }); + const root = new TestRoot([heading], [localAnchor, externalAnchor]); + + fixDocumentHeadingLinks(root as unknown as Element); + + assert.equal(localAnchor.getAttribute('href'), '#1.1 - Lexical Analysis'); + assert.equal(externalAnchor.getAttribute('href'), 'book.html#lexical-analysis'); +}); + +class TestRoot { + constructor( + private headings: TestElement[], + private anchors: TestElement[] + ) {} + + findAll(selector: string) { + if (selector === 'h1, h2, h3, h4, h5, h6') return this.headings; + if (selector === 'a') return this.anchors; + return []; + } +} + +class TestElement { + constructor( + private attrs: Record, + public textContent: string = '' + ) {} + + getAttribute(name: string) { + return this.attrs[name] ?? null; + } + + setAttribute(name: string, value: string) { + this.attrs[name] = value; + } +}