diff --git a/quartz.config.ts b/quartz.config.ts index b3db3d60d..99ea4faad 100644 --- a/quartz.config.ts +++ b/quartz.config.ts @@ -66,6 +66,8 @@ const config: QuartzConfig = { }, keepBackground: false, }), + Plugin.ObsidianHighlight(), + Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }), Plugin.GitHubFlavoredMarkdown(), Plugin.TableOfContents(), diff --git a/quartz/plugins/transformers/index.ts b/quartz/plugins/transformers/index.ts index 8e2cd844f..94320a6fc 100644 --- a/quartz/plugins/transformers/index.ts +++ b/quartz/plugins/transformers/index.ts @@ -1,4 +1,5 @@ export { FrontMatter } from "./frontmatter" +export { ObsidianHighlight } from "./obsidian-highlight" export { GitHubFlavoredMarkdown } from "./gfm" export { Citations } from "./citations" export { CreatedModifiedDate } from "./lastmod" diff --git a/quartz/plugins/transformers/obsidian-highlight.md b/quartz/plugins/transformers/obsidian-highlight.md new file mode 100644 index 000000000..fb94c076a --- /dev/null +++ b/quartz/plugins/transformers/obsidian-highlight.md @@ -0,0 +1,44 @@ +# ObsidianHighlight + +Handles Obsidian's `==highlight==` syntax at the rehype (HTML AST) level, including nested emphasis like `==***text***==`. + +## Why a rehype plugin? + +Remark-parse processes emphasis (`***`) **before** the OFM plugin's markdown-level highlight regex runs, splitting `==` markers into separate text nodes. This plugin runs after `remarkRehype` and handles both cases: + +1. **Simple:** `==text==` → `text` +2. **Nested:** `==***text***==` → matches `==` as sibling text nodes with inline elements between them + +## Usage + +```ts +// quartz.config.ts +import { ObsidianHighlight } from "./quartz/plugins/transformers/obsidian-highlight" + +Plugin.ObsidianHighlight() +``` + +## Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `highlightClass` | `string` | `"text-highlight"` | CSS class for the highlight `` | + +## Example + +```ts +// Custom class +Plugin.ObsidianHighlight({ highlightClass: "mark" }) +``` + +## CSS + +The output uses the `--textHighlight` CSS variable defined in `quartz.config.ts` theme colors. Override via: + +```ts +// quartz.config.ts +colors: { + lightMode: { textHighlight: "#84a59d88" }, + darkMode: { textHighlight: "#84a59d88" }, +} +``` diff --git a/quartz/plugins/transformers/obsidian-highlight.ts b/quartz/plugins/transformers/obsidian-highlight.ts new file mode 100644 index 000000000..b133a0f1a --- /dev/null +++ b/quartz/plugins/transformers/obsidian-highlight.ts @@ -0,0 +1,139 @@ +import { QuartzTransformerPlugin } from "../types" +import { Element, Text, Root, RootContent } from "hast" + +/** + * ObsidianHighlight handles Obsidian's ==highlight== syntax at the rehype + * (HTML AST) level. This is necessary because remark-parse processes + * emphasis (`***`) before the OFM plugin's markdown-level highlight regex + * can run, splitting `==` markers into separate text nodes. + * + * This plugin runs AFTER remarkRehype and handles two cases: + * 1. Simple: `==text==` all in one text node + * 2. Nested: `==***text***==` where emphasis creates sibling nodes + * between the `==` markers + * + * Output: `...` + */ + +interface Options { + /** CSS class name for the highlight span. Default: "text-highlight" */ + highlightClass: string +} + +const defaultOptions: Options = { + highlightClass: "text-highlight", +} + +export const ObsidianHighlight: QuartzTransformerPlugin> = (userOpts) => { + const opts = { ...defaultOptions, ...userOpts } + const HIGHLIGHT_REGEX = /==([^=]+)==/g + + function walk(node: Element | Root) { + if (!node?.children) return + + let i = 0 + while (i < node.children.length) { + const child = node.children[i] + + // Case 1: ==text== in a single text node + if (child.type === "text" && (child as Text).value.includes("==")) { + const val = (child as Text).value + HIGHLIGHT_REGEX.lastIndex = 0 + if (HIGHLIGHT_REGEX.test(val)) { + const parts: RootContent[] = [] + let lastIndex = 0 + let match: RegExpExecArray | null + HIGHLIGHT_REGEX.lastIndex = 0 + while ((match = HIGHLIGHT_REGEX.exec(val)) !== null) { + if (match.index > lastIndex) { + parts.push({ type: "text", value: val.slice(lastIndex, match.index) }) + } + parts.push({ + type: "element", + tagName: "span", + properties: { className: [opts.highlightClass] }, + children: [{ type: "text", value: match[1] }], + }) + lastIndex = match.index + match[0].length + } + if (lastIndex < val.length) { + parts.push({ type: "text", value: val.slice(lastIndex) }) + } + node.children.splice(i, 1, ...parts) + i += parts.length - 1 + continue + } + } + + // Case 2: == at start/end with elements between (e.g. ==***text***==) + // First, split any "==" suffix/prefix from text nodes into separate nodes + // so that all "==" markers are standalone text nodes + if (child.type === "text" && (child as Text).value.endsWith("==") && (child as Text).value.trim() !== "==") { + const val = (child as Text).value + const prefix = val.slice(0, -2) + const parts: RootContent[] = [] + if (prefix) parts.push({ type: "text", value: prefix }) + parts.push({ type: "text", value: "==" }) + node.children.splice(i, 1, ...parts) + i += parts.length - 1 + continue + } + // Split "==text" prefix (e.g. "==text" at end of content before emphasis) + // This handles the case where text before emphasis starts with "==" + if (child.type === "text" && (child as Text).value.startsWith("==") && (child as Text).value.trim() !== "==") { + const val = (child as Text).value + const suffix = val.slice(2) + const parts: RootContent[] = [] + parts.push({ type: "text", value: "==" }) + if (suffix) parts.push({ type: "text", value: suffix }) + node.children.splice(i, 1, ...parts) + i += parts.length - 1 + continue + } + + // Handle standalone "==" markers with elements between them (e.g. ==***text***==) + // This runs after the splitting above, so all "==" markers are now standalone nodes + if (child.type === "text" && (child as Text).value.trim() === "==") { + for (let j = i + 2; j < node.children.length; j++) { + const endChild = node.children[j] + if (endChild.type === "text" && (endChild as Text).value.trim() === "==") { + const between = node.children.slice(i + 1, j) + if (between.length === 0) break + + const highlightChildren: RootContent[] = [] + const startBefore = (child as Text).value.replace("==", "") + if (startBefore) highlightChildren.push({ type: "text", value: startBefore }) + highlightChildren.push(...(between as RootContent[])) + const endAfter = (endChild as Text).value.replace("==", "") + if (endAfter) highlightChildren.push({ type: "text", value: endAfter }) + + node.children.splice(i, j - i + 1, { + type: "element", + tagName: "span", + properties: { className: [opts.highlightClass] }, + children: highlightChildren, + } as Element) + break + } + } + } + + // Recurse into child elements + if (child.type === "element") { + walk(child as Element) + } + i++ + } + } + + return { + name: "ObsidianHighlight", + htmlPlugins() { + return [ + () => (tree: Root) => { + if (tree) walk(tree) + }, + ] + }, + } +}