|
|
@@ -1,841 +0,0 @@
|
|
|
-/**
|
|
|
- * Rule-based markdown parser architecture.
|
|
|
- *
|
|
|
- * Pipeline: Lezer Parser → AST Visitor → Rule Registry → Decoration Extraction
|
|
|
- *
|
|
|
- * Three rule categories:
|
|
|
- * - Inline AST rules (emphasis, links) — matched via Lezer node types
|
|
|
- * - Leaf AST rules (tags, refs) — matched via Lezer node types
|
|
|
- * - Line/block regex rules (task, heading) — matched via line patterns
|
|
|
- */
|
|
|
-
|
|
|
-import type { Parser, SyntaxNode, TreeCursor } from "@lezer/common"
|
|
|
-
|
|
|
-// ── Context ──────────────────────────────────────────────────────────
|
|
|
-
|
|
|
-export interface ParseContext {
|
|
|
- doc: string
|
|
|
- cursorPos?: number
|
|
|
-}
|
|
|
-
|
|
|
-// ── Unified DecorationRange ──────────────────────────────────────────
|
|
|
-
|
|
|
-export interface DecorationRange {
|
|
|
- type: "hidden" | "content"
|
|
|
- from: number
|
|
|
- to: number
|
|
|
- className?: string
|
|
|
-}
|
|
|
-
|
|
|
-// ── Result types ─────────────────────────────────────────────────────
|
|
|
-
|
|
|
-/**
|
|
|
- * Base interface for all parsed tokens.
|
|
|
- * Each rule provides its own wrapperAttrs so the decoration builder
|
|
|
- * can render without type-switching.
|
|
|
- */
|
|
|
-export interface ParsedToken {
|
|
|
- type: string
|
|
|
- decorationRanges: DecorationRange[]
|
|
|
- wrapperAttrs?: Record<string, string>
|
|
|
-}
|
|
|
-
|
|
|
-export interface TokenDecoration extends ParsedToken {
|
|
|
- type: string
|
|
|
-}
|
|
|
-
|
|
|
-export interface PageRefDecoration extends ParsedToken {
|
|
|
- type: "page-ref"
|
|
|
- pageName: string
|
|
|
- alias?: string
|
|
|
-}
|
|
|
-
|
|
|
-export interface BlockRefDecoration extends ParsedToken {
|
|
|
- type: "block-ref"
|
|
|
- blockId: string
|
|
|
-}
|
|
|
-
|
|
|
-export interface TagDecoration extends ParsedToken {
|
|
|
- type: "tag"
|
|
|
- tag: string
|
|
|
-}
|
|
|
-
|
|
|
-export interface HighlightDecoration extends ParsedToken {
|
|
|
- type: "highlight"
|
|
|
-}
|
|
|
-
|
|
|
-export interface PropertyInfo {
|
|
|
- key: string
|
|
|
- value: string
|
|
|
- keyRange: [number, number]
|
|
|
- delimiterRange: [number, number]
|
|
|
- valueRange: [number, number]
|
|
|
-}
|
|
|
-
|
|
|
-export type TaskState =
|
|
|
- | "TODO"
|
|
|
- | "DOING"
|
|
|
- | "DONE"
|
|
|
- | "LATER"
|
|
|
- | "NOW"
|
|
|
- | "WAITING"
|
|
|
- | "CANCELLED"
|
|
|
-export type PriorityLevel = "A" | "B" | "C"
|
|
|
-export type CalloutType = "NOTE" | "WARNING" | "TIP" | "DANGER" | "INFO"
|
|
|
-
|
|
|
-export interface BlockDecoration extends ParsedToken {
|
|
|
- type: "heading" | "task" | "priority" | "blockquote" | "callout" | "date"
|
|
|
- metadata?: {
|
|
|
- level?: number
|
|
|
- taskState?: TaskState
|
|
|
- priority?: PriorityLevel
|
|
|
- calloutType?: CalloutType
|
|
|
- date?: string
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-export interface ParsedDecorations {
|
|
|
- content: (
|
|
|
- | TokenDecoration
|
|
|
- | PageRefDecoration
|
|
|
- | BlockRefDecoration
|
|
|
- | TagDecoration
|
|
|
- | HighlightDecoration
|
|
|
- )[]
|
|
|
- properties: PropertyInfo[]
|
|
|
- blocks: BlockDecoration[]
|
|
|
-}
|
|
|
-
|
|
|
-// ── Tree helpers ─────────────────────────────────────────────────────
|
|
|
-
|
|
|
-export function findChildByTypeName(
|
|
|
- node: SyntaxNode,
|
|
|
- typeName: string,
|
|
|
-): SyntaxNode | null {
|
|
|
- const cursor = node.cursor()
|
|
|
- if (!cursor.firstChild()) return null
|
|
|
- do {
|
|
|
- if (cursor.node.type.name === typeName) {
|
|
|
- const child = cursor.node
|
|
|
- cursor.parent()
|
|
|
- return child
|
|
|
- }
|
|
|
- } while (cursor.nextSibling())
|
|
|
- cursor.parent()
|
|
|
- return null
|
|
|
-}
|
|
|
-
|
|
|
-export function findChildrenByTypeName(
|
|
|
- node: SyntaxNode,
|
|
|
- typeName: string,
|
|
|
-): SyntaxNode[] {
|
|
|
- const results: SyntaxNode[] = []
|
|
|
- const cursor = node.cursor()
|
|
|
- if (!cursor.firstChild()) return results
|
|
|
- do {
|
|
|
- if (cursor.node.type.name === typeName) {
|
|
|
- results.push(cursor.node)
|
|
|
- }
|
|
|
- } while (cursor.nextSibling())
|
|
|
- cursor.parent()
|
|
|
- return results
|
|
|
-}
|
|
|
-
|
|
|
-// ── Rule interface ───────────────────────────────────────────────────
|
|
|
-
|
|
|
-export interface MarkdownRule<T = unknown> {
|
|
|
- name: string
|
|
|
- nodeTypes?: string[]
|
|
|
- match(node: SyntaxNode, ctx: ParseContext): boolean
|
|
|
- run(node: SyntaxNode, ctx: ParseContext): T | null
|
|
|
-}
|
|
|
-
|
|
|
-// ── Inline rule factories ────────────────────────────────────────────
|
|
|
-
|
|
|
-export function createDelimitedRule(config: {
|
|
|
- node: string
|
|
|
- mark: string
|
|
|
- type: string
|
|
|
- className: string
|
|
|
-}): MarkdownRule<TokenDecoration> {
|
|
|
- return {
|
|
|
- name: config.type,
|
|
|
- nodeTypes: [config.node],
|
|
|
- match(node) {
|
|
|
- return node.type.name === config.node
|
|
|
- },
|
|
|
- run(node) {
|
|
|
- const marks = findChildrenByTypeName(node, config.mark)
|
|
|
- if (marks.length < 2) return null
|
|
|
- const opener = marks[0]
|
|
|
- const closer = marks[marks.length - 1]
|
|
|
- return {
|
|
|
- type: config.type,
|
|
|
- wrapperAttrs: {
|
|
|
- "data-md-type": config.type,
|
|
|
- },
|
|
|
- decorationRanges: [
|
|
|
- { type: "hidden", from: opener.from, to: opener.to },
|
|
|
- {
|
|
|
- type: "content",
|
|
|
- from: opener.to,
|
|
|
- to: closer.from,
|
|
|
- className: config.className,
|
|
|
- },
|
|
|
- { type: "hidden", from: closer.from, to: closer.to },
|
|
|
- ],
|
|
|
- }
|
|
|
- },
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-export function createLinkRule(): MarkdownRule<TokenDecoration> {
|
|
|
- return {
|
|
|
- name: "link",
|
|
|
- nodeTypes: ["Link"],
|
|
|
- match(node) {
|
|
|
- return node.type.name === "Link"
|
|
|
- },
|
|
|
- run(node) {
|
|
|
- const linkMarks = findChildrenByTypeName(node, "LinkMark")
|
|
|
- if (linkMarks.length < 2) return null
|
|
|
- const url = findChildByTypeName(node, "URL")
|
|
|
- if (!url) return null
|
|
|
- const opener = linkMarks[0]
|
|
|
- const closer = linkMarks[linkMarks.length - 1]
|
|
|
- const textEnd = linkMarks[1]
|
|
|
- return {
|
|
|
- type: "link",
|
|
|
- wrapperAttrs: {
|
|
|
- "data-md-type": "link",
|
|
|
- },
|
|
|
- decorationRanges: [
|
|
|
- { type: "hidden", from: opener.from, to: opener.to },
|
|
|
- {
|
|
|
- type: "content",
|
|
|
- from: opener.to,
|
|
|
- to: textEnd.from,
|
|
|
- className: "md-link",
|
|
|
- },
|
|
|
- { type: "hidden", from: textEnd.from, to: closer.to },
|
|
|
- ],
|
|
|
- }
|
|
|
- },
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-export function createPageRefRule(): MarkdownRule<PageRefDecoration> {
|
|
|
- return {
|
|
|
- name: "page-ref",
|
|
|
- nodeTypes: ["PageRef"],
|
|
|
- match(node) {
|
|
|
- return node.type.name === "PageRef"
|
|
|
- },
|
|
|
- run(node, ctx) {
|
|
|
- const from = node.from
|
|
|
- const to = node.to
|
|
|
- const innerText = ctx.doc.slice(from + 2, to - 2)
|
|
|
- const pipeIdx = innerText.indexOf("|")
|
|
|
- const pageName = pipeIdx >= 0 ? innerText.slice(0, pipeIdx) : innerText
|
|
|
- const alias = pipeIdx >= 0 ? innerText.slice(pipeIdx + 1) : undefined
|
|
|
-
|
|
|
- return {
|
|
|
- type: "page-ref",
|
|
|
- pageName,
|
|
|
- alias,
|
|
|
- wrapperAttrs: {
|
|
|
- "data-page-name": pageName,
|
|
|
- ...(alias && { "data-alias": alias }),
|
|
|
- role: "link",
|
|
|
- tabindex: "0",
|
|
|
- },
|
|
|
- decorationRanges: [
|
|
|
- { type: "hidden", from, to: from + 2 },
|
|
|
- {
|
|
|
- type: "content",
|
|
|
- from: from + 2,
|
|
|
- to: to - 2,
|
|
|
- className: "md-page-ref",
|
|
|
- },
|
|
|
- { type: "hidden", from: to - 2, to },
|
|
|
- ],
|
|
|
- }
|
|
|
- },
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-export function createBlockRefRule(): MarkdownRule<BlockRefDecoration> {
|
|
|
- return {
|
|
|
- name: "block-ref",
|
|
|
- nodeTypes: ["BlockRef"],
|
|
|
- match(node) {
|
|
|
- return node.type.name === "BlockRef"
|
|
|
- },
|
|
|
- run(node, ctx) {
|
|
|
- const from = node.from
|
|
|
- const to = node.to
|
|
|
- const blockId = ctx.doc.slice(from + 2, to - 2)
|
|
|
- return {
|
|
|
- type: "block-ref",
|
|
|
- blockId,
|
|
|
- wrapperAttrs: {
|
|
|
- "data-block-id": blockId,
|
|
|
- },
|
|
|
- decorationRanges: [
|
|
|
- { type: "hidden", from, to: from + 2 },
|
|
|
- {
|
|
|
- type: "content",
|
|
|
- from: from + 2,
|
|
|
- to: to - 2,
|
|
|
- className: "md-block-ref",
|
|
|
- },
|
|
|
- { type: "hidden", from: to - 2, to },
|
|
|
- ],
|
|
|
- }
|
|
|
- },
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-export function createTagRule(): MarkdownRule<TagDecoration> {
|
|
|
- return {
|
|
|
- name: "tag",
|
|
|
- nodeTypes: ["Tag"],
|
|
|
- match(node) {
|
|
|
- return node.type.name === "Tag"
|
|
|
- },
|
|
|
- run(node, ctx) {
|
|
|
- const from = node.from
|
|
|
- const to = node.to
|
|
|
- const tagText = ctx.doc.slice(from, to)
|
|
|
- const tag = tagText.slice(1)
|
|
|
- return {
|
|
|
- type: "tag",
|
|
|
- tag,
|
|
|
- wrapperAttrs: {
|
|
|
- "data-tag": tag,
|
|
|
- },
|
|
|
- decorationRanges: [
|
|
|
- { type: "content", from, to, className: "md-tag" },
|
|
|
- ],
|
|
|
- }
|
|
|
- },
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-export function createHighlightRule(): MarkdownRule<HighlightDecoration> {
|
|
|
- return {
|
|
|
- name: "highlight",
|
|
|
- nodeTypes: ["Highlight"],
|
|
|
- match(node) {
|
|
|
- return node.type.name === "Highlight"
|
|
|
- },
|
|
|
- run(node) {
|
|
|
- const from = node.from
|
|
|
- const to = node.to
|
|
|
- return {
|
|
|
- type: "highlight",
|
|
|
- wrapperAttrs: {
|
|
|
- "data-md-type": "highlight",
|
|
|
- },
|
|
|
- decorationRanges: [
|
|
|
- { type: "hidden", from, to: from + 2 },
|
|
|
- {
|
|
|
- type: "content",
|
|
|
- from: from + 2,
|
|
|
- to: to - 2,
|
|
|
- className: "md-highlight",
|
|
|
- },
|
|
|
- { type: "hidden", from: to - 2, to },
|
|
|
- ],
|
|
|
- }
|
|
|
- },
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-// ── Line rule interface ──────────────────────────────────────────────
|
|
|
-
|
|
|
-export interface LineRule {
|
|
|
- name: string
|
|
|
- priority?: number
|
|
|
- match(line: string): boolean
|
|
|
- run(line: string): BlockDecoration | BlockDecoration[] | null
|
|
|
-}
|
|
|
-
|
|
|
-// ── Line rule factories ──────────────────────────────────────────────
|
|
|
-//
|
|
|
-// All line rules account for leading whitespace by calculating
|
|
|
-// `leadingOffset = line.length - trimmed.length` and adding it to
|
|
|
-// all decoration range positions. This ensures decorations target
|
|
|
-// the correct characters even when lines are indented.
|
|
|
-
|
|
|
-export function createHeadingRule(): LineRule {
|
|
|
- return {
|
|
|
- name: "heading",
|
|
|
- priority: 10,
|
|
|
- match(line) {
|
|
|
- return line.trimStart().startsWith("#")
|
|
|
- },
|
|
|
- run(line) {
|
|
|
- const trimmed = line.trimStart()
|
|
|
- const leadingOffset = line.length - trimmed.length
|
|
|
- const match = trimmed.match(/^(#{1,6})\s+(.*)$/)
|
|
|
- if (!match) return null
|
|
|
- const level = match[1].length
|
|
|
- const markerEnd = leadingOffset + level
|
|
|
- const contentStart = leadingOffset + level + 1
|
|
|
- return {
|
|
|
- type: "heading",
|
|
|
- wrapperAttrs: {
|
|
|
- "data-heading-level": String(level),
|
|
|
- },
|
|
|
- decorationRanges: [
|
|
|
- {
|
|
|
- type: "content",
|
|
|
- from: leadingOffset,
|
|
|
- to: markerEnd,
|
|
|
- className: "md-heading-marker",
|
|
|
- },
|
|
|
- {
|
|
|
- type: "content",
|
|
|
- from: contentStart,
|
|
|
- to: line.length,
|
|
|
- className: "md-heading-content",
|
|
|
- },
|
|
|
- ],
|
|
|
- metadata: { level },
|
|
|
- }
|
|
|
- },
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-export function createCalloutRule(): LineRule {
|
|
|
- return {
|
|
|
- name: "callout",
|
|
|
- priority: 20,
|
|
|
- match(line) {
|
|
|
- const trimmed = line.trimStart()
|
|
|
- return trimmed.startsWith(">") && /^>\s*\[![A-Z]+\]/.test(trimmed)
|
|
|
- },
|
|
|
- run(line) {
|
|
|
- const trimmed = line.trimStart()
|
|
|
- const leadingOffset = line.length - trimmed.length
|
|
|
- const calloutMatch = trimmed.match(/^>\s*\[!([A-Z]+)\]\s*(.*)$/)
|
|
|
- if (!calloutMatch) return null
|
|
|
- const calloutType = calloutMatch[1] as CalloutType
|
|
|
- if (!["NOTE", "WARNING", "TIP", "DANGER", "INFO"].includes(calloutType))
|
|
|
- return null
|
|
|
- const bracketEnd = trimmed.indexOf("]")
|
|
|
- const markerEnd = leadingOffset + bracketEnd + 1
|
|
|
- const spaceAfter = trimmed.indexOf(" ", bracketEnd + 1)
|
|
|
- const contentStart =
|
|
|
- spaceAfter >= 0 ? leadingOffset + spaceAfter + 1 : line.length
|
|
|
- return {
|
|
|
- type: "callout",
|
|
|
- wrapperAttrs: {
|
|
|
- "data-callout-type": calloutType,
|
|
|
- },
|
|
|
- decorationRanges: [
|
|
|
- {
|
|
|
- type: "content",
|
|
|
- from: leadingOffset,
|
|
|
- to: markerEnd,
|
|
|
- className: "md-callout-marker",
|
|
|
- },
|
|
|
- {
|
|
|
- type: "content",
|
|
|
- from: contentStart,
|
|
|
- to: line.length,
|
|
|
- className: "md-callout-content",
|
|
|
- },
|
|
|
- ],
|
|
|
- metadata: { calloutType },
|
|
|
- }
|
|
|
- },
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-export function createBlockquoteRule(): LineRule {
|
|
|
- return {
|
|
|
- name: "blockquote",
|
|
|
- priority: 15,
|
|
|
- match(line) {
|
|
|
- const trimmed = line.trimStart()
|
|
|
- return trimmed.startsWith(">") && !/^>\s*\[![A-Z]+\]/.test(trimmed)
|
|
|
- },
|
|
|
- run(line) {
|
|
|
- const trimmed = line.trimStart()
|
|
|
- const leadingOffset = line.length - trimmed.length
|
|
|
- const match = trimmed.match(/^>/)
|
|
|
- if (!match) return null
|
|
|
- const markerEnd = leadingOffset + 1
|
|
|
- const spaceMatch = trimmed.match(/^>\s+/)
|
|
|
- const contentStart = spaceMatch
|
|
|
- ? leadingOffset + spaceMatch[0].length
|
|
|
- : markerEnd + 1
|
|
|
- return {
|
|
|
- type: "blockquote",
|
|
|
- wrapperAttrs: {
|
|
|
- "data-md-type": "blockquote",
|
|
|
- },
|
|
|
- decorationRanges: [
|
|
|
- {
|
|
|
- type: "content",
|
|
|
- from: leadingOffset,
|
|
|
- to: markerEnd,
|
|
|
- className: "md-blockquote-marker",
|
|
|
- },
|
|
|
- {
|
|
|
- type: "content",
|
|
|
- from: contentStart,
|
|
|
- to: line.length,
|
|
|
- className: "md-blockquote-content",
|
|
|
- },
|
|
|
- ],
|
|
|
- }
|
|
|
- },
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-export function createDateRule(): LineRule {
|
|
|
- return {
|
|
|
- name: "date",
|
|
|
- priority: 10,
|
|
|
- match(line) {
|
|
|
- return line.trimStart().startsWith("📅")
|
|
|
- },
|
|
|
- run(line) {
|
|
|
- const trimmed = line.trimStart()
|
|
|
- const leadingOffset = line.length - trimmed.length
|
|
|
- const match = trimmed.match(/^📅\s*(\d{4}-\d{2}-\d{2})/)
|
|
|
- if (!match) return null
|
|
|
- const date = match[1]
|
|
|
- const emojiLen = "📅".length
|
|
|
- const markerEnd = leadingOffset + emojiLen
|
|
|
- const contentStart = leadingOffset + emojiLen + 1
|
|
|
- return {
|
|
|
- type: "date",
|
|
|
- wrapperAttrs: {
|
|
|
- "data-date": date,
|
|
|
- },
|
|
|
- decorationRanges: [
|
|
|
- {
|
|
|
- type: "content",
|
|
|
- from: leadingOffset,
|
|
|
- to: markerEnd,
|
|
|
- className: "md-date-marker",
|
|
|
- },
|
|
|
- {
|
|
|
- type: "content",
|
|
|
- from: contentStart,
|
|
|
- to: contentStart + 10,
|
|
|
- className: "md-date-content",
|
|
|
- },
|
|
|
- ],
|
|
|
- metadata: { date },
|
|
|
- }
|
|
|
- },
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-export function createTaskRule(): LineRule {
|
|
|
- return {
|
|
|
- name: "task",
|
|
|
- priority: 30,
|
|
|
- match(line) {
|
|
|
- const trimmed = line.trimStart()
|
|
|
- return /^(TODO|DOING|DONE|LATER|NOW|WAITING|CANCELLED)\s+/i.test(trimmed)
|
|
|
- },
|
|
|
- run(line) {
|
|
|
- const trimmed = line.trimStart()
|
|
|
- const leadingOffset = line.length - trimmed.length
|
|
|
- const taskMatch = trimmed.match(
|
|
|
- /^(TODO|DOING|DONE|LATER|NOW|WAITING|CANCELLED)\s+(.*)$/i,
|
|
|
- )
|
|
|
- if (!taskMatch) return null
|
|
|
- const taskState = taskMatch[1].toUpperCase() as TaskState
|
|
|
- if (
|
|
|
- ![
|
|
|
- "TODO",
|
|
|
- "DOING",
|
|
|
- "DONE",
|
|
|
- "LATER",
|
|
|
- "NOW",
|
|
|
- "WAITING",
|
|
|
- "CANCELLED",
|
|
|
- ].includes(taskState)
|
|
|
- )
|
|
|
- return null
|
|
|
-
|
|
|
- const markerEnd = leadingOffset + taskMatch[1].length
|
|
|
- const afterTask = taskMatch[2]
|
|
|
- const blocks: BlockDecoration[] = []
|
|
|
-
|
|
|
- const priorityMatch = afterTask.match(/\s+\[#([A-C])\]\s*$/)
|
|
|
- let taskContentEnd = line.length
|
|
|
-
|
|
|
- if (priorityMatch) {
|
|
|
- taskContentEnd = line.length - priorityMatch[0].length
|
|
|
- }
|
|
|
-
|
|
|
- const contentStart = leadingOffset + taskMatch[0].indexOf(taskMatch[2])
|
|
|
-
|
|
|
- blocks.push({
|
|
|
- type: "task",
|
|
|
- wrapperAttrs: {
|
|
|
- "data-task-state": taskState,
|
|
|
- },
|
|
|
- decorationRanges: [
|
|
|
- {
|
|
|
- type: "content",
|
|
|
- from: leadingOffset,
|
|
|
- to: markerEnd,
|
|
|
- className: "md-task-marker",
|
|
|
- },
|
|
|
- {
|
|
|
- type: "content",
|
|
|
- from: contentStart,
|
|
|
- to: taskContentEnd,
|
|
|
- className: "md-task-content",
|
|
|
- },
|
|
|
- ],
|
|
|
- metadata: { taskState },
|
|
|
- })
|
|
|
-
|
|
|
- if (priorityMatch) {
|
|
|
- const priority = priorityMatch[1] as PriorityLevel
|
|
|
- const priorityStart = taskContentEnd
|
|
|
- const priorityMarkerEnd = priorityStart + 4
|
|
|
-
|
|
|
- blocks.push({
|
|
|
- type: "priority",
|
|
|
- wrapperAttrs: {
|
|
|
- "data-priority": priority,
|
|
|
- },
|
|
|
- decorationRanges: [
|
|
|
- {
|
|
|
- type: "content",
|
|
|
- from: priorityStart,
|
|
|
- to: priorityMarkerEnd,
|
|
|
- className: "md-priority-marker",
|
|
|
- },
|
|
|
- {
|
|
|
- type: "content",
|
|
|
- from: priorityMarkerEnd,
|
|
|
- to: line.length,
|
|
|
- className: "md-priority-content",
|
|
|
- },
|
|
|
- ],
|
|
|
- metadata: { priority },
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- return blocks
|
|
|
- },
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-export function createPriorityRule(): LineRule {
|
|
|
- return {
|
|
|
- name: "priority",
|
|
|
- priority: 25,
|
|
|
- match(line) {
|
|
|
- return /^\[#([A-C])\]/.test(line.trimStart())
|
|
|
- },
|
|
|
- run(line) {
|
|
|
- const trimmed = line.trimStart()
|
|
|
- const leadingOffset = line.length - trimmed.length
|
|
|
- const match = trimmed.match(/^\[#([A-C])\]\s*(.*)$/)
|
|
|
- if (!match) return null
|
|
|
- const priority = match[1] as PriorityLevel
|
|
|
- const markerEnd = leadingOffset + 4
|
|
|
- const contentStart = leadingOffset + match[0].indexOf(match[2])
|
|
|
- return {
|
|
|
- type: "priority",
|
|
|
- wrapperAttrs: {
|
|
|
- "data-priority": priority,
|
|
|
- },
|
|
|
- decorationRanges: [
|
|
|
- {
|
|
|
- type: "content",
|
|
|
- from: leadingOffset,
|
|
|
- to: markerEnd,
|
|
|
- className: "md-priority-marker",
|
|
|
- },
|
|
|
- {
|
|
|
- type: "content",
|
|
|
- from: contentStart,
|
|
|
- to: line.length,
|
|
|
- className: "md-priority-content",
|
|
|
- },
|
|
|
- ],
|
|
|
- metadata: { priority },
|
|
|
- }
|
|
|
- },
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-// ── Engine ───────────────────────────────────────────────────────────
|
|
|
-
|
|
|
-function buildRuleIndex(
|
|
|
- rules: ReadonlyArray<MarkdownRule<unknown>>,
|
|
|
-): ReadonlyMap<string, ReadonlyArray<MarkdownRule<unknown>>> {
|
|
|
- const index = new Map<string, MarkdownRule<unknown>[]>()
|
|
|
- for (const rule of rules) {
|
|
|
- const types = rule.nodeTypes
|
|
|
- if (types) {
|
|
|
- for (const nodeType of types) {
|
|
|
- const bucket = index.get(nodeType)
|
|
|
- if (bucket) bucket.push(rule)
|
|
|
- else index.set(nodeType, [rule])
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- return index
|
|
|
-}
|
|
|
-
|
|
|
-export class MarkdownRuleEngine {
|
|
|
- private readonly inlineRules: ReadonlyArray<MarkdownRule<unknown>>
|
|
|
- private readonly ruleIndex: ReadonlyMap<
|
|
|
- string,
|
|
|
- ReadonlyArray<MarkdownRule<unknown>>
|
|
|
- >
|
|
|
- private readonly lineRules: ReadonlyArray<LineRule>
|
|
|
-
|
|
|
- constructor() {
|
|
|
- const inlineRules = [
|
|
|
- createDelimitedRule({
|
|
|
- node: "StrongEmphasis",
|
|
|
- mark: "EmphasisMark",
|
|
|
- type: "strong",
|
|
|
- className: "md-strong",
|
|
|
- }),
|
|
|
- createDelimitedRule({
|
|
|
- node: "Emphasis",
|
|
|
- mark: "EmphasisMark",
|
|
|
- type: "em",
|
|
|
- className: "md-em",
|
|
|
- }),
|
|
|
- createDelimitedRule({
|
|
|
- node: "InlineCode",
|
|
|
- mark: "CodeMark",
|
|
|
- type: "code",
|
|
|
- className: "md-code",
|
|
|
- }),
|
|
|
- createDelimitedRule({
|
|
|
- node: "Strikethrough",
|
|
|
- mark: "StrikethroughMark",
|
|
|
- type: "strike",
|
|
|
- className: "md-strike",
|
|
|
- }),
|
|
|
- createLinkRule(),
|
|
|
- createPageRefRule(),
|
|
|
- createBlockRefRule(),
|
|
|
- createTagRule(),
|
|
|
- createHighlightRule(),
|
|
|
- ]
|
|
|
-
|
|
|
- this.inlineRules = inlineRules
|
|
|
- this.ruleIndex = buildRuleIndex(inlineRules)
|
|
|
-
|
|
|
- this.lineRules = [
|
|
|
- createCalloutRule(),
|
|
|
- createBlockquoteRule(),
|
|
|
- createTaskRule(),
|
|
|
- createPriorityRule(),
|
|
|
- createHeadingRule(),
|
|
|
- createDateRule(),
|
|
|
- ].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
|
|
- }
|
|
|
-
|
|
|
- parse(docText: string, markdownParser: Parser): ParsedDecorations {
|
|
|
- const content: ParsedDecorations["content"] = []
|
|
|
- const properties: PropertyInfo[] = []
|
|
|
- const blocks: BlockDecoration[] = []
|
|
|
-
|
|
|
- if (!docText) return { content, properties, blocks }
|
|
|
-
|
|
|
- const lines = docText.split("\n")
|
|
|
- let lineOffset = 0
|
|
|
-
|
|
|
- for (const line of lines) {
|
|
|
- // Pipeline 1: AST inline rules (per-line to avoid Lezer paragraph boundaries)
|
|
|
- try {
|
|
|
- const tree = markdownParser.parse(line)
|
|
|
- this.visitTree(tree.cursor(), (node) => {
|
|
|
- const ctx: ParseContext = { doc: line }
|
|
|
- const candidates = this.ruleIndex.get(node.type.name)
|
|
|
- const rulesToTry = candidates ?? this.inlineRules
|
|
|
- for (const rule of rulesToTry) {
|
|
|
- if (rule.match(node, ctx)) {
|
|
|
- const result = rule.run(node, ctx)
|
|
|
- if (result) {
|
|
|
- // Shift positions to absolute docText coordinates
|
|
|
- for (const range of result.decorationRanges) {
|
|
|
- range.from += lineOffset
|
|
|
- range.to += lineOffset
|
|
|
- }
|
|
|
- content.push(result)
|
|
|
- }
|
|
|
- break
|
|
|
- }
|
|
|
- }
|
|
|
- })
|
|
|
- } catch {
|
|
|
- // Skip lines that fail to parse
|
|
|
- }
|
|
|
-
|
|
|
- // Pipeline 2: Line/block rules
|
|
|
- for (const rule of this.lineRules) {
|
|
|
- if (rule.match(line)) {
|
|
|
- const result = rule.run(line)
|
|
|
- if (result) {
|
|
|
- const items = Array.isArray(result) ? result : [result]
|
|
|
- for (const item of items) {
|
|
|
- for (const range of item.decorationRanges) {
|
|
|
- range.from += lineOffset
|
|
|
- range.to += lineOffset
|
|
|
- }
|
|
|
- blocks.push(item)
|
|
|
- }
|
|
|
- }
|
|
|
- break
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // Pipeline 3: Property rules (per-line)
|
|
|
- const propMatch = line.match(/^(\w[\w-]*)::([^\n]*)/)
|
|
|
- if (propMatch) {
|
|
|
- const key = propMatch[1]
|
|
|
- const value = propMatch[2]?.trim() ?? ""
|
|
|
- const keyEnd = lineOffset + key.length
|
|
|
- const delimiterEnd = keyEnd + 2
|
|
|
- properties.push({
|
|
|
- key,
|
|
|
- value,
|
|
|
- keyRange: [lineOffset, keyEnd],
|
|
|
- delimiterRange: [keyEnd, delimiterEnd],
|
|
|
- valueRange: [delimiterEnd, delimiterEnd + propMatch[2].length],
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- lineOffset += line.length + 1 // +1 for the \n separator
|
|
|
- }
|
|
|
-
|
|
|
- return { content, properties, blocks }
|
|
|
- }
|
|
|
-
|
|
|
- private visitTree(
|
|
|
- cursor: TreeCursor,
|
|
|
- visit: (node: SyntaxNode) => void,
|
|
|
- ): void {
|
|
|
- do {
|
|
|
- if (this.ruleIndex.has(cursor.node.type.name)) {
|
|
|
- visit(cursor.node)
|
|
|
- }
|
|
|
- if (cursor.firstChild()) {
|
|
|
- this.visitTree(cursor, visit)
|
|
|
- cursor.parent()
|
|
|
- }
|
|
|
- } while (cursor.nextSibling())
|
|
|
- }
|
|
|
-}
|