mirror of
https://github.com/quartz-community/crawl-links.git
synced 2026-07-22 02:50:29 +00:00
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:
parent
330dc10398
commit
4f17471903
8 changed files with 227 additions and 10 deletions
|
|
@ -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.
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -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
9
dist/index.d.ts
vendored
|
|
@ -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
7
dist/index.js
vendored
|
|
@ -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
2
dist/index.js.map
vendored
File diff suppressed because one or more lines are too long
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
177
test/broken-links.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue