diff --git a/quartz/processors/parse.ts b/quartz/processors/parse.ts index b5e9df10a18df..8cde5f06f37dc 100644 --- a/quartz/processors/parse.ts +++ b/quartz/processors/parse.ts @@ -14,6 +14,7 @@ import { QuartzLogger } from "../util/log" import { trace } from "../util/trace" import { BuildCtx, WorkerSerializableBuildCtx } from "../util/ctx" import { styleText } from "util" +import { stripCalloutTitleContinuationsInMarkdown } from "../util/calloutTitle" export type QuartzMdProcessor = Processor export type QuartzHtmlProcessor = Processor @@ -99,6 +100,8 @@ export function createFileParser(ctx: BuildCtx, fps: FilePath[]) { file.value = plugin.textTransform!(ctx, file.value.toString()) } + file.value = stripCalloutTitleContinuationsInMarkdown(file.value.toString()) + // base data properties that plugins may use file.data.filePath = file.path as FilePath file.data.relativePath = path.posix.relative(argv.directory, file.path) as FilePath diff --git a/quartz/util/calloutTitle.test.ts b/quartz/util/calloutTitle.test.ts new file mode 100644 index 0000000000000..a53136e4b5e64 --- /dev/null +++ b/quartz/util/calloutTitle.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert" +import test, { describe } from "node:test" +import { + stripCalloutTitleContinuation, + stripCalloutTitleContinuationsInMarkdown, +} from "./calloutTitle" + +describe("stripCalloutTitleContinuation", () => { + test("drops a trailing continuation backslash", () => { + assert.equal(stripCalloutTitleContinuation(" 提示\\"), " 提示") + assert.equal(stripCalloutTitleContinuation("提示\\"), "提示") + assert.equal(stripCalloutTitleContinuation("\\"), "") + }) + + test("leaves titles without a trailing backslash alone", () => { + assert.equal(stripCalloutTitleContinuation(" 提示"), " 提示") + assert.equal(stripCalloutTitleContinuation("C:\\Windows"), "C:\\Windows") + }) +}) + +describe("stripCalloutTitleContinuationsInMarkdown", () => { + test("strips INFO title continuation and keeps the body line", () => { + const src = "> [!INFO] 提示\\\n> 这里是正文\n" + const out = stripCalloutTitleContinuationsInMarkdown(src) + assert.equal(out, "> [!INFO] 提示\n> 这里是正文\n") + }) + + test("strips foldable and nested openers", () => { + assert.equal( + stripCalloutTitleContinuationsInMarkdown("> [!TIP]- 标题\\"), + "> [!TIP]- 标题", + ) + assert.equal( + stripCalloutTitleContinuationsInMarkdown("> > [!NOTE] x\\"), + "> > [!NOTE] x", + ) + }) + + test("does not touch ordinary quotes or mid-title backslashes", () => { + assert.equal( + stripCalloutTitleContinuationsInMarkdown("> quote\\"), + "> quote\\", + ) + assert.equal( + stripCalloutTitleContinuationsInMarkdown("> [!NOTE] C:\\Windows"), + "> [!NOTE] C:\\Windows", + ) + }) + + test("empty title leftover slash becomes default-title case", () => { + assert.equal(stripCalloutTitleContinuationsInMarkdown("> [!INFO]\\"), "> [!INFO]") + }) +}) diff --git a/quartz/util/calloutTitle.ts b/quartz/util/calloutTitle.ts new file mode 100644 index 0000000000000..d863beca3db9c --- /dev/null +++ b/quartz/util/calloutTitle.ts @@ -0,0 +1,23 @@ +export function stripCalloutTitleContinuation(title: string): string { + if (!/(?:\\[ \t]*)+$/.test(title)) { + return title + } + return title.replace(/(?:\\[ \t]*)+$/u, "").replace(/[ \t]+$/u, "") +} + +const CALLOUT_OPEN = /^((?:>[ \t]*)+\[![\w-]+(?:\|[^\]]+)?\][+-]?)(.*)$/ + +export function stripCalloutTitleContinuationsInMarkdown(src: string): string { + const newline = src.includes("\r\n") ? "\r\n" : src.includes("\r") ? "\r" : "\n" + return src + .split(/\r?\n/) + .map((line) => { + const match = line.match(CALLOUT_OPEN) + if (!match) { + return line + } + const [, directive, rest] = match + return `${directive}${stripCalloutTitleContinuation(rest ?? "")}` + }) + .join(newline) +}