mirror of
https://github.com/quartz-community/note-properties.git
synced 2026-07-22 02:50:26 +00:00
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:
parent
ed851f3380
commit
3539cf1e6a
11 changed files with 193 additions and 75 deletions
|
|
@ -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.
|
||||
|
|
|
|||
11
dist/components/index.js
vendored
11
dist/components/index.js
vendored
|
|
@ -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,
|
||||
|
|
|
|||
2
dist/components/index.js.map
vendored
2
dist/components/index.js.map
vendored
File diff suppressed because one or more lines are too long
62
dist/index.js
vendored
62
dist/index.js
vendored
|
|
@ -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
2
dist/index.js.map
vendored
File diff suppressed because one or more lines are too long
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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("/")
|
||||
|
|
|
|||
74
test/frontmatterLinks.test.ts
Normal file
74
test/frontmatterLinks.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
|
||||
|
|
|
|||
42
test/slugifyWikilinkTarget.test.ts
Normal file
42
test/slugifyWikilinkTarget.test.ts
Normal 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(" ");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue