feat: add disableBrokenWikilinks option

When enabled, internal links whose resolved slug is not present in
ctx.allSlugs gain a 'broken' CSS class alongside 'internal' so they
can be styled distinctly. Applies to both wikilinks (after they've
been converted to <a> elements by ObsidianFlavoredMarkdown) and
regular markdown links, since both are indistinguishable <a> nodes
by the time this plugin runs.

This check lives here rather than in ObsidianFlavoredMarkdown because
CrawlLinks already owns link resolution and computes the canonical
slug, so the broken-link verdict uses the same resolution logic that
determines the actual href. This eliminates the drift risk where
two plugins might disagree about what 'broken' means.

Default is false (preserves existing behavior). Covered by 15 new
tests exercising absolute/relative/shortest strategies, anchor/alias
handling, external/intra-document link scopes, and empty allSlugs.
This commit is contained in:
saberzero1 2026-04-16 22:19:29 +02:00
parent 330dc10398
commit 4f17471903
No known key found for this signature in database
8 changed files with 227 additions and 10 deletions

View file

@ -10,3 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Initial Quartz community plugin template.
- `disableBrokenWikilinks` option: when enabled, internal links whose resolved slug is not present in `ctx.allSlugs` gain a `broken` CSS class alongside `internal` so broken links can be styled distinctly. The check lives here (rather than in `ObsidianFlavoredMarkdown`) because this plugin owns link resolution and already computes the canonical slug.
### Changed
- **BREAKING (inherited from `@quartz-community/utils`)**: internal link resolution is now case-insensitive. Links resolve to lowercased slugs regardless of how they're typed (`[[MyNote]]`, `[[mynote]]`, `[[MYNOTE]]` all produce href `my-note`). Matches Obsidian's link-matching behavior.

View file

