Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/formats/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions src/formats/html/heading-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
export function fixDocumentHeadingLinks(el: Element) {
const headingTextById = new Map<string, string>();
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, string>): 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;
}
}
77 changes: 77 additions & 0 deletions tests/html/heading-links.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>,
public textContent: string = ''
) {}

getAttribute(name: string) {
return this.attrs[name] ?? null;
}

setAttribute(name: string, value: string) {
this.attrs[name] = value;
}
}