fix: slugify wikilinks in frontmatter to prevent %20 in hrefs

Wikilinks appearing in frontmatter property strings (e.g. 'related:
"[[My Note]]"') were rendered with their raw target passed
directly to resolveRelative, leaving the space intact. The browser
then percent-encoded it as %20 in the rendered href, producing
broken links like './My%20Note' that never resolve.

The root cause was in renderTextWithLinks (NoteProperties.tsx):
the matched wikilink target went straight into resolveRelative
without going through slugifyFilePath, so spaces, ampersands, and
other characters that should be slugified survived into the href.
extractLinksFromValue (transformer.ts) had the same issue: it
stored raw target strings in file.data.frontmatterLinks instead
of canonical slugs.

Adds a shared slugifyWikilinkTarget helper in util/path.ts that
mirrors the slug+anchor contract CrawlLinks uses for body-content
links, so frontmatter wikilinks and body wikilinks now produce
identical hrefs. Both NoteProperties.tsx and the transformer's
extractLinksFromValue now go through this helper.

Also replaces the plugin's local slugTag/slugifyFilePath/
getFileExtension helpers with the shared @quartz-community/utils
versions. This eliminates slugification drift between plugins and
ensures that note-properties honors the same case-insensitive
matching that the rest of the Quartz ecosystem uses.

BREAKING CHANGE: tag URLs and frontmatter-wikilink slugs are now
lowercased (inherited from utils' case-insensitive matching).
Tags #MyTag, #mytag, and #MYTAG collapse into a single tag page
tags/mytag.
This commit is contained in:
saberzero1 2026-04-16 22:22:10 +02:00
parent ed851f3380
commit 3539cf1e6a
No known key found for this signature in database
11 changed files with 193 additions and 75 deletions

View file

@ -10,3 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Initial Quartz community plugin template.
### Fixed
- Frontmatter wikilinks containing spaces are now correctly slugified instead of rendering with `%20` in the href. `[[My Note]]` in a frontmatter property now renders as `./my-note` instead of `./My%20Note`.
### Changed
- **BREAKING (inherited from `@quartz-community/utils`)**: tag URLs and frontmatter-wikilink slugs are now lowercased to match Obsidian's case-insensitive matching. Tags `#MyTag`, `#mytag`, and `#MYTAG` collapse into a single tag page `tags/mytag`.
- Replaced the plugin's local slugification helpers with the shared `@quartz-community/utils` versions to eliminate drift across the Quartz ecosystem.

View file

@ -1,6 +1,6 @@
import { createRequire } from 'module';
import { classNames } from '@quartz-community/utils/lang';
import { joinSegments, simplifySlug as simplifySlug$1 } from '@quartz-community/utils';
import { joinSegments, simplifySlug as simplifySlug$1, splitAnchor, slugifyFilePath } from '@quartz-community/utils';
import { jsxs, jsx, Fragment } from 'preact/jsx-runtime';
createRequire(import.meta.url);
@ -12,6 +12,13 @@ function resolveRelative(current, target) {
const rootPath = pathToRoot(current);
return joinSegments(rootPath, simplified);
}
function slugifyWikilinkTarget(target) {
const [rawPath, anchor] = splitAnchor(target);
if (!rawPath) return anchor;
const pathWithExt = rawPath.endsWith(".md") ? rawPath : `${rawPath}.md`;
const slug = slugifyFilePath(pathWithExt);
return slug + anchor;
}
function pathToRoot(slug) {
let rootPath = slug.split("/").filter((x) => x !== "").slice(0, -1).map((_) => "..").join("/");
if (rootPath.length === 0) {
@ -50,7 +57,7 @@ function renderTextWithLinks(text, ctx) {
for (const match of text.matchAll(WIKILINK_RE)) {
const target = match[1];
const display = match[2] ?? target;
const href = resolveRelative(ctx.slug, target);
const href = resolveRelative(ctx.slug, slugifyWikilinkTarget(target));
segments.push({
start: match.index,
end: match.index + match[0].length,

File diff suppressed because one or more lines are too long

62
dist/index.js vendored
View file

@ -1,6 +1,6 @@
import { createRequire } from 'module';
import { slugTag, getFileExtension, slugifyFilePath, splitAnchor, joinSegments, simplifySlug as simplifySlug$1 } from '@quartz-community/utils';
import { classNames } from '@quartz-community/utils/lang';
import { joinSegments, simplifySlug as simplifySlug$1 } from '@quartz-community/utils';
import { jsxs, jsx, Fragment } from 'preact/jsx-runtime';
const require$1 = createRequire(import.meta.url);
@ -10268,6 +10268,30 @@ var jsYaml = {
// src/transformer.ts
var import_toml = __toESM(require_toml());
function simplifySlug(fp) {
return simplifySlug$1(fp);
}
function resolveRelative(current, target) {
const simplified = simplifySlug(target);
const rootPath = pathToRoot(current);
return joinSegments(rootPath, simplified);
}
function slugifyWikilinkTarget(target) {
const [rawPath, anchor] = splitAnchor(target);
if (!rawPath) return anchor;
const pathWithExt = rawPath.endsWith(".md") ? rawPath : `${rawPath}.md`;
const slug = slugifyFilePath(pathWithExt);
return slug + anchor;
}
function pathToRoot(slug) {
let rootPath = slug.split("/").filter((x) => x !== "").slice(0, -1).map((_) => "..").join("/");
if (rootPath.length === 0) {
rootPath = ".";
}
return rootPath;
}
// src/transformer.ts
var defaultOptions = {
includeAll: false,
includedProperties: ["description", "tags", "aliases"],
@ -10288,24 +10312,9 @@ function coerceToArray(input) {
}
return input.filter((v) => typeof v === "string" || typeof v === "number").map((v) => v.toString());
}
function slugTag(tag) {
return tag.split("/").map(
(segment) => segment.replace(/\s+/g, "-").replace(/[^\w\p{L}\p{M}\p{N}\p{Extended_Pictographic}/-]/gu, "").toLowerCase()
).join("/");
}
function getFileExtension(fp) {
return fp.split(".").pop() ?? "";
}
function slugifyFilePath(fp) {
fp = fp.replace(/\\/g, "/");
fp = fp.replace(/\.md$/, "");
let slug = fp.split("/").map((segment) => segment.replace(/\s+/g, "-").replace(/[^\w\p{L}\p{M}\p{N}/-]/gu, "")).join("/");
slug = slug.replace(/\/$/, "");
return slug;
}
function getAliasSlugs(aliases) {
return aliases.map((alias) => {
const isMd = getFileExtension(alias) === "md";
const isMd = getFileExtension(alias) === ".md";
const mockFp = isMd ? alias : alias + ".md";
return slugifyFilePath(mockFp);
});
@ -10318,7 +10327,7 @@ function extractLinksFromValue(value2) {
let match;
WIKILINK_PATTERN.lastIndex = 0;
while ((match = WIKILINK_PATTERN.exec(value2)) !== null) {
links.push(match[1]);
links.push(slugifyWikilinkTarget(match[1]));
}
MDLINK_PATTERN.lastIndex = 0;
while ((match = MDLINK_PATTERN.exec(value2)) !== null) {
@ -10454,21 +10463,6 @@ var NoteProperties = (userOpts) => {
}
};
};
function simplifySlug(fp) {
return simplifySlug$1(fp);
}
function resolveRelative(current, target) {
const simplified = simplifySlug(target);
const rootPath = pathToRoot(current);
return joinSegments(rootPath, simplified);
}
function pathToRoot(slug) {
let rootPath = slug.split("/").filter((x) => x !== "").slice(0, -1).map((_) => "..").join("/");
if (rootPath.length === 0) {
rootPath = ".";
}
return rootPath;
}
// src/i18n/locales/en-US.ts
var en_US_default = {
@ -10500,7 +10494,7 @@ function renderTextWithLinks(text, ctx) {
for (const match of text.matchAll(WIKILINK_RE)) {
const target = match[1];
const display = match[2] ?? target;
const href = resolveRelative(ctx.slug, target);
const href = resolveRelative(ctx.slug, slugifyWikilinkTarget(target));
segments.push({
start: match.index,
end: match.index + match[0].length,

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

View file

@ -4,7 +4,7 @@ import type {
QuartzComponentConstructor,
} from "@quartz-community/types";
import { classNames } from "../util/lang";
import { resolveRelative } from "../util/path";
import { resolveRelative, slugifyWikilinkTarget } from "../util/path";
import { i18n } from "../i18n";
import style from "./styles/noteProperties.scss";
// @ts-expect-error - inline script import handled by Quartz bundler
@ -25,7 +25,7 @@ function renderTextWithLinks(text: string, ctx: RenderCtx): (preact.JSX.Element
for (const match of text.matchAll(WIKILINK_RE)) {
const target = match[1]!;
const display = match[2] ?? target;
const href = resolveRelative(ctx.slug, target);
const href = resolveRelative(ctx.slug, slugifyWikilinkTarget(target));
segments.push({
start: match.index,
end: match.index + match[0].length,

View file

@ -9,6 +9,8 @@ import type {
FullSlug,
FilePath,
} from "@quartz-community/types";
import { slugTag, slugifyFilePath, getFileExtension } from "@quartz-community/utils";
import { slugifyWikilinkTarget } from "./util/path";
import type { NotePropertiesOptions } from "./types";
const defaultOptions: NotePropertiesOptions = {
@ -40,44 +42,15 @@ function coerceToArray(input: unknown): string[] | undefined {
.map((v: string | number) => v.toString());
}
function slugTag(tag: string): string {
return tag
.split("/")
.map((segment) =>
segment
.replace(/\s+/g, "-")
.replace(/[^\w\p{L}\p{M}\p{N}\p{Extended_Pictographic}/-]/gu, "")
.toLowerCase(),
)
.join("/");
}
function getFileExtension(fp: string): string {
return fp.split(".").pop() ?? "";
}
function slugifyFilePath(fp: string): FullSlug {
fp = fp.replace(/\\/g, "/");
fp = fp.replace(/\.md$/, "");
let slug = fp
.split("/")
.map((segment) => segment.replace(/\s+/g, "-").replace(/[^\w\p{L}\p{M}\p{N}/-]/gu, ""))
.join("/");
slug = slug.replace(/\/$/, "");
return slug as FullSlug;
}
function getAliasSlugs(aliases: string[]): FullSlug[] {
return aliases.map((alias) => {
const isMd = getFileExtension(alias) === "md";
const isMd = getFileExtension(alias) === ".md";
const mockFp = isMd ? alias : alias + ".md";
return slugifyFilePath(mockFp as FilePath);
});
}
// Wikilink pattern: [[target|display]] or [[target]]
const WIKILINK_PATTERN = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
// Markdown link pattern: [text](target)
const MDLINK_PATTERN = /\[(?:[^\]]*)\]\(([^)]+)\)/g;
function extractLinksFromValue(value: unknown): string[] {
@ -87,7 +60,7 @@ function extractLinksFromValue(value: unknown): string[] {
WIKILINK_PATTERN.lastIndex = 0;
while ((match = WIKILINK_PATTERN.exec(value)) !== null) {
links.push(match[1]!);
links.push(slugifyWikilinkTarget(match[1]!));
}
MDLINK_PATTERN.lastIndex = 0;

View file

@ -1,4 +1,10 @@
import { simplifySlug as utilSimplifySlug, joinSegments } from "@quartz-community/utils";
import {
simplifySlug as utilSimplifySlug,
joinSegments,
slugifyFilePath,
splitAnchor,
} from "@quartz-community/utils";
import type { FilePath } from "@quartz-community/types";
export function simplifySlug(fp: string): string {
return utilSimplifySlug(fp);
@ -10,6 +16,19 @@ export function resolveRelative(current: string, target: string): string {
return joinSegments(rootPath, simplified);
}
/**
* Convert a wikilink target like `"My Note#Section"` into the canonical slug
* form `"My-Note#section"` so links rendered from frontmatter match the slugs
* CrawlLinks produces for body links. Keeps the anchor after `#` intact.
*/
export function slugifyWikilinkTarget(target: string): string {
const [rawPath, anchor] = splitAnchor(target);
if (!rawPath) return anchor;
const pathWithExt = rawPath.endsWith(".md") ? rawPath : `${rawPath}.md`;
const slug = slugifyFilePath(pathWithExt as FilePath);
return slug + anchor;
}
function pathToRoot(slug: string): string {
let rootPath = slug
.split("/")

View file

@ -0,0 +1,74 @@
import { describe, it, expect } from "vitest";
import { VFile } from "vfile";
import { NoteProperties } from "../src/transformer";
import type { BuildCtx } from "@quartz-community/types";
function runWithFrontmatter(frontmatter: string): string[] {
const plugin = NoteProperties({});
const ctx = { allSlugs: [] } as unknown as BuildCtx;
const plugins = plugin.markdownPlugins!(ctx);
const transformerFactory = plugins[1] as () => (tree: unknown, file: VFile) => void;
const transformer = transformerFactory();
const markdown = `---\n${frontmatter}\n---\ncontent`;
const file = new VFile({
value: new TextEncoder().encode(markdown),
path: "note.md",
});
file.data = {};
transformer(null, file);
return (file.data.frontmatterLinks as string[] | undefined) ?? [];
}
describe("frontmatterLinks extraction", () => {
it("slugifies wikilinks with spaces", () => {
const links = runWithFrontmatter('related: "[[My Note]]"');
expect(links).toEqual(["my-note"]);
});
it("never leaves spaces or %20 in the extracted wikilink", () => {
const links = runWithFrontmatter('related: "[[My Note]]"');
for (const link of links) {
expect(link).not.toContain(" ");
expect(link).not.toContain("%20");
}
});
it("preserves and slugifies anchors on wikilinks", () => {
const links = runWithFrontmatter('related: "[[My Note#Some Section]]"');
expect(links).toEqual(["my-note#some-section"]);
});
it("slugifies nested-path wikilinks per segment", () => {
const links = runWithFrontmatter('related: "[[Folder Name/My Note]]"');
expect(links).toEqual(["folder-name/my-note"]);
});
it("strips the .md extension on wikilinks that include it", () => {
const links = runWithFrontmatter('related: "[[My Note.md]]"');
expect(links).toEqual(["my-note"]);
});
it("extracts wikilinks from array values", () => {
const links = runWithFrontmatter(
"related:\n" + ' - "[[First Note]]"\n' + ' - "[[Second Note]]"',
);
expect(links).toEqual(["first-note", "second-note"]);
});
it("leaves markdown-link targets untouched (user-provided hrefs)", () => {
const links = runWithFrontmatter('link: "[label](./some/path)"');
expect(links).toEqual(["./some/path"]);
});
it("drops the alias portion and only stores the target", () => {
const links = runWithFrontmatter('related: "[[My Note|Display Text]]"');
expect(links).toEqual(["my-note"]);
});
it("does not set frontmatterLinks when frontmatter has no links", () => {
const links = runWithFrontmatter('title: "Plain Title"');
expect(links).toEqual([]);
});
});

View file

@ -32,15 +32,15 @@ describe("slugTag", () => {
expect(runWithTag("my tag")).toEqual(["my-tag"]);
});
it("strips special characters", () => {
expect(runWithTag("tag?#&")).toEqual(["tag"]);
it("replaces '&' with '-and-' and strips '?' and '#'", () => {
expect(runWithTag("tag?#&")).toEqual(["tag-and-"]);
});
it("keeps nested tag separators", () => {
expect(runWithTag("parent/child")).toEqual(["parent/child"]);
});
it("lowercases tags", () => {
it("lowercases tags (Obsidian-parity case-insensitive tag matching)", () => {
expect(runWithTag("MyTag")).toEqual(["mytag"]);
});

View file

@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import { slugifyWikilinkTarget } from "../src/util/path";
describe("slugifyWikilinkTarget", () => {
it("converts spaces in the path to hyphens", () => {
expect(slugifyWikilinkTarget("My Note")).toBe("my-note");
});
it("lowercases case in the path (Obsidian-parity case-insensitive matching)", () => {
expect(slugifyWikilinkTarget("CamelCase")).toBe("camelcase");
});
it("strips the trailing .md extension", () => {
expect(slugifyWikilinkTarget("My Note.md")).toBe("my-note");
});
it("slugifies a nested path per segment", () => {
expect(slugifyWikilinkTarget("Folder Name/My Note")).toBe("folder-name/my-note");
});
it("preserves and slugifies the anchor after '#'", () => {
expect(slugifyWikilinkTarget("My Note#Some Section")).toBe("my-note#some-section");
});
it("returns the slugified anchor alone when path is empty", () => {
expect(slugifyWikilinkTarget("#Just An Anchor")).toBe("#just-an-anchor");
});
it("handles targets with no spaces unchanged", () => {
expect(slugifyWikilinkTarget("already-slugged")).toBe("already-slugged");
});
it("replaces '&' with '-and-'", () => {
expect(slugifyWikilinkTarget("Foo&Bar")).toBe("foo-and-bar");
});
it("never produces '%20' for a target that contains a space", () => {
const result = slugifyWikilinkTarget("My Note");
expect(result).not.toContain("%20");
expect(result).not.toContain(" ");
});
});