@ -30,13 +30,14 @@ ExternalPlugin.CrawlLinks({
## Configuration
| Option | Type | Default | Description |
| ------------------------ | ---------------------------------------- | ------------ | ------------------------------------- |
| `markdownLinkResolution` | `"absolute" \| "relative" \| "shortest"` | `"absolute"` | How to resolve internal links. |
| `prettyLinks` | `boolean` | `true` | Whether to use pretty links. |
| `openLinksInNewTab` | `boolean` | `false` | Whether to open links in a new tab. |
| `lazyLoad` | `boolean` | `false` | Whether to lazy load links. |
| `externalLinkIcon` | `boolean` | `true` | Whether to add an external link icon. |
| Option | Type | Default | Description |
| ------------------------ | ---------------------------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| `markdownLinkResolution` | `"absolute" \| "relative" \| "shortest"` | `"absolute"` | How to resolve internal links. |
| `prettyLinks` | `boolean` | `true` | Whether to use pretty links. |
| `openLinksInNewTab` | `boolean` | `false` | Whether to open links in a new tab. |
| `lazyLoad` | `boolean` | `false` | Whether to lazy load links. |
| `externalLinkIcon` | `boolean` | `true` | Whether to add an external link icon. |
| `disableBrokenWikilinks` | `boolean` | `false` | When `true`, internal links whose resolved slug is not in `ctx.allSlugs` gain a `broken` CSS class (alongside `internal`) for styling. |
## Documentation

9
dist/index.d.ts vendored
View file

@ -10,6 +10,15 @@ interface CrawlLinksOptions {
openLinksInNewTab: boolean;
lazyLoad: boolean;
externalLinkIcon: boolean;
/**
* When `true`, internal links whose resolved slug is not present in
* `ctx.allSlugs` gain a `broken` CSS class (alongside `internal`) so
* broken links can be styled distinctly. Applies to both wikilinks
* (after they've been converted to `<a>` elements by
* ObsidianFlavoredMarkdown) and markdown links, since both are
* indistinguishable `<a>` nodes at this phase of the pipeline.
*/
disableBrokenWikilinks: boolean;
}
declare const CrawlLinks: QuartzTransformerPlugin<Partial<CrawlLinksOptions>>;

7
dist/index.js vendored
View file

@ -221,7 +221,8 @@ var defaultOptions = {
prettyLinks: true,
openLinksInNewTab: false,
lazyLoad: false,
externalLinkIcon: true
externalLinkIcon: true,
disableBrokenWikilinks: false
};
var isAbsoluteUrlWithOptions = isAbsoluteUrl;
var CrawlLinks = (userOpts) => {
@ -289,6 +290,10 @@ var CrawlLinks = (userOpts) => {
const simple = simplifySlug(full);
outgoing.add(simple);
node.properties["data-slug"] = full;
if (opts.disableBrokenWikilinks && !ctx.allSlugs.includes(full)) {
classes.push("broken");
node.properties.className = classes;
}
}
if (opts.prettyLinks && isInternal && node.children.length === 1) {
const textChild = node.children[0];

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

View file

@ -91,7 +91,8 @@
"defaultOrder": 60,
"defaultEnabled": true,
"defaultOptions": {
"markdownLinkResolution": "shortest"
"markdownLinkResolution": "shortest",
"disableBrokenWikilinks": false
},
"optionSchema": {
"markdownLinkResolution": {
@ -101,6 +102,10 @@
"absolute",
"relative"
]
},
"disableBrokenWikilinks": {
"type": "boolean",
"description": "When true, internal links whose resolved slug is not in ctx.allSlugs gain a 'broken' CSS class (alongside 'internal') for styling."
}
}
}

View file

@ -15,6 +15,15 @@ export interface CrawlLinksOptions {
openLinksInNewTab: boolean;
lazyLoad: boolean;
externalLinkIcon: boolean;
/**
* When `true`, internal links whose resolved slug is not present in
* `ctx.allSlugs` gain a `broken` CSS class (alongside `internal`) so
* broken links can be styled distinctly. Applies to both wikilinks
* (after they've been converted to `<a>` elements by
* ObsidianFlavoredMarkdown) and markdown links, since both are
* indistinguishable `<a>` nodes at this phase of the pipeline.
*/
disableBrokenWikilinks: boolean;
}
const defaultOptions: CrawlLinksOptions = {
@ -23,6 +32,7 @@ const defaultOptions: CrawlLinksOptions = {
openLinksInNewTab: false,
lazyLoad: false,
externalLinkIcon: true,
disableBrokenWikilinks: false,
};
const isAbsoluteUrlWithOptions = isAbsoluteUrl as (
@ -122,6 +132,11 @@ export const CrawlLinks: QuartzTransformerPlugin<Partial<CrawlLinksOptions>> = (
const simple = simplifySlug(full);
outgoing.add(simple);
node.properties["data-slug"] = full;
if (opts.disableBrokenWikilinks && !ctx.allSlugs.includes(full)) {
classes.push("broken");
node.properties.className = classes;
}
}
// rewrite link internals if prettylinks is on

177
test/broken-links.test.ts Normal file
View file

@ -0,0 +1,177 @@
import { describe, it, expect } from "vitest";
import type { Root, Element } from "hast";
import { VFile } from "vfile";
import type { BuildCtx } from "@quartz-community/types";
import type { FullSlug } from "@quartz-community/utils";
import { CrawlLinks } from "../src/transformer";
import type { CrawlLinksOptions } from "../src/transformer";
function makeAnchorTree(href: string, text = "link"): Root {
const link: Element = {
type: "element",
tagName: "a",
properties: { href },
children: [{ type: "text", value: text }],
};
return { type: "root", children: [link] };
}
function runTransform(
tree: Root,
opts: Partial<CrawlLinksOptions>,
allSlugs: string[],
fileSlug: string,
): Element {
const plugin = CrawlLinks(opts);
const ctx = { allSlugs } as unknown as BuildCtx;
const plugins = plugin.htmlPlugins!(ctx);
const factory = plugins[0] as () => (tree: Root, file: VFile) => void;
const transformer = factory();
const file = new VFile();
file.data.slug = fileSlug as FullSlug;
transformer(tree, file);
return tree.children[0] as Element;
}
function hasClass(el: Element, className: string): boolean {
const classes = (el.properties?.className ?? []) as string[];
return classes.includes(className);
}
describe("disableBrokenWikilinks - default (off)", () => {
it("does not add 'broken' class to unresolved internal links when option is off", () => {
const tree = makeAnchorTree("missing");
const link = runTransform(tree, { disableBrokenWikilinks: false }, [], "test/page");
expect(hasClass(link, "internal")).toBe(true);
expect(hasClass(link, "broken")).toBe(false);
});
});
describe("disableBrokenWikilinks - shortest strategy", () => {
const opts: Partial<CrawlLinksOptions> = {
disableBrokenWikilinks: true,
markdownLinkResolution: "shortest",
};
const allSlugs = ["a/b/c", "a/b/d", "a/b/index", "e/f", "e/g/h", "index"];
const fileSlug = "a/b/c";
it("does not mark a bare filename that resolves via basename as broken", () => {
const tree = makeAnchorTree("d");
const link = runTransform(tree, opts, allSlugs, fileSlug);
expect(hasClass(link, "broken")).toBe(false);
});
it("does not mark a multi-segment partial path that resolves via suffix as broken", () => {
const tree = makeAnchorTree("g/h");
const link = runTransform(tree, opts, allSlugs, fileSlug);
expect(hasClass(link, "broken")).toBe(false);
});
it("does not mark an absolute path that exists in allSlugs as broken", () => {
const tree = makeAnchorTree("e/f");
const link = runTransform(tree, opts, allSlugs, fileSlug);
expect(hasClass(link, "broken")).toBe(false);
});
it("marks a completely missing wikilink as broken", () => {
const tree = makeAnchorTree("does-not-exist");
const link = runTransform(tree, opts, allSlugs, fileSlug);
expect(hasClass(link, "internal")).toBe(true);
expect(hasClass(link, "broken")).toBe(true);
});
it("marks a missing multi-segment path as broken", () => {
const tree = makeAnchorTree("x/y/z");
const link = runTransform(tree, opts, allSlugs, fileSlug);
expect(hasClass(link, "broken")).toBe(true);
});
});
describe("disableBrokenWikilinks - absolute strategy", () => {
const opts: Partial<CrawlLinksOptions> = {
disableBrokenWikilinks: true,
markdownLinkResolution: "absolute",
};
const allSlugs = ["a/b/c", "a/b/d", "e/f"];
const fileSlug = "a/b/c";
it("marks a bare filename as broken under absolute resolution", () => {
const tree = makeAnchorTree("d");
const link = runTransform(tree, opts, allSlugs, fileSlug);
expect(hasClass(link, "broken")).toBe(true);
});
it("does not mark a valid absolute path as broken", () => {
const tree = makeAnchorTree("a/b/d");
const link = runTransform(tree, opts, allSlugs, fileSlug);
expect(hasClass(link, "broken")).toBe(false);
});
it("marks a missing absolute path as broken", () => {
const tree = makeAnchorTree("nowhere/at/all");
const link = runTransform(tree, opts, allSlugs, fileSlug);
expect(hasClass(link, "broken")).toBe(true);
});
});
describe("disableBrokenWikilinks - relative strategy", () => {
const opts: Partial<CrawlLinksOptions> = {
disableBrokenWikilinks: true,
markdownLinkResolution: "relative",
};
const allSlugs = ["a/b/c", "a/b/d", "a/sibling"];
const fileSlug = "a/b/c";
it("does not mark a valid same-folder sibling as broken", () => {
const tree = makeAnchorTree("d");
const link = runTransform(tree, opts, allSlugs, fileSlug);
expect(hasClass(link, "broken")).toBe(false);
});
it("does not mark a valid parent-folder link via ../ as broken", () => {
const tree = makeAnchorTree("../sibling");
const link = runTransform(tree, opts, allSlugs, fileSlug);
expect(hasClass(link, "broken")).toBe(false);
});
it("marks an unresolved relative link as broken", () => {
const tree = makeAnchorTree("../nonexistent");
const link = runTransform(tree, opts, allSlugs, fileSlug);
expect(hasClass(link, "broken")).toBe(true);
});
});
describe("disableBrokenWikilinks - scope", () => {
const opts: Partial<CrawlLinksOptions> = {
disableBrokenWikilinks: true,
markdownLinkResolution: "shortest",
};
it("does not mark external links as broken", () => {
const tree = makeAnchorTree("https://example.com");
const link = runTransform(tree, opts, [], "test/page");
expect(hasClass(link, "external")).toBe(true);
expect(hasClass(link, "broken")).toBe(false);
});
it("does not mark intra-document anchor links as broken", () => {
const tree = makeAnchorTree("#section");
const link = runTransform(tree, opts, [], "test/page");
expect(hasClass(link, "broken")).toBe(false);
});
});
describe("disableBrokenWikilinks - empty allSlugs", () => {
it("marks every internal link as broken when allSlugs is empty", () => {
const tree = makeAnchorTree("any-page");
const link = runTransform(
tree,
{ disableBrokenWikilinks: true, markdownLinkResolution: "shortest" },
[],
"test/page",
);
expect(hasClass(link, "broken")).toBe(true);
});
});