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)
+ },
+ ]
+ },
+ }
+}