mirror of
https://github.com/jackyzha0/quartz.git
synced 2026-03-21 21:45:42 -05:00
fix: ObsidianHighlight plugin for nested ==highlight== syntax
Handles ==***text***== where remark-parse splits == markers across separate text nodes due to emphasis processing order. Fix by splitting == suffix/prefix from text nodes before pairing open/close highlight markers and wrapping in <span>.
This commit is contained in:
parent
9576701d85
commit
1248e7fc01
@ -66,6 +66,8 @@ const config: QuartzConfig = {
|
|||||||
},
|
},
|
||||||
keepBackground: false,
|
keepBackground: false,
|
||||||
}),
|
}),
|
||||||
|
Plugin.ObsidianHighlight(),
|
||||||
|
|
||||||
Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }),
|
Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }),
|
||||||
Plugin.GitHubFlavoredMarkdown(),
|
Plugin.GitHubFlavoredMarkdown(),
|
||||||
Plugin.TableOfContents(),
|
Plugin.TableOfContents(),
|
||||||
|
|||||||
@ -11,3 +11,4 @@ export { SyntaxHighlighting } from "./syntax"
|
|||||||
export { TableOfContents } from "./toc"
|
export { TableOfContents } from "./toc"
|
||||||
export { HardLineBreaks } from "./linebreaks"
|
export { HardLineBreaks } from "./linebreaks"
|
||||||
export { RoamFlavoredMarkdown } from "./roam"
|
export { RoamFlavoredMarkdown } from "./roam"
|
||||||
|
export { ObsidianHighlight } from "./obsidian-highlight"
|
||||||
|
|||||||
44
quartz/plugins/transformers/obsidian-highlight.md
Normal file
44
quartz/plugins/transformers/obsidian-highlight.md
Normal file
@ -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==` → `<span class="text-highlight">text</span>`
|
||||||
|
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 `<span>` |
|
||||||
|
|
||||||
|
## 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: "#fff23688" },
|
||||||
|
darkMode: { textHighlight: "#b3aa0288" },
|
||||||
|
}
|
||||||
|
```
|
||||||
147
quartz/plugins/transformers/obsidian-highlight.ts
Normal file
147
quartz/plugins/transformers/obsidian-highlight.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
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: `<span class="text-highlight">...</span>`
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
/** CSS class name for the highlight span. Default: "text-highlight" */
|
||||||
|
highlightClass: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOptions: Options = {
|
||||||
|
highlightClass: "text-highlight",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ObsidianHighlight: QuartzTransformerPlugin<Partial<Options>> = (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)
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user