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;
+ }
+}