Some links are incorrectly removed when their target path partially matches the page's path
Summary
Same-site links can be incorrectly deleted from extracted content when their target path partially matches the page's path. The check also ignores the host, so a link to a different site can be deleted whenever its target path prefixes the page's path. The link text goes with it, leaving a gap mid-sentence and no indication that anything was removed.
Context
Originally seen using:
- Obsidian Web Clipper: 1.7.1
- Firefox: 156.0a1 (2026-08-25)
- macOS: 26.6.2 (25G83)
Reproduced using:
defuddle0.19.3linkedomas the DOM backend
Background
The Obsidian Web Clipper was used to save a page who's URL looked like:
https://pages.example.com/acmelabs/posts/12345a link within the document, in this case referencing a different host, was incorrectly removed:
https://social.example.net/acmeExpected Behaviour
Links within prose should be preserved unless they are section breadcrumbs or back-navigation links; the link above is neither.
The docblock for the "section breadcrumb" rule in removeByContentPattern (src/removals/content-patterns.ts) states:
// Remove section breadcrumbs and back-navigation links.
// Matches short elements (div, span, p) containing a link to a parent path,
// and bare <a> elements used as standalone back links (e.g. "← back", "↑ index").
// Two parent-link patterns are recognized:
// 1. Direct prefix: linkPath is a path prefix of the current URL
// e.g. current=/blog/2024/post, link=/blog/ or /blog
// ...So a link should only be removed when its target path is made up of complete segments of the page's path: /blog and /blog/2024 are parents of /blog/2024/post, /blog/20 is not. It should also be on the same host, though the docblock does not mention this explicitly.
Actual Behaviour
The link was removed, along with its text.
Steps to Reproduce
In an empty directory install the two packages:
npm install defuddle linkedomSave this as
example.mjs:import { parseHTML } from 'linkedom'; import { Defuddle } from 'defuddle/node'; const url = 'https://pages.example.com/acmelabs/posts/12345'; const filler = '<p>Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua ut enim ad minim veniam quis nostrud exercitation ullamco laboris.</p>'; const html = `<!DOCTYPE html><html><head><title>Repro</title></head><body><article> <h1>Repro</h1> ${filler.repeat(3)} <p>A paragraph long enough to exceed the rule's ten-word limit, ending with links to <span><a href="https://social.example.net/acme">our profile</a></span> and <span><a href="https://pages.example.com/acme">our other posts</a></span>.</p> ${filler.repeat(3)} </article></body></html>`; const { document } = parseHTML(html); const result = await Defuddle(document, url, { separateMarkdown: true }); console.log('off-site link kept:', result.content.includes('social.example.net/acme')); console.log('same-site link kept:', result.content.includes('our other posts')); console.log(result.contentMarkdown.split('\n').find(line => line.includes('ten-word limit')));Run it:
node example.mjsOutput:
off-site link kept: false same-site link kept: false A paragraph long enough to exceed the rule's ten-word limit, ending with links to and .Both
<span>s, both links, and the words "our profile" and "our other posts" are all gone, leaving the sentence ending on a bare full stop.
Two markup conditions are needed to reach the rule, which is why the issue doesn't always present: the link must sit inside a wrapper element (a bare <a> in flowing prose is exempted by the rule's own closest('p') guard), and the paragraph must exceed ten words (a shorter one is itself matched and removed whole, so the symptom looks like a missing paragraph rather than a missing link).
Root Cause
In src/removals/content-patterns.ts ~L800:
const linkPath = new URL(link.getAttribute('href') || '', url).pathname;
// ...
if (linkPath !== '/' && linkPath !== urlPath && (urlPath.startsWith(linkPath) || isParentIndex)) {
el.remove();
}Two guards are missing, and the example above hits both:
1. The host is never compared. linkPath is only a pathname, so a link to any site is eligible. pageHost is already computed a few lines above and goes unused until the trailing external-link rule further down.
2. The prefix has no segment boundary. urlPath.startsWith(linkPath) matches mid-segment, so /blog/20 counts as a parent of /blog/2024/post. A prefix should count only when the next character is /, or when linkPath already ends in one. The other branch, isParentIndex, strips back to the last / to build a directory path, so it already ends at a separator and is unaffected.
They are independent: fixing only the host still deletes a same-site link to /acme from a page at /acmelabs/posts/12345 — the second link in the example above.
Next Steps
I have a fix for both guards — one commit each, so either can be dropped — and will open a PR against main.
Searched open and closed issues and PRs for breadcrumb, "section breadcrumb", removeByContentPattern, startsWith and pathname prefix before filing; the nearest matches (#107, #134, #228, #252) all seem to be different mechanisms.
Source: kepano/defuddle