Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 65 additions & 13 deletions quartz/plugins/transformers/ofm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
DefinitionContent,
Paragraph,
Code,
Parent,
} from "mdast"
import { Element, Literal, Root as HtmlRoot } from "hast"
import { ReplaceFunction, findAndReplace as mdastFindReplace } from "mdast-util-find-and-replace"
Expand Down Expand Up @@ -100,6 +101,19 @@ const arrowMapping: Record<string, string> = {
"<=": "&lArr;",
"<==": "&lArr;",
}
// Marker node used for highlighting
interface HighlightMarker {
type: "highlightMarker"
}

function isHighlightMarker(node: unknown): node is HighlightMarker {
return (
typeof node === "object" &&
node !== null &&
"type" in node &&
(node as { type: unknown }).type === "highlightMarker"
)
}

function canonicalizeCallout(calloutName: string): keyof typeof calloutMapping {
const normalizedCallout = calloutName.toLowerCase() as keyof typeof calloutMapping
Expand Down Expand Up @@ -128,7 +142,7 @@ export const tableRegex = new RegExp(/^\|([^\n])+\|\n(\|)( ?:?-{3,}:? ?\|)+\n(\|
// matches any wikilink, only used for escaping wikilinks inside tables
export const tableWikilinkRegex = new RegExp(/(!?\[\[[^\]]*?\]\]|\[\^[^\]]*?\])/g)

const highlightRegex = new RegExp(/==([^=]+)==/g)
const highlightRegex = new RegExp(/==/g)
const commentRegex = new RegExp(/%%[\s\S]*?%%/g)
// from https://github.com/escwxyz/remark-obsidian-callout/blob/main/src/index.ts
const calloutRegex = new RegExp(/^\[\!([\w-]+)\|?(.+?)?\]([+-]?)/)
Expand Down Expand Up @@ -306,18 +320,7 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
])
}

if (opts.highlight) {
replacements.push([
highlightRegex,
(_value: string, ...capture: string[]) => {
const [inner] = capture
return {
type: "html",
value: `<span class="text-highlight">${inner}</span>`,
}
},
])
}
// Highlight logic is handled separately below (lines 385-430) to support nested markdown

if (opts.parseArrows) {
replacements.push([
Expand Down Expand Up @@ -393,6 +396,55 @@ export const ObsidianFlavoredMarkdown: QuartzTransformerPlugin<Partial<Options>>
}
})

if (opts.highlight) {
plugins.push(() => {
return (tree: Root) => {
mdastFindReplace(tree, [
[highlightRegex, (_value: string) => ({ type: "highlightMarker" }) as any],
Copy link

Copilot AI Nov 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The as any type assertion bypasses TypeScript's type safety. Consider defining a proper interface for the custom highlightMarker node type to maintain type safety throughout the transformation pipeline.

interface HighlightMarker {
  type: "highlightMarker"
}

// Then use:
[highlightRegex, (_value: string): HighlightMarker => ({ type: "highlightMarker" })]
Suggested change
[highlightRegex, (_value: string) => ({ type: "highlightMarker" }) as any],
[highlightRegex, (_value: string): HighlightMarker => ({ type: "highlightMarker" })],

Copilot uses AI. Check for mistakes.
])

visit(tree, (node) => {
if (!("children" in node)) return
const parent = node as Parent
const children = parent.children
if (children.length === 0) return

const markers: number[] = []
for (let i = 0; i < children.length; i++) {
if (isHighlightMarker(children[i])) {
markers.push(i)
}
}

if (markers.length < 2) return

Copy link

Copilot AI Nov 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When there's an odd number of == markers, the last unpaired marker will be silently ignored. Consider adding a warning or handling this case explicitly to help users identify unclosed highlight markers in their content. For example:

if (markers.length < 2) return

// Check for odd number of markers
if (markers.length % 2 !== 0) {
  // Option 1: Log a warning
  console.warn(`Unpaired highlight marker found in content`)
  // Option 2: Remove the last unpaired marker
  children.splice(markers[markers.length - 1], 1)
}
Suggested change
// Check for odd number of markers
if (markers.length % 2 !== 0) {
console.warn("Unpaired highlight marker found in content");
// Remove the last unpaired marker
children.splice(markers[markers.length - 1], 1);
// Remove the last marker from the list
markers.pop();
}

Copilot uses AI. Check for mistakes.
const pairs: [number, number][] = []
for (let i = 0; i < markers.length - 1; i += 2) {
pairs.push([markers[i], markers[i + 1]])
}

Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When there is an odd number of highlight markers (e.g., ==text1== some text ==text2), the last unpaired marker remains in the AST as a highlightMarker node. This could cause rendering issues or appear as unexpected content in the output.

Consider adding logic to handle odd markers, such as:

  • Removing unpaired markers after processing pairs
  • Converting unpaired markers back to their original == text
  • Logging a warning about malformed highlight syntax

This would make the behavior more robust and predictable when users have malformed markup.

Suggested change
if (markers.length % 2 === 1) {
const lastMarkerIndex = markers[markers.length - 1]
// Convert the unpaired marker back into its literal representation
children[lastMarkerIndex] = { type: "text", value: "==" } as any
}

Copilot uses AI. Check for mistakes.
for (let i = pairs.length - 1; i >= 0; i--) {
const [start, end] = pairs[i]
const content = children.slice(start + 1, end)
const htmlContent = content
.map((n) => {
const hast = toHast(n, { allowDangerousHtml: true })
return hast ? toHtml(hast, { allowDangerousHtml: true }) : ""
})
.join("")

const newNode: Html = {
type: "html",
value: `<span class="text-highlight">${htmlContent}</span>`,
}

children.splice(start, end - start + 1, newNode)
}
})
Comment on lines +406 to +443
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation only matches highlight markers within a single parent node's immediate children. If a highlight spans across different structural boundaries (e.g., across list items or block quotes), the opening and closing markers won't be paired, leaving unpaired markers in the output.

While this may be acceptable for typical use cases (highlights within paragraphs), it's worth noting this limitation. Consider adding a comment explaining that highlights must be contained within a single parent node, or alternatively, implement a more sophisticated cross-boundary matching algorithm if this is a common use case.

Copilot uses AI. Check for mistakes.
}
})
}

if (opts.enableVideoEmbed) {
plugins.push(() => {
return (tree: Root, _file) => {
Expand Down
Loading