feat(desktop): embed BoosAPI Desktop client as a subproject

Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -0,0 +1,29 @@
import type { MarkdownToken, ParsedNode } from '../types'
export function processNestedBlocks(
tokens: MarkdownToken[],
startIndex: number,
): [ParsedNode | null, number] {
// This is a simplified version to break circular dependencies
// Nested lists are now properly handled in list-parser.ts with an enhanced parseNestedList function
let i = startIndex
let depth = 1
while (i < tokens.length && depth > 0) {
if (
tokens[i].type === 'bullet_list_open'
|| tokens[i].type === 'ordered_list_open'
) {
depth++
}
else if (
tokens[i].type === 'bullet_list_close'
|| tokens[i].type === 'ordered_list_close'
) {
depth--
}
i++
}
return [null, i]
}
@@ -0,0 +1,11 @@
export {
getHtmlTagFromContent,
hasCompleteHtmlTagContent,
isHtmlLikeTagName,
mergeCustomHtmlTags,
normalizeCustomHtmlTags,
normalizeCustomHtmlTagName as normalizeCustomTag,
resolveCustomHtmlTags,
shouldRenderUnknownHtmlTagAsText,
stripCustomHtmlWrapper,
} from '../customHtmlTags'
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
import type { CheckboxInputNode, CheckboxNode, MarkdownToken } from '../../types'
export function parseCheckboxToken(token: MarkdownToken): CheckboxNode {
const tokenMeta = (token.meta ?? {}) as unknown as { checked?: boolean }
return {
type: 'checkbox',
checked: tokenMeta.checked === true,
raw: tokenMeta.checked ? '[x]' : '[ ]',
}
}
export function parseCheckboxInputToken(token: any): CheckboxInputNode {
const tokenAny = token as unknown as { attrGet?: (name: string) => string | undefined }
const rawAttr = tokenAny.attrGet ? tokenAny.attrGet('checked') : undefined
const checked = rawAttr === '' || rawAttr === 'true'
return {
type: 'checkbox_input',
checked,
raw: checked ? '[x]' : '[ ]',
}
}
@@ -0,0 +1,12 @@
import type { EmojiNode, MarkdownToken } from '../../types'
export function parseEmojiToken(token: MarkdownToken): EmojiNode {
const name = String(token.content ?? '')
const markup = String(token.markup ?? '')
return {
type: 'emoji',
name,
markup,
raw: `:${name}:`,
}
}
@@ -0,0 +1,37 @@
import type { EmphasisNode, MarkdownToken, ParsedNode, ParseOptions } from '../../types'
import { parseInlineTokens } from '../index'
export function parseEmphasisToken(
tokens: MarkdownToken[],
startIndex: number,
options?: ParseOptions,
): {
node: EmphasisNode
nextIndex: number
} {
const children: ParsedNode[] = []
let emText = ''
let i = startIndex + 1
const innerTokens: MarkdownToken[] = []
// Process tokens between em_open and em_close
while (i < tokens.length && tokens[i].type !== 'em_close') {
emText += String(tokens[i].content ?? (tokens[i] as any)?.text ?? '')
innerTokens.push(tokens[i])
i++
}
// Parse inner tokens to handle nested elements
children.push(...parseInlineTokens(innerTokens, undefined, undefined, options as any))
const node: EmphasisNode = {
type: 'emphasis',
children,
raw: `*${emText}*`,
}
// Skip to after em_close
const nextIndex = i < tokens.length ? i + 1 : tokens.length
return { node, nextIndex }
}
@@ -0,0 +1,126 @@
import type { CodeBlockNode, MarkdownToken } from '../../types'
// Strip a final line that looks like a fence marker (``` etc.)
const TRAILING_FENCE_LINE_RE = /\r?\n[ \t]*`+\s*$/
// Unified diff metadata/header line prefixes to skip when splitting a diff
const DIFF_HEADER_PREFIXES = ['diff ', 'index ', '--- ', '+++ ', '@@ ']
// Newline splitter reused in this module
const NEWLINE_RE = /\r?\n/
function flushPendingDiffHunk(
orig: string[],
updated: string[],
pendingOrig: string[],
pendingUpdated: string[],
) {
if (pendingOrig.length > 0)
orig.push(...pendingOrig)
if (pendingUpdated.length > 0)
updated.push(...pendingUpdated)
pendingOrig.length = 0
pendingUpdated.length = 0
}
function splitUnifiedDiff(content: string, closed: boolean) {
const orig: string[] = []
const updated: string[] = []
const pendingOrig: string[] = []
const pendingUpdated: string[] = []
const lines = content.split(NEWLINE_RE)
const stableLineCount = Math.max(0, lines.length - 1)
const hasUnifiedDiffHeaders = lines.some(line =>
line.startsWith('diff ')
|| line.startsWith('--- ')
|| line.startsWith('+++ ')
|| line.startsWith('@@ '),
)
const processLine = (rawLine: string) => {
const line = rawLine
// skip diff metadata lines
if (DIFF_HEADER_PREFIXES.some(p => line.startsWith(p)))
return
if (line.startsWith('-')) {
const body = line.slice(1)
pendingOrig.push(!hasUnifiedDiffHeaders && body.startsWith(' ') ? ` ${body}` : body)
}
else if (line.startsWith('+')) {
const body = line.slice(1)
pendingUpdated.push(!hasUnifiedDiffHeaders && body.startsWith(' ') ? ` ${body}` : body)
}
else {
flushPendingDiffHunk(orig, updated, pendingOrig, pendingUpdated)
const contextLine = hasUnifiedDiffHeaders && line.startsWith(' ') ? line.slice(1) : line
orig.push(contextLine)
updated.push(contextLine)
}
}
for (let index = 0; index < stableLineCount; index++)
processLine(lines[index] ?? '')
if (closed && stableLineCount < lines.length)
processLine(lines[lines.length - 1] ?? '')
if (closed || (pendingOrig.length > 0 && pendingUpdated.length > 0))
flushPendingDiffHunk(orig, updated, pendingOrig, pendingUpdated)
return {
original: orig.join('\n'),
updated: updated.join('\n'),
}
}
export function parseFenceToken(token: MarkdownToken): CodeBlockNode {
const hasMap = Array.isArray(token.map) && token.map.length === 2
const tokenMeta = (token.meta ?? {}) as unknown as { closed?: boolean }
const closed = typeof tokenMeta.closed === 'boolean' ? tokenMeta.closed : undefined
const info = String(token.info ?? '')
const diff = info.startsWith('diff')
const language = diff
? (() => {
const s = info
const sp = s.indexOf(' ')
return sp === -1
? ''
: String(s.slice(sp + 1) ?? '')
})()
: info
// Defensive sanitization: sometimes a closing fence line (e.g. ``` or ``)
// can accidentally end up inside `token.content` (for example when
// the parser/mapping is confused). Remove a trailing line that only
// contains backticks and optional whitespace so we don't render stray
// ` or `` characters at the end of the code output. This is a
// conservative cleanup and only strips a final line that looks like a
// fence marker (starts with optional spaces then one or more ` and
// only whitespace until end-of-string).
let content = String(token.content ?? '')
if (TRAILING_FENCE_LINE_RE.test(content))
content = content.replace(TRAILING_FENCE_LINE_RE, '')
if (diff) {
const { original, updated } = splitUnifiedDiff(content, closed === true)
// 返回时保留原来的 code 字段为 updated(编辑后代码),并额外附加原始与更新的文本
return {
type: 'code_block',
language,
code: String(updated ?? ''),
raw: String(content ?? ''),
diff,
loading: closed === true ? false : closed === false ? true : !hasMap,
originalCode: original,
updatedCode: updated,
}
}
return {
type: 'code_block',
language,
code: String(content ?? ''),
raw: String(content ?? ''),
diff,
loading: closed === true ? false : closed === false ? true : !hasMap,
}
}
@@ -0,0 +1,12 @@
import type { FootnoteReferenceNode, MarkdownToken } from '../../types'
export function parseFootnoteRefToken(
token: MarkdownToken,
): FootnoteReferenceNode {
const tokenMeta = (token.meta ?? {}) as unknown as { label?: string }
return {
type: 'footnote_reference',
id: String(tokenMeta.label ?? ''),
raw: `[^${String(tokenMeta.label ?? '')}]`,
}
}
@@ -0,0 +1,8 @@
import type { HardBreakNode } from '../../types'
export function parseHardbreakToken(): HardBreakNode {
return {
type: 'hardbreak',
raw: '\\\n',
}
}
@@ -0,0 +1,37 @@
import type { HighlightNode, MarkdownToken, ParsedNode, ParseOptions } from '../../types'
import { parseInlineTokens } from '../index'
export function parseHighlightToken(
tokens: MarkdownToken[],
startIndex: number,
options?: ParseOptions,
): {
node: HighlightNode
nextIndex: number
} {
const children: ParsedNode[] = []
let markText = ''
let i = startIndex + 1
const innerTokens: MarkdownToken[] = []
// Process tokens between mark_open and mark_close
while (i < tokens.length && tokens[i].type !== 'mark_close') {
markText += String(tokens[i].content ?? '')
innerTokens.push(tokens[i])
i++
}
// Parse inner tokens to handle nested elements
children.push(...parseInlineTokens(innerTokens, undefined, undefined, options as any))
const node: HighlightNode = {
type: 'highlight',
children,
raw: `==${markText}==`,
}
// Skip to after mark_close
const nextIndex = i < tokens.length ? i + 1 : tokens.length
return { node, nextIndex }
}
@@ -0,0 +1,392 @@
import type { InlineCodeNode, MarkdownToken, ParsedNode } from '../../types'
import { VOID_HTML_TAGS } from '../../htmlTags'
import { parseTagAttrs } from '../../htmlTagUtils'
import { normalizeCustomTag } from '../customHtmlTags'
import { buildAllowedHtmlTagSet } from '../index'
type ParseInlineTokensFn = (
tokens: MarkdownToken[],
raw?: string,
pPreToken?: MarkdownToken,
options?: { requireClosingStrong?: boolean, customHtmlTags?: readonly string[] },
) => ParsedNode[]
interface TagSetCacheEntry {
customTagSet: Set<string> | null
allowedTagSet: Set<string>
}
let emptyTagSets: TagSetCacheEntry | null = null
const TAG_SET_CACHE = new WeakMap<readonly string[], TagSetCacheEntry>()
function getEmptyTagSets() {
if (!emptyTagSets) {
emptyTagSets = {
customTagSet: null,
allowedTagSet: buildAllowedHtmlTagSet(),
}
}
return emptyTagSets
}
function getTagName(html: string) {
const match = html.match(/^<\s*(?:\/\s*)?([\w-]+)/)
return match ? match[1].toLowerCase() : ''
}
function isClosingTag(html: string) {
return /^<\s*\//.test(html)
}
function isSelfClosing(tag: string, html: string) {
return /\/\s*>\s*$/.test(html) || VOID_HTML_TAGS.has(tag)
}
function getTagSets(customTags?: readonly string[]) {
if (!customTags || customTags.length === 0)
return getEmptyTagSets()
const cached = TAG_SET_CACHE.get(customTags)
if (cached)
return cached
const normalized = customTags.map(normalizeCustomTag).filter(Boolean)
if (!normalized.length) {
const entry = getEmptyTagSets()
TAG_SET_CACHE.set(customTags, entry)
return entry
}
const entry = {
customTagSet: new Set(normalized),
allowedTagSet: buildAllowedHtmlTagSet({ customHtmlTags: customTags as any }),
}
TAG_SET_CACHE.set(customTags, entry)
return entry
}
function tokenToRaw(token: MarkdownToken) {
const shape = token as { raw?: string, markup?: string, content?: string }
const raw = shape.raw ?? shape.content ?? shape.markup ?? ''
return String(raw ?? '')
}
type AttrTuple = [string, string]
function getAttrValue(attrs: AttrTuple[], name: string): string | undefined {
const lowerName = name.toLowerCase()
for (let i = attrs.length - 1; i >= 0; i--) {
const [key, value] = attrs[i]
if (String(key).toLowerCase() === lowerName)
return value
}
return undefined
}
function normalizeLinkAttrs(
attrs: AttrTuple[],
href: string,
title: string | null,
): AttrTuple[] {
const normalized = attrs.slice()
if (!getAttrValue(normalized, 'href'))
normalized.push(['href', href])
if (title != null && !getAttrValue(normalized, 'title'))
normalized.push(['title', title])
return normalized
}
function stringifyTokens(tokens: MarkdownToken[]) {
return tokens.map(tokenToRaw).join('')
}
function normalizeStandardHtmlChildren(children: ParsedNode[]) {
const normalized: ParsedNode[] = []
const pushText = (rawText: string) => {
const text = String(rawText ?? '')
if (!text)
return
const last = normalized[normalized.length - 1] as ParsedNode | undefined
if (last?.type === 'text') {
; (last as any).content = String((last as any).content ?? '') + text
; (last as any).raw = String((last as any).raw ?? '') + text
return
}
normalized.push({
type: 'text',
content: text,
raw: text,
} as ParsedNode)
}
for (const child of children) {
if (!child)
continue
if (child.type === 'reference' || child.type === 'footnote_reference') {
pushText(String((child as any).raw ?? ''))
continue
}
if (Array.isArray((child as any).children)) {
normalized.push({
...(child as any),
children: normalizeStandardHtmlChildren(((child as any).children ?? []) as ParsedNode[]),
} as ParsedNode)
continue
}
normalized.push(child)
}
return normalized
}
function findMatchingClosing(tokens: MarkdownToken[], startIndex: number, tag: string) {
let depth = 0
for (let idx = startIndex; idx < tokens.length; idx++) {
const t = tokens[idx]
if (t.type !== 'html_inline')
continue
const content = String(t.content ?? '')
const tTag = getTagName(content)
const closing = isClosingTag(content)
const selfClosing = isSelfClosing(tTag, content)
if (!closing && !selfClosing && tTag === tag) {
depth++
continue
}
if (closing && tTag === tag) {
if (depth === 0)
return idx
depth--
}
}
return -1
}
function collectHtmlFragment(tokens: MarkdownToken[], startIndex: number, tag: string) {
const openToken = tokens[startIndex]
const fragmentTokens: MarkdownToken[] = [openToken]
let innerTokens: MarkdownToken[] = []
let nextIndex = startIndex + 1
let closed = false
const closingIndex = tag ? findMatchingClosing(tokens, startIndex + 1, tag) : -1
if (closingIndex !== -1) {
innerTokens = tokens.slice(startIndex + 1, closingIndex)
fragmentTokens.push(...innerTokens, tokens[closingIndex])
nextIndex = closingIndex + 1
closed = true
}
else {
// Streaming mid-state: if no matching closing tag exists yet,
// treat all following inline tokens as the inner content of this tag.
innerTokens = tokens.slice(startIndex + 1)
if (innerTokens.length)
fragmentTokens.push(...innerTokens)
nextIndex = tokens.length
}
return {
closed,
html: stringifyTokens(fragmentTokens),
innerTokens,
nextIndex,
}
}
// Parse inline HTML and return an appropriate ParsedNode depending on tag.
export function parseHtmlInlineCodeToken(
token: MarkdownToken,
tokens: MarkdownToken[],
i: number,
parseInlineTokens: ParseInlineTokensFn,
raw?: string,
pPreToken?: MarkdownToken,
options?: { requireClosingStrong?: boolean, customHtmlTags?: readonly string[] },
): [ParsedNode, number] {
const code = String(token.content ?? '')
const tag = getTagName(code)
const { customTagSet, allowedTagSet } = getTagSets(options?.customHtmlTags)
if (!tag) {
return [
{
type: 'inline_code',
code,
raw: code,
} as InlineCodeNode,
i + 1,
]
}
// If it's not a standard HTML tag and not in customHtmlTags, default to
// rendering it as literal text (方案 A). However, if the tag is already
// properly closed within the inline token stream, keep it as HTML so
// HtmlInlineNode can still render custom components via HTML parsing.
if (!allowedTagSet.has(tag)) {
const fragment = collectHtmlFragment(tokens, i, tag)
if (!fragment.closed) {
const content = tokenToRaw(token)
return [{ type: 'text', content, raw: content } as ParsedNode, i + 1]
}
}
if (tag === 'br') {
return [
{
type: 'hardbreak',
raw: code,
} as ParsedNode,
i + 1,
]
}
const closing = isClosingTag(code)
const selfClosing = isSelfClosing(tag, code)
if (closing) {
return [
{
type: 'html_inline',
tag,
content: code,
children: [],
raw: code,
loading: false,
} as ParsedNode,
i + 1,
]
}
if (tag === 'a') {
const fragment = collectHtmlFragment(tokens, i, tag)
const attrs = parseTagAttrs(code)
const innerTokens = fragment.innerTokens
const href = String(getAttrValue(attrs, 'href') ?? '')
const titleAttr = getAttrValue(attrs, 'title')
const title = titleAttr == null ? null : String(titleAttr)
const normalizedAttrs = normalizeLinkAttrs(attrs, href, title)
const children = innerTokens.length
? parseInlineTokens(innerTokens, raw, pPreToken, options)
: []
const normalizedChildren = normalizeStandardHtmlChildren(children)
const textContent = innerTokens.length ? stringifyTokens(innerTokens) : href || ''
if (!normalizedChildren.length && textContent) {
normalizedChildren.push({
type: 'text',
content: textContent,
raw: textContent,
} as ParsedNode)
}
return [
{
type: 'link',
href,
title,
text: textContent,
attrs: normalizedAttrs,
children: normalizedChildren,
loading: !fragment.closed,
raw: fragment.html || code,
} as ParsedNode,
fragment.nextIndex,
]
}
if (selfClosing) {
const nodeType = customTagSet?.has(tag) ? tag : 'html_inline'
return [
{
type: nodeType,
tag,
content: code,
children: [],
raw: code,
loading: false,
} as ParsedNode,
i + 1,
]
}
const fragment = collectHtmlFragment(tokens, i, tag)
if (tag === 'p' || tag === 'div') {
const children = fragment.innerTokens.length
? parseInlineTokens(fragment.innerTokens, raw, pPreToken, options)
: []
const normalizedChildren = normalizeStandardHtmlChildren(children)
return [
{
type: 'paragraph',
children: normalizedChildren,
raw: fragment.html,
} as ParsedNode,
fragment.nextIndex,
]
}
const children = fragment.innerTokens.length
? parseInlineTokens(fragment.innerTokens, raw, pPreToken, options)
: []
const normalizedChildren = normalizeStandardHtmlChildren(children)
let content = fragment.html || code
let loading = !fragment.closed
let autoClosed = false
if (!fragment.closed) {
const closeTag = `</${tag}>`
if (!content.toLowerCase().includes(closeTag.toLowerCase()))
content += closeTag
autoClosed = true
// Still mark loading for mid-state, even though we auto-closed for rendering.
loading = true
}
const attrs = []
// 解析属性
const attrRegex = /\s([\w:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g
let match
while ((match = attrRegex.exec(code)) !== null) {
const attrName = match[1]
const attrValue = match[2] || match[3] || match[4] || ''
attrs.push([attrName, attrValue])
}
if (customTagSet?.has(tag)) {
const _content = fragment.innerTokens.length
? stringifyTokens(fragment.innerTokens)
: ''
const customChildren = fragment.innerTokens.length
? parseInlineTokens(fragment.innerTokens, raw, pPreToken, options)
: []
return [
{
type: tag,
tag,
attrs,
content: _content,
children: customChildren,
raw: content,
loading: token.loading || loading,
autoClosed,
} as ParsedNode,
fragment.nextIndex,
]
}
return [
{
type: 'html_inline',
tag,
attrs,
content,
children: normalizedChildren,
raw: content,
loading,
autoClosed,
} as ParsedNode,
fragment.nextIndex,
]
}
@@ -0,0 +1,61 @@
import type { ImageNode, MarkdownToken } from '../../types'
export function parseImageToken(token: MarkdownToken, loading = false): ImageNode {
// Some call-sites pass an outer/inline token whose children contain the
// actual image token (with attrs). Prefer token.attrs when present; when
// absent, search children for the first child that carries attrs.
let attrs = token.attrs ?? []
// If the parent token has no attrs, prefer attrs from the inner child image
// token. Remember which child provided attrs so we can prefer its content
// over the parent's `token.content` (the parent may contain the raw
// markdown string like `![alt](src "title")`).
let childWithAttrs: any = null
if ((!attrs || attrs.length === 0) && Array.isArray(token.children)) {
for (const child of token.children) {
// child.attrs may be null in markdown-it; check and use if populated
const childAttrs = (child as any)?.attrs
if (Array.isArray(childAttrs) && childAttrs.length > 0) {
attrs = childAttrs
childWithAttrs = child
break
}
}
}
const src = String(attrs.find(attr => attr[0] === 'src')?.[1] ?? '')
const altAttr = attrs.find(attr => attr[0] === 'alt')?.[1]
// Prefer a non-empty alt attribute. If attrs were sourced from an inner
// child token prefer that child's `content` over the parent's `token.content`
// because the parent may contain the raw markdown instead of the plain alt
// text.
let alt = ''
if (altAttr != null && String(altAttr).length > 0) {
alt = String(altAttr)
}
else if (childWithAttrs?.content != null && String(childWithAttrs.content).length > 0) {
alt = String(childWithAttrs.content)
}
else if (Array.isArray(childWithAttrs?.children) && childWithAttrs.children[0]?.content) {
// If the inner image token has children (e.g. a text token) prefer that
// child's content when the child token's own `content` is empty.
alt = String(childWithAttrs.children[0].content)
}
else if (Array.isArray(token.children) && token.children[0]?.content) {
alt = String(token.children[0].content)
}
else if (token.content != null && String(token.content).length > 0) {
alt = String(token.content)
}
const _title = attrs.find(attr => attr[0] === 'title')?.[1] ?? null
const title = _title === null ? null : String(_title)
const raw = String(token.content ?? '')
return {
type: 'image',
src,
alt,
title,
raw,
loading,
}
}
@@ -0,0 +1,10 @@
import type { InlineCodeNode, MarkdownToken } from '../../types'
export function parseInlineCodeToken(token: MarkdownToken): InlineCodeNode {
const code = String(token.content ?? '')
return {
type: 'inline_code',
code,
raw: code,
}
}
@@ -0,0 +1,37 @@
import type { InsertNode, MarkdownToken, ParsedNode, ParseOptions } from '../../types'
import { parseInlineTokens } from '../index'
export function parseInsertToken(
tokens: MarkdownToken[],
startIndex: number,
options?: ParseOptions,
): {
node: InsertNode
nextIndex: number
} {
const children: ParsedNode[] = []
let insText = ''
let i = startIndex + 1
const innerTokens: MarkdownToken[] = []
// Process tokens between ins_open and ins_close
while (i < tokens.length && tokens[i].type !== 'ins_close') {
insText += String(tokens[i].content ?? '')
innerTokens.push(tokens[i])
i++
}
// Parse inner tokens to handle nested elements
children.push(...parseInlineTokens(innerTokens, undefined, undefined, options as any))
const node: InsertNode = {
type: 'insert',
children,
raw: `++${String(insText)}++`,
}
// Skip to after ins_close
const nextIndex = i < tokens.length ? i + 1 : tokens.length
return { node, nextIndex }
}
@@ -0,0 +1,112 @@
import type { LinkNode, MarkdownToken, ParseOptions } from '../../types'
import { parseInlineTokens } from '../index'
type AttrTuple = [string, string]
function toAttrsTuple(attrs: MarkdownToken['attrs']): AttrTuple[] {
const tuples: AttrTuple[] = []
if (!Array.isArray(attrs))
return tuples
for (const attr of attrs) {
const key = attr?.[0]
if (!key)
continue
tuples.push([String(key), String(attr?.[1] ?? '')])
}
return tuples
}
function getAttrValue(attrs: AttrTuple[], name: string): string | undefined {
const lowerName = name.toLowerCase()
for (let i = attrs.length - 1; i >= 0; i--) {
const [key, value] = attrs[i]
if (String(key).toLowerCase() === lowerName)
return value
}
return undefined
}
function normalizeLinkAttrs(
attrs: AttrTuple[],
href: string,
title: string | null,
): AttrTuple[] {
const normalized = attrs.slice()
if (!getAttrValue(normalized, 'href'))
normalized.push(['href', href])
if (title != null && !getAttrValue(normalized, 'title'))
normalized.push(['title', title])
return normalized
}
export function parseLinkToken(
tokens: MarkdownToken[],
startIndex: number,
options?: ParseOptions,
): {
node: LinkNode
nextIndex: number
} {
const openToken = tokens[startIndex]
const attrsTuple = toAttrsTuple(openToken.attrs)
const href = String(getAttrValue(attrsTuple, 'href') ?? '')
const _title = getAttrValue(attrsTuple, 'title')
const title = _title == null ? null : String(_title)
const normalizedAttrs = normalizeLinkAttrs(attrsTuple, href, title)
let i = startIndex + 1
const linkTokens: MarkdownToken[] = []
let loading = true
// Collect all tokens between link_open and link_close
while (i < tokens.length && tokens[i].type !== 'link_close') {
linkTokens.push(tokens[i])
i++
}
if (tokens[i]?.type === 'link_close') {
loading = false
}
const lastLinkToken = linkTokens[linkTokens.length - 1]
if (
(options as any)?.__insideStrong
&& lastLinkToken?.type === 'text'
&& String(lastLinkToken.content ?? '').endsWith('**')
&& !linkTokens.some(token => token.type === 'strong_open')
) {
const originalContent = String(lastLinkToken.content ?? '')
const originalRaw = String((lastLinkToken as any).raw ?? originalContent)
lastLinkToken.content = originalContent.slice(0, -2)
lastLinkToken.raw = originalRaw.replace(/\*\*$/, '')
}
// Parse the collected tokens as inline content
const children = parseInlineTokens(linkTokens, undefined, undefined, options as any)
const linkText = children
.map((node) => {
const nodeAny = node as unknown as { content?: string, raw?: string }
if ('content' in node)
return String(nodeAny.content ?? '')
return String(nodeAny.raw ?? '')
})
.join('')
const node: LinkNode = {
type: 'link',
href,
title,
text: linkText,
children,
raw: String(`[${linkText}](${href}${title ? ` "${title}"` : ''})`),
loading,
attrs: normalizedAttrs,
}
// Skip to after link_close
const nextIndex = i < tokens.length ? i + 1 : tokens.length
return { node, nextIndex }
}
@@ -0,0 +1,14 @@
import type { MarkdownToken, MathInlineNode } from '../../types'
// Parse a math_inline token (inline math expressions)
export function parseMathInlineToken(token: MarkdownToken): MathInlineNode {
const content = token.content ?? ''
const raw = token.raw === '$$' ? `$${content}$` : token.raw || ''
return {
type: 'math_inline',
content,
loading: !!token.loading,
raw,
markup: token.markup,
}
}
@@ -0,0 +1,12 @@
import type { MarkdownToken, ReferenceNode } from '../../types'
// Parse a reference token from markdown-it
export function parseReferenceToken(token: MarkdownToken): ReferenceNode {
const id = String(token.content ?? '')
const raw = String(token.markup ?? `[${token.content ?? ''}]`)
return {
type: 'reference',
id,
raw,
}
}
@@ -0,0 +1,42 @@
import type {
MarkdownToken,
ParsedNode,
ParseOptions,
StrikethroughNode,
} from '../../types'
import { parseInlineTokens } from '../index'
export function parseStrikethroughToken(
tokens: MarkdownToken[],
startIndex: number,
options?: ParseOptions,
): {
node: StrikethroughNode
nextIndex: number
} {
const children: ParsedNode[] = []
let sText = ''
let i = startIndex + 1
const innerTokens: MarkdownToken[] = []
// Process tokens between s_open and s_close
while (i < tokens.length && tokens[i].type !== 's_close') {
sText += String(tokens[i].content ?? '')
innerTokens.push(tokens[i])
i++
}
// Parse inner tokens to handle nested elements
children.push(...parseInlineTokens(innerTokens, undefined, undefined, options as any))
const node: StrikethroughNode = {
type: 'strikethrough',
children,
raw: `~~${sText}~~`,
}
// Skip to after s_close
const nextIndex = i < tokens.length ? i + 1 : tokens.length
return { node, nextIndex }
}
@@ -0,0 +1,71 @@
import type { MarkdownToken, ParsedNode, ParseOptions, StrongNode } from '../../types'
import { parseInlineTokens } from '../index'
const ESCAPED_PUNCTUATION_RE = /\\([\\()[\]`$|*_\-!])/g
function resolveInnerRaw(raw: string | undefined, strongText: string) {
if (!raw)
return undefined
const rawText = String(raw)
if (!rawText)
return undefined
if (rawText === strongText)
return rawText
const decodedRawText = rawText.replace(ESCAPED_PUNCTUATION_RE, '$1')
if (decodedRawText === strongText)
return rawText
return undefined
}
export function parseStrongToken(
tokens: MarkdownToken[],
startIndex: number,
raw?: string,
options?: ParseOptions,
): {
node: StrongNode
nextIndex: number
} {
const children: ParsedNode[] = []
let strongText = ''
let i = startIndex + 1
const innerTokens: MarkdownToken[] = []
// Process tokens between strong_open and strong_close
// 这里可能会遇到多个 strong_open, 需要记录嵌套层级
let openCount = 1
while (i < tokens.length) {
if (tokens[i].type === 'strong_close') {
if (openCount === 1)
break
openCount--
}
if (tokens[i].type === 'strong_open') {
openCount++
}
strongText += String(tokens[i].content ?? '')
innerTokens.push(tokens[i])
i++
}
// Parse inner tokens to handle nested elements
children.push(...parseInlineTokens(innerTokens, resolveInnerRaw(raw, strongText), undefined, {
...(options as any),
__insideStrong: true,
}))
const node: StrongNode = {
type: 'strong',
children,
raw: `**${String(strongText)}**`,
}
// Skip to after strong_close
const nextIndex = i < tokens.length ? i + 1 : tokens.length
return { node, nextIndex }
}
@@ -0,0 +1,48 @@
import type { MarkdownToken, ParsedNode, ParseOptions, SubscriptNode } from '../../types'
import { parseInlineTokens } from '../index'
export function parseSubscriptToken(
tokens: MarkdownToken[],
startIndex: number,
options?: ParseOptions,
): {
node: SubscriptNode
nextIndex: number
} {
const children: ParsedNode[] = []
let subText = ''
let i = startIndex + 1
const innerTokens: MarkdownToken[] = []
// Process tokens between sub_open and sub_close (if applicable)
while (i < tokens.length && tokens[i].type !== 'sub_close') {
subText += String(tokens[i].content ?? '')
innerTokens.push(tokens[i])
i++
}
// Parse inner tokens to handle nested elements
children.push(...parseInlineTokens(innerTokens, undefined, undefined, options as any))
const startContent = String(tokens[startIndex].content ?? '')
const display = subText || startContent
const node: SubscriptNode = {
type: 'subscript',
children: children.length > 0
? children
: [
{
type: 'text',
// Fallback to the collected inner text (e.g., "2" in H~2~O)
content: display,
raw: display,
},
],
raw: `~${display}~`,
}
// Skip to after sub_close
const nextIndex = i < tokens.length ? i + 1 : tokens.length
return { node, nextIndex }
}
@@ -0,0 +1,47 @@
import type { MarkdownToken, ParsedNode, ParseOptions, SuperscriptNode } from '../../types'
import { parseInlineTokens } from '../index'
export function parseSuperscriptToken(
tokens: MarkdownToken[],
startIndex: number,
options?: ParseOptions,
): {
node: SuperscriptNode
nextIndex: number
} {
const children: ParsedNode[] = []
let supText = ''
let i = startIndex + 1
const innerTokens: MarkdownToken[] = []
// Process tokens between sup_open and sup_close (if applicable)
while (i < tokens.length && tokens[i].type !== 'sup_close') {
supText += String(tokens[i].content ?? '')
innerTokens.push(tokens[i])
i++
}
// Parse inner tokens to handle nested elements
children.push(...parseInlineTokens(innerTokens, undefined, undefined, options as any))
const node: SuperscriptNode = {
type: 'superscript',
children:
children.length > 0
? children
: [
{
type: 'text',
// Fallback to the collected inner text (e.g., "2" in x^2^)
content: supText || String(tokens[startIndex].content ?? ''),
raw: supText || String(tokens[startIndex].content ?? ''),
},
],
raw: `^${supText || String(tokens[startIndex].content ?? '')}^`,
}
// Skip to after sup_close
const nextIndex = i < tokens.length ? i + 1 : tokens.length
return { node, nextIndex }
}
@@ -0,0 +1,10 @@
import type { MarkdownToken, TextNode } from '../../types'
export function parseTextToken(token: MarkdownToken): TextNode {
const content = String(token.content ?? '')
return {
type: 'text',
content,
raw: content,
}
}
@@ -0,0 +1,142 @@
const FILENAMEISH_EXTENSION_RE = /\.([a-z0-9]{1,10})$/i
const FILENAMEISH_SEGMENT_RE = /[_()[\]{}<>]/u
const URL_PREFIX_HINT_RE = /^(?:https?:\/\/|ftp:\/\/|mailto:|www\.)/i
const URL_QUERY_OR_AUTH_HINT_RE = /[?#@]/u
const PATH_SEPARATOR_RE = /[\\/]/u
const DOMAINISH_TEXT_RE = /^[\p{L}\p{N}./\\-]+$/u
const DOMAIN_LABEL_RE = /^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$/u
const PUNYCODE_TLD_RE = /^xn--[a-z0-9-]{2,59}$/i
const AMBIGUOUS_BARE_DOMAIN_EXTENSIONS = new Set([
'ai',
'md',
'py',
'rs',
'sh',
'zip',
])
const FILENAMEISH_LINK_EXTENSIONS = new Set([
'7z',
'ai',
'astro',
'avi',
'bash',
'bz2',
'c',
'cjs',
'cpp',
'cs',
'csv',
'doc',
'docx',
'fish',
'flac',
'gif',
'go',
'gz',
'h',
'hpp',
'html',
'java',
'jpeg',
'jpg',
'js',
'json',
'jsx',
'kt',
'md',
'mdx',
'mjs',
'mov',
'mp3',
'mp4',
'pdf',
'php',
'png',
'ppt',
'pptx',
'ps1',
'py',
'rar',
'rb',
'rs',
'sh',
'sql',
'svg',
'swift',
'svelte',
'tar',
'tgz',
'toml',
'ts',
'tsx',
'txt',
'vue',
'wav',
'webp',
'xls',
'xlsx',
'xml',
'yaml',
'yml',
'zip',
'zsh',
])
function isPlausibleBareDomain(text: string) {
const labels = text.split('.')
if (labels.length < 2)
return false
const tld = labels[labels.length - 1]?.toLowerCase() ?? ''
if (!(DOMAIN_LABEL_RE.test(tld) || PUNYCODE_TLD_RE.test(tld)))
return false
return labels.every(label => DOMAIN_LABEL_RE.test(label))
}
function hasDomainAuthorityPrefix(text: string) {
const prefix = text.split(/[\\/]/)[0] ?? ''
return isPlausibleBareDomain(prefix)
}
function isUppercaseFilenameSegment(segment: string) {
const lettersOnly = segment.replace(/[^a-z]/gi, '')
return lettersOnly.length >= 2 && lettersOnly === lettersOnly.toUpperCase()
}
function hasStrongFilenameSignals(linkText: string) {
if (FILENAMEISH_SEGMENT_RE.test(linkText))
return true
if (!DOMAINISH_TEXT_RE.test(linkText))
return true
if (PATH_SEPARATOR_RE.test(linkText))
return !hasDomainAuthorityPrefix(linkText)
const extensionless = linkText.replace(FILENAMEISH_EXTENSION_RE, '')
const filenameLikeSegments = extensionless.split('.').filter(Boolean)
return filenameLikeSegments.some(isUppercaseFilenameSegment)
}
export function shouldDemoteFilenameLikeLinkify(linkText: string) {
if (!linkText || URL_PREFIX_HINT_RE.test(linkText) || URL_QUERY_OR_AUTH_HINT_RE.test(linkText))
return false
const extensionMatch = linkText.match(FILENAMEISH_EXTENSION_RE)
if (!extensionMatch)
return false
const extension = String(extensionMatch[1] ?? '').toLowerCase()
if (!FILENAMEISH_LINK_EXTENSIONS.has(extension))
return false
// Extensions like `.ai` and `.md` can be both real bare-domain TLDs and
// common filenames. Keep those linkified unless we also see stronger
// filename-only signals such as path separators, underscores, brackets, or
// all-uppercase filename segments like `README`.
if (!AMBIGUOUS_BARE_DOMAIN_EXTENSIONS.has(extension))
return true
return hasStrongFilenameSignals(linkText)
}
@@ -0,0 +1,66 @@
import type { AdmonitionNode, MarkdownToken, ParsedNode, ParseOptions } from '../../types'
import { parseInlineTokens } from '../inline-parsers'
import { parseBasicBlockToken } from './block-token-parser'
import { parseBlockquote } from './blockquote-parser'
import { parseList } from './list-parser'
export function parseAdmonition(
tokens: MarkdownToken[],
index: number,
match: RegExpExecArray,
options?: ParseOptions,
): [AdmonitionNode, number] {
const kind = String(match[1] ?? 'note')
const title = String(match[2] ?? (kind.charAt(0).toUpperCase() + kind.slice(1)))
const admonitionChildren: ParsedNode[] = []
let j = index + 1
while (j < tokens.length && tokens[j].type !== 'container_close') {
if (tokens[j].type === 'paragraph_open') {
const contentToken = tokens[j + 1]
if (contentToken) {
admonitionChildren.push({
type: 'paragraph',
children: parseInlineTokens(contentToken.children || [], String(contentToken.content ?? ''), undefined, options as any),
raw: String(contentToken.content ?? ''),
})
}
j += 3 // Skip paragraph_open, inline, paragraph_close
}
else if (
tokens[j].type === 'bullet_list_open'
|| tokens[j].type === 'ordered_list_open'
) {
const [listNode, newIndex] = parseList(tokens, j, options)
admonitionChildren.push(listNode)
j = newIndex
}
else if (tokens[j].type === 'blockquote_open') {
const [blockquoteNode, newIndex] = parseBlockquote(tokens, j, options)
admonitionChildren.push(blockquoteNode)
j = newIndex
}
else {
const handled = parseBasicBlockToken(tokens, j, options)
if (handled) {
admonitionChildren.push(handled[0])
j = handled[1]
}
else {
j++
}
}
}
const admonitionNode: AdmonitionNode = {
type: 'admonition',
kind,
title,
children: admonitionChildren,
raw: `:::${kind} ${title}\n${admonitionChildren
.map(child => child.raw)
.join('\n')}\n:::`,
}
return [admonitionNode, j + 1] // Skip container_close
}
@@ -0,0 +1,518 @@
import type { AdmonitionNode, MarkdownToken, ParsedNode, ParseOptions, VmrContainerNode } from '../../types'
import { escapeTagForRegExp, findTagCloseIndexOutsideQuotes } from '../../htmlTagUtils'
import { normalizeCustomTag } from '../customHtmlTags'
import { buildAllowedHtmlTagSet } from '../index'
import { parseInlineTokens } from '../inline-parsers'
import { parseFenceToken } from '../inline-parsers/fence-parser'
import { parseBlockquote } from './blockquote-parser'
import { parseCodeBlock } from './code-block-parser'
import { parseDefinitionList } from './definition-list-parser'
import { parseFootnote } from './footnote-parser'
import { parseHeading } from './heading-parser'
import { parseHtmlBlock } from './html-block-parser'
import { parseList } from './list-parser'
import { parseMathBlock } from './math-block-parser'
import { parseTable } from './table-parser'
import { parseThematicBreak } from './thematic-break-parser'
interface HtmlTagSetCacheEntry {
allowedTagSet: Set<string>
customTagSet: Set<string> | null
}
let emptyHtmlTagSets: HtmlTagSetCacheEntry | null = null
const HTML_TAG_SET_CACHE = new WeakMap<readonly string[], HtmlTagSetCacheEntry>()
function getEmptyHtmlTagSets() {
if (!emptyHtmlTagSets) {
emptyHtmlTagSets = {
allowedTagSet: buildAllowedHtmlTagSet(),
customTagSet: null,
}
}
return emptyHtmlTagSets
}
function getHtmlTagSets(customTags?: readonly string[]) {
if (!customTags || customTags.length === 0)
return getEmptyHtmlTagSets()
const cached = HTML_TAG_SET_CACHE.get(customTags)
if (cached)
return cached
const normalized = customTags.map(normalizeCustomTag).filter(Boolean)
if (!normalized.length) {
const entry = getEmptyHtmlTagSets()
HTML_TAG_SET_CACHE.set(customTags, entry)
return entry
}
const entry = {
allowedTagSet: buildAllowedHtmlTagSet({ customHtmlTags: customTags as any }),
customTagSet: new Set(normalized),
}
HTML_TAG_SET_CACHE.set(customTags, entry)
return entry
}
function parseVmrContainer(
tokens: MarkdownToken[],
index: number,
options?: ParseOptions,
): [VmrContainerNode, number] {
const openToken = tokens[index]
// Extract name from class attribute (e.g., "vmr-container vmr-container-viewcode:topo-test-001")
// Handle multiple class values by looking for vmr-container-* anywhere in the string
const attrs = openToken.attrs as [string, string][] | null
let name = ''
const containerAttrs: Record<string, string> = {}
if (attrs) {
for (const [key, value] of attrs) {
if (key === 'class') {
// Match vmr-container-* anywhere in the class string, not just at the end
const match = value.match(/(?:\s|^)vmr-container-(\S+)/)
if (match) {
name = match[1]
}
}
else if (key.startsWith('data-')) {
// Extract data attributes (e.g., "data-devId" -> "devId")
const attrName = key.slice(5)
// Try to parse JSON values; fallback to raw string if parsing fails
try {
containerAttrs[attrName] = JSON.parse(value)
}
catch {
containerAttrs[attrName] = value
}
}
}
}
const children: ParsedNode[] = []
let j = index + 1
// Parse children until we find the closing token
while (j < tokens.length && tokens[j].type !== 'vmr_container_close') {
if (tokens[j].type === 'paragraph_open') {
// Handle paragraph tokens (plain text)
const contentToken = tokens[j + 1]
if (contentToken) {
const childrenArr = (contentToken.children as MarkdownToken[]) || []
children.push({
type: 'paragraph',
children: parseInlineTokens(childrenArr || [], undefined, undefined, options as any),
raw: String(contentToken.content ?? ''),
})
}
j += 3 // Skip paragraph_open, inline, paragraph_close
}
else if (
tokens[j].type === 'bullet_list_open'
|| tokens[j].type === 'ordered_list_open'
) {
// Handle list tokens
const [listNode, newIndex] = parseList(tokens, j, options)
children.push(listNode)
j = newIndex
}
else if (tokens[j].type === 'blockquote_open') {
// Handle blockquote tokens
const [blockquoteNode, newIndex] = parseBlockquote(tokens, j, options)
children.push(blockquoteNode)
j = newIndex
}
else {
// Handle other basic block tokens (heading, code_block, fence, etc.)
const handled = parseBasicBlockToken(tokens, j, options)
if (handled) {
children.push(handled[0])
j = handled[1]
}
else {
j++
}
}
}
const hasCloseToken = j < tokens.length && tokens[j].type === 'vmr_container_close'
const closed = hasCloseToken || !!options?.final
// Build raw content
let raw = `::: ${name}`
if (Object.keys(containerAttrs).length > 0) {
raw += ` ${JSON.stringify(containerAttrs)}`
}
raw += '\n'
if (children.length > 0) {
// Prefer original Markdown (openToken.raw); rebuilding from tokens may lose formatting.
raw += openToken.raw ?? children.map(c => c.raw).join('\n')
raw += '\n'
}
raw += ':::'
const containerNode: VmrContainerNode = {
type: 'vmr_container',
name,
loading: !closed,
attrs: Object.keys(containerAttrs).length > 0 ? containerAttrs : undefined,
children,
raw,
}
// Skip the closing token when present; otherwise we're already at the end.
return [containerNode, hasCloseToken ? (j + 1) : j]
}
function stripWrapperNewlines(s: string) {
// Preserve inner whitespace/indentation, but drop a single leading/trailing newline
// introduced by the common pattern: <tag>\n...\n</tag>
return s.replace(/^\r?\n/, '').replace(/\r?\n$/, '')
}
function stripTrailingPartialClosingTag(inner: string, tag: string) {
if (!inner || !tag)
return inner
const re = new RegExp(String.raw`[\t ]*<\s*\/\s*${tag}[^>]*$`, 'i')
return inner.replace(re, '')
}
function findMatchingCloseTagRange(
rawHtml: string,
tag: string,
startIndex: number,
) {
if (!rawHtml || !tag)
return null
const lowerTag = tag.toLowerCase()
const openTagRe = new RegExp(String.raw`^<\s*${escapeTagForRegExp(lowerTag)}(?=\s|>|/)`, 'i')
const closeTagRe = new RegExp(String.raw`^<\s*\/\s*${escapeTagForRegExp(lowerTag)}(?=\s|>)`, 'i')
let depth = 0
let index = Math.max(0, startIndex)
while (index < rawHtml.length) {
const lt = rawHtml.indexOf('<', index)
if (lt === -1)
break
const slice = rawHtml.slice(lt)
if (closeTagRe.test(slice)) {
const endRel = findTagCloseIndexOutsideQuotes(slice)
if (endRel === -1)
return null
if (depth === 0) {
return {
start: lt,
end: lt + endRel + 1,
}
}
depth--
index = lt + endRel + 1
continue
}
if (openTagRe.test(slice)) {
const endRel = findTagCloseIndexOutsideQuotes(slice)
if (endRel === -1)
return null
const raw = slice.slice(0, endRel + 1)
if (!/\/\s*>$/.test(raw))
depth++
index = lt + endRel + 1
continue
}
index = lt + 1
}
return null
}
function findNextCustomHtmlBlockFromSource(
source: string,
tag: string,
startIndex: number,
): { raw: string, end: number } | null {
if (!source || !tag)
return null
const lowerTag = tag.toLowerCase()
const openRe = new RegExp(String.raw`<\s*${lowerTag}(?=\s|>|/)`, 'gi')
openRe.lastIndex = Math.max(0, startIndex || 0)
const openMatch = openRe.exec(source)
if (!openMatch || openMatch.index == null)
return null
const openStart = openMatch.index
const openSlice = source.slice(openStart)
const openEndRel = findTagCloseIndexOutsideQuotes(openSlice)
if (openEndRel === -1)
return null
const openEnd = openStart + openEndRel
// Self-closing custom tags: treat as a complete block
if (/\/\s*>\s*$/.test(openSlice.slice(0, openEndRel + 1))) {
const end = openEnd + 1
return { raw: source.slice(openStart, end), end }
}
let depth = 1
let i = openEnd + 1
const isOpenAt = (pos: number) => {
const s = source.slice(pos)
return new RegExp(String.raw`^<\s*${lowerTag}(?=\s|>|/)`, 'i').test(s)
}
const isCloseAt = (pos: number) => {
const s = source.slice(pos)
return new RegExp(String.raw`^<\s*\/\s*${lowerTag}(?=\s|>)`, 'i').test(s)
}
while (i < source.length) {
const lt = source.indexOf('<', i)
if (lt === -1) {
// No more tags in the remaining source; treat as unclosed streaming block.
return { raw: source.slice(openStart), end: source.length }
}
if (isCloseAt(lt)) {
const gt = source.indexOf('>', lt)
if (gt === -1)
return null
depth--
if (depth === 0) {
const end = gt + 1
return { raw: source.slice(openStart, end), end }
}
i = gt + 1
continue
}
if (isOpenAt(lt)) {
const rel = findTagCloseIndexOutsideQuotes(source.slice(lt))
if (rel === -1)
return null
depth++
i = lt + rel + 1
continue
}
i = lt + 1
}
// If the closing tag hasn't arrived yet (streaming), return a partial block
// from the opening tag to end-of-source. This preserves original lines like
// `---` so inner markdown can render progressively.
return { raw: source.slice(openStart), end: source.length }
}
function clampNonNegative(n: number) {
return Number.isFinite(n) && n > 0 ? n : 0
}
function lineToIndex(source: string, line: number) {
// markdown-it token.map uses 0-based line numbers.
const targetLine = clampNonNegative(line)
if (!source || targetLine <= 0)
return 0
let currentLine = 0
for (let i = 0; i < source.length; i++) {
if (source[i] === '\n') {
currentLine++
if (currentLine === targetLine)
return i + 1
}
}
return source.length
}
export function parseBasicBlockToken(
tokens: MarkdownToken[],
index: number,
options?: ParseOptions,
): [ParsedNode, number] | null {
const token = tokens[index]
switch (token.type) {
case 'heading_open':
return [parseHeading(tokens, index, options), index + 3]
case 'code_block': {
return [parseCodeBlock(token), index + 1]
}
case 'fence':
return [parseFenceToken(token), index + 1]
case 'math_block':
return [parseMathBlock(token), index + 1]
case 'html_block': {
const htmlBlockNode = parseHtmlBlock(token)
const tagSets = htmlBlockNode.tag ? getHtmlTagSets(options?.customHtmlTags) : null
if (htmlBlockNode.tag && htmlBlockNode.loading && tagSets && !tagSets.allowedTagSet.has(htmlBlockNode.tag)) {
const raw = String((token as any)?.content ?? '')
const content = raw.replace(/\n+$/, '')
return [
{
type: 'paragraph',
children: content ? [{ type: 'text', content, raw: content }] : [],
raw: content,
} as any,
index + 1,
]
}
if (htmlBlockNode.tag && tagSets?.customTagSet?.has(htmlBlockNode.tag)) {
const tag = htmlBlockNode.tag
// markdown-it can normalize html_block token.content and lose original lines.
// Re-extract the next full <tag>...</tag> block from the original source.
const source = String((options as any)?.__sourceMarkdown ?? '')
const cursor = Number((options as any)?.__customHtmlBlockCursor ?? 0)
// If markdown-it provides a source map for this token, prefer anchoring the
// re-extraction to that line range. This avoids accidentally matching an
// earlier occurrence of the same custom tag that was tokenized as inline.
const mappedLineStart = Array.isArray((token as any).map)
? lineToIndex(source, Number((token as any).map?.[0] ?? 0))
: 0
const searchStart = Math.max(clampNonNegative(cursor), clampNonNegative(mappedLineStart))
const fromSource = findNextCustomHtmlBlockFromSource(source, tag, searchStart)
if (fromSource)
(options as any).__customHtmlBlockCursor = fromSource.end
const rawHtml = String(fromSource?.raw ?? htmlBlockNode.raw ?? '')
const openEnd = findTagCloseIndexOutsideQuotes(rawHtml)
const openTag = openEnd !== -1 ? rawHtml.slice(0, openEnd + 1) : rawHtml
const selfClosing = openEnd !== -1 && /\/\s*>\s*$/.test(openTag)
const closeRange = openEnd === -1
? null
: findMatchingCloseTagRange(rawHtml, tag, openEnd + 1)
const closeIndex = closeRange?.start ?? -1
let inner = ''
if (openEnd !== -1) {
if (closeIndex !== -1 && openEnd < closeIndex)
inner = rawHtml.slice(openEnd + 1, closeIndex)
else
inner = rawHtml.slice(openEnd + 1)
}
// Streaming mid-state: if the closing tag is being typed but not yet
// complete, don't leak the partial `</tag` into content.
if (closeIndex === -1)
inner = stripTrailingPartialClosingTag(inner, tag)
// Parse attrs from the opening tag only
const attrs: [string, string][] = []
const attrRegex = /\s([\w:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g
let m
while ((m = attrRegex.exec(openTag)) !== null) {
const name = m[1]
if (!name || name.toLowerCase() === tag)
continue
const value = m[2] || m[3] || m[4] || ''
attrs.push([name, value])
}
const loading = !options?.final && !selfClosing && closeRange == null
return [
{
type: tag,
tag,
content: stripWrapperNewlines(inner),
raw: String(fromSource?.raw ?? htmlBlockNode.raw ?? rawHtml),
loading,
attrs: attrs.length ? attrs : undefined,
} as ParsedNode,
index + 1,
]
}
return [htmlBlockNode, index + 1]
}
case 'table_open': {
const [tableNode, newIndex] = parseTable(tokens, index, options)
return [tableNode, newIndex]
}
case 'dl_open': {
const [definitionListNode, newIndex] = parseDefinitionList(tokens, index, options)
return [definitionListNode, newIndex]
}
case 'footnote_open': {
const [footnoteNode, newIndex] = parseFootnote(tokens, index, options)
return [footnoteNode, newIndex]
}
case 'hr':
return [parseThematicBreak(), index + 1]
default:
break
}
return null
}
type ContainerParser = (
tokens: MarkdownToken[],
index: number,
options?: ParseOptions,
) => [AdmonitionNode, number]
type ContainerMatcher = (
tokens: MarkdownToken[],
index: number,
options?: ParseOptions,
) => [AdmonitionNode, number] | null
export function parseCommonBlockToken(
tokens: MarkdownToken[],
index: number,
options?: ParseOptions,
handlers?: {
parseContainer?: ContainerParser
matchAdmonition?: ContainerMatcher
},
): [ParsedNode, number] | null {
const basicResult = parseBasicBlockToken(tokens, index, options)
if (basicResult)
return basicResult
const token = tokens[index]
switch (token.type) {
case 'container_warning_open':
case 'container_info_open':
case 'container_note_open':
case 'container_tip_open':
case 'container_danger_open':
case 'container_caution_open':
case 'container_error_open': {
if (handlers?.parseContainer)
return handlers.parseContainer(tokens, index, options)
break
}
case 'container_open': {
if (handlers?.matchAdmonition) {
const result = handlers.matchAdmonition(tokens, index, options)
if (result)
return result
}
break
}
case 'vmr_container_open': {
return parseVmrContainer(tokens, index, options)
}
default:
break
}
return null
}
@@ -0,0 +1,63 @@
import type { BlockquoteNode, MarkdownToken, ParsedNode, ParseOptions } from '../../types'
import { parseInlineTokens } from '../inline-parsers'
import { parseCommonBlockToken } from './block-token-parser'
import { containerTokenHandlers } from './container-token-handlers'
import { parseList } from './list-parser'
export function parseBlockquote(
tokens: MarkdownToken[],
index: number,
options?: ParseOptions,
): [BlockquoteNode, number] {
const blockquoteChildren: ParsedNode[] = []
let j = index + 1
// Process blockquote content until closing tag is found
while (j < tokens.length && tokens[j].type !== 'blockquote_close') {
const token = tokens[j]
switch (token.type) {
case 'paragraph_open': {
const contentToken = tokens[j + 1]
blockquoteChildren.push({
type: 'paragraph',
children: parseInlineTokens(contentToken.children || [], String(contentToken.content ?? ''), undefined, options as any),
raw: String(contentToken.content ?? ''),
})
j += 3 // Skip paragraph_open, inline, paragraph_close
break
}
case 'bullet_list_open':
case 'ordered_list_open': {
const [listNode, newIndex] = parseList(tokens, j, options)
blockquoteChildren.push(listNode)
j = newIndex
break
}
case 'blockquote_open': {
const [nestedBlockquote, newIndex] = parseBlockquote(tokens, j, options)
blockquoteChildren.push(nestedBlockquote)
j = newIndex
break
}
default:{
const handled = parseCommonBlockToken(tokens, j, options, containerTokenHandlers)
if (handled) {
blockquoteChildren.push(handled[0])
j = handled[1]
}
else {
j++
}
break
}
}
}
const blockquoteNode: BlockquoteNode = {
type: 'blockquote',
children: blockquoteChildren,
raw: blockquoteChildren.map(child => child.raw).join('\n'),
}
return [blockquoteNode, j + 1] // Skip blockquote_close
}
@@ -0,0 +1,29 @@
import type { CodeBlockNode, MarkdownToken } from '../../types'
import { parseFenceToken } from '../inline-parsers/fence-parser'
export function parseCodeBlock(token: MarkdownToken): CodeBlockNode {
// If this code block is actually a diff (some markdown-it backends
// classify fences vs code_block differently), delegate to the
// fence parser to preserve original/updated fields.
if (token.info?.startsWith('diff')) {
return parseFenceToken(token)
}
const contentStr = String(token.content ?? '')
const match = contentStr.match(/ type="application\/vnd\.ant\.([^"]+)"/)
if (match?.[1]) {
// 需要把 <antArtifact> 标签去掉
// mutate token.content safely by assigning the cleaned string
token.content = contentStr
.replace(/<antArtifact[^>]*>/g, '')
.replace(/<\/antArtifact>/g, '')
}
const hasMap = Array.isArray(token.map) && token.map.length === 2
return {
type: 'code_block',
language: match ? match[1] : String(token.info ?? ''),
code: String(token.content ?? ''),
raw: String(token.content ?? ''),
loading: !hasMap,
}
}
@@ -0,0 +1,137 @@
import type { AdmonitionNode, MarkdownToken, ParsedNode, ParseOptions, TextNode } from '../../types'
import { parseInlineTokens } from '../inline-parsers'
import { parseBasicBlockToken } from './block-token-parser'
import { parseBlockquote } from './blockquote-parser'
import { parseList } from './list-parser'
const CONTAINER_KINDS = new Set(['warning', 'info', 'note', 'tip', 'danger', 'caution'])
function parseContainerInfo(info: string) {
let markerEnd = 0
while (markerEnd < info.length && markerEnd < 3 && info[markerEnd] === ':')
markerEnd++
if (markerEnd === 0 || info[markerEnd] === ':')
return null
const rest = info.slice(markerEnd).trimStart()
if (!rest)
return null
const firstWhitespace = rest.search(/\s/)
const rawKind = (firstWhitespace === -1 ? rest : rest.slice(0, firstWhitespace)).toLowerCase()
if (!CONTAINER_KINDS.has(rawKind))
return null
const title = firstWhitespace === -1 ? '' : rest.slice(firstWhitespace).trim()
return { kind: rawKind, title }
}
export function parseContainer(
tokens: MarkdownToken[],
index: number,
options?: ParseOptions,
): [AdmonitionNode, number] {
const openToken = tokens[index]
// Determine kind and optional title
let kind = 'note'
let title = ''
const typeMatch = openToken.type.match(/^container_(\w+)_open$/)
if (typeMatch) {
kind = typeMatch[1]
// some implementations set info to remaining title text
const info = String(openToken.info ?? '').trim()
if (info && !info.startsWith(':::')) {
// if info looks like 'warning title', drop leading kind token
const lowerInfo = info.toLowerCase()
if (lowerInfo.startsWith(kind)) {
const maybe = info.slice(kind.length).trim()
if (maybe)
title = maybe
}
}
}
else {
// container_open: info usually contains the marker like ' warning Title'
const info = String(openToken.info ?? '').trim()
const parsedInfo = parseContainerInfo(info)
if (parsedInfo) {
kind = parsedInfo.kind
title = parsedInfo.title
}
}
if (!title)
title = kind.charAt(0).toUpperCase() + kind.slice(1)
const children: ParsedNode[] = []
let j = index + 1
// Accept closing tokens: 'container_close' or 'container_<kind>_close'
const closeType = new RegExp(`^container_${kind}_close$`)
while (
j < tokens.length
&& tokens[j].type !== 'container_close'
&& !closeType.test(tokens[j].type)
) {
if (tokens[j].type === 'paragraph_open') {
const contentToken = tokens[j + 1]
if (contentToken) {
const childrenArr = (contentToken.children as MarkdownToken[]) || []
let i = -1
for (let k = childrenArr.length - 1; k >= 0; k--) {
const t = childrenArr[k] as TextNode
if (t.type === 'text' && /:+/.test(t.content)) {
i = k
break
}
}
const _children = i !== -1 ? childrenArr.slice(0, i) : childrenArr
children.push({
type: 'paragraph',
children: parseInlineTokens(_children || [], undefined, undefined, options as any),
raw: String(contentToken.content ?? '').replace(/\n:+$/, '').replace(/\n\s*:::\s*$/, ''),
})
}
j += 3
}
else if (
tokens[j].type === 'bullet_list_open'
|| tokens[j].type === 'ordered_list_open'
) {
const [listNode, newIndex] = parseList(tokens, j, options)
children.push(listNode)
j = newIndex
}
else if (tokens[j].type === 'blockquote_open') {
const [blockquoteNode, newIndex] = parseBlockquote(tokens, j, options)
children.push(blockquoteNode)
j = newIndex
}
else {
const handled = parseBasicBlockToken(tokens, j, options)
if (handled) {
children.push(handled[0])
j = handled[1]
}
else {
j++
}
}
}
const admonitionNode: AdmonitionNode = {
type: 'admonition',
kind,
title,
children,
raw: `:::${kind} ${title}\n${children.map(c => c.raw).join('\n')}\n:::`,
}
// Skip the closing token
const closingIndex = j
return [admonitionNode, closingIndex + 1]
}
@@ -0,0 +1,29 @@
import type { AdmonitionNode, MarkdownToken, ParseOptions } from '../../types'
import { parseAdmonition } from './admonition-parser'
import { parseContainer } from './container-parser'
const CONTAINER_REGEX
= /^::: ?(warning|info|note|tip|danger|caution|error) ?(.*)$/
function handleContainerOpen(
tokens: MarkdownToken[],
index: number,
options?: ParseOptions,
): [AdmonitionNode, number] | null {
const token = tokens[index]
if (token.type !== 'container_open')
return null
const match = CONTAINER_REGEX.exec(String(token.info ?? ''))
if (!match)
return null
return parseAdmonition(tokens, index, match, options)
}
export const containerTokenHandlers = {
parseContainer: (
tokens: MarkdownToken[],
index: number,
options?: ParseOptions,
) => parseContainer(tokens, index, options),
matchAdmonition: handleContainerOpen,
}
@@ -0,0 +1,76 @@
import type {
DefinitionItemNode,
DefinitionListNode,
MarkdownToken,
ParsedNode,
ParseOptions,
} from '../../types'
import { parseInlineTokens } from '../inline-parsers'
export function parseDefinitionList(
tokens: MarkdownToken[],
index: number,
options?: ParseOptions,
): [DefinitionListNode, number] {
const items: DefinitionItemNode[] = []
let j = index + 1
let termNodes: ParsedNode[] = []
let definitionNodes: ParsedNode[] = []
while (j < tokens.length && tokens[j].type !== 'dl_close') {
if (tokens[j].type === 'dt_open') {
// Process term
const termToken = tokens[j + 1]
termNodes = parseInlineTokens(termToken.children || [], undefined, undefined, options as any)
j += 3 // Skip dt_open, inline, dt_close
}
else if (tokens[j].type === 'dd_open') {
// Process definition
let k = j + 1
definitionNodes = []
while (k < tokens.length && tokens[k].type !== 'dd_close') {
if (tokens[k].type === 'paragraph_open') {
const contentToken = tokens[k + 1]
definitionNodes.push({
type: 'paragraph',
children: parseInlineTokens(contentToken.children || [], String(contentToken.content ?? ''), undefined, options as any),
raw: String(contentToken.content ?? ''),
})
k += 3 // Skip paragraph_open, inline, paragraph_close
}
else {
k++
}
}
// Add definition item
if (termNodes.length > 0) {
items.push({
type: 'definition_item',
term: termNodes,
definition: definitionNodes,
raw: `${termNodes.map(term => term.raw).join('')}: ${definitionNodes
.map(def => def.raw)
.join('\n')}`,
})
// Reset term nodes
termNodes = []
}
j = k + 1 // Skip dd_close
}
else {
j++
}
}
const definitionListNode: DefinitionListNode = {
type: 'definition_list',
items,
raw: items.map(item => item.raw).join('\n'),
}
return [definitionListNode, j + 1] // Skip dl_close
}
@@ -0,0 +1,43 @@
import type { FootnoteNode, MarkdownToken, ParsedNode, ParseOptions } from '../../types'
import { parseInlineTokens } from '../inline-parsers'
export function parseFootnote(
tokens: MarkdownToken[],
index: number,
options?: ParseOptions,
): [FootnoteNode, number] {
const token = tokens[index]
const meta = (token.meta ?? {}) as unknown as { label?: number | string }
const id = String(meta?.label ?? '0')
const footnoteChildren: ParsedNode[] = []
let j = index + 1
while (j < tokens.length && tokens[j].type !== 'footnote_close') {
if (tokens[j].type === 'paragraph_open') {
const contentToken = tokens[j + 1]
const children = contentToken.children || []
if (tokens[j + 2].type === 'footnote_anchor') {
children.push(tokens[j + 2] as any)
}
footnoteChildren.push({
type: 'paragraph',
children: parseInlineTokens(contentToken.children || [], String(contentToken.content ?? ''), undefined, options as any),
raw: String(contentToken.content ?? ''),
})
j += 3 // Skip paragraph_open, inline, paragraph_close
}
else {
j++
}
}
const footnoteNode: FootnoteNode = {
type: 'footnote',
id,
children: footnoteChildren,
raw: `[^${id}]: ${footnoteChildren.map(child => child.raw).join('\n')}`,
}
return [footnoteNode, j + 1] // Skip footnote_close
}
@@ -0,0 +1,8 @@
import type { HardBreakNode } from '../../types'
export function parseHardBreak(): HardBreakNode {
return {
type: 'hardbreak',
raw: '\\\n',
}
}
@@ -0,0 +1,31 @@
import type { HeadingNode, MarkdownToken, ParseOptions } from '../../types'
import { parseInlineTokens } from '../inline-parsers'
export function parseHeading(
tokens: MarkdownToken[],
index: number,
options?: ParseOptions,
): HeadingNode {
const token = tokens[index]
const attrs = (token as any)?.attrs as any[] | null | undefined
const attrsRecord = Array.isArray(attrs) && attrs.length
? Object.fromEntries(
attrs
.filter(pair => Array.isArray(pair) && pair.length >= 1 && pair[0])
.map(([name, value]) => [String(name), value == null || value === '' ? true : String(value)]),
)
: undefined
const levelStr = String(token.tag?.substring(1) ?? '1')
const headingLevel = Number.parseInt(levelStr, 10)
const headingContentToken = tokens[index + 1]
const headingContent = String(headingContentToken.content ?? '')
return {
type: 'heading',
level: headingLevel,
text: headingContent,
...(attrsRecord ? { attrs: attrsRecord } : {}),
children: parseInlineTokens(headingContentToken.children || [], headingContent, undefined, options as any),
raw: headingContent,
}
}
@@ -0,0 +1,100 @@
import type { HtmlBlockNode, MarkdownToken } from '../../types'
import { VOID_HTML_TAGS } from '../../htmlTags'
import { findTagCloseIndexOutsideQuotes, parseTagAttrs } from '../../htmlTagUtils'
function findMatchingCloseTagEnd(rawHtml: string, tag: string, startIndex: number) {
const lowerTag = tag.toLowerCase()
const openTagRe = new RegExp(String.raw`^<\s*${lowerTag}(?=\s|>|/)`, 'i')
const closeTagRe = new RegExp(String.raw`^<\s*\/\s*${lowerTag}(?=\s|>)`, 'i')
let depth = 0
let index = Math.max(0, startIndex)
while (index < rawHtml.length) {
const lt = rawHtml.indexOf('<', index)
if (lt === -1)
return -1
const slice = rawHtml.slice(lt)
if (closeTagRe.test(slice)) {
const endRel = findTagCloseIndexOutsideQuotes(slice)
if (endRel === -1)
return -1
if (depth === 0)
return lt + endRel + 1
depth--
index = lt + endRel + 1
continue
}
if (openTagRe.test(slice)) {
const endRel = findTagCloseIndexOutsideQuotes(slice)
if (endRel === -1)
return -1
const rawTag = slice.slice(0, endRel + 1)
if (!/\/\s*>$/.test(rawTag))
depth++
index = lt + endRel + 1
continue
}
index = lt + 1
}
return -1
}
export function parseHtmlBlock(token: MarkdownToken): HtmlBlockNode {
const raw = String(token.content ?? '')
// Non-element html blocks (comments, doctypes, processing instructions) are non-closable
if (/^\s*<!--/.test(raw) || /^\s*<!/.test(raw) || /^\s*<\?/.test(raw)) {
return {
type: 'html_block',
content: raw,
raw,
tag: '',
loading: false,
}
}
// Extract first tag name (lowercased) like div, p, section, etc.
const tagMatch = raw.match(/^\s*<([A-Z][\w:-]*)/i)
const tag = (tagMatch?.[1] || '').toLowerCase()
// Handle unknown or malformed tag gracefully
if (!tag) {
return {
type: 'html_block',
content: raw,
raw,
tag: '',
loading: false,
}
}
const openEnd = findTagCloseIndexOutsideQuotes(raw)
const openTag = openEnd === -1 ? raw : raw.slice(0, openEnd + 1)
// Self-closing first tag like <img ... />
const selfClosing = openEnd !== -1 && /\/\s*>$/.test(openTag)
const isVoid = VOID_HTML_TAGS.has(tag)
const attrs = parseTagAttrs(openTag)
const closeEnd = openEnd === -1 ? -1 : findMatchingCloseTagEnd(raw, tag, openEnd + 1)
const hasClosing = closeEnd !== -1
const loading = !(isVoid || selfClosing || hasClosing)
const content = loading
? `${raw.replace(/<[^>]*$/, '')}\n</${tag}>`
: raw
return {
type: 'html_block',
content,
raw,
tag,
attrs: attrs.length ? attrs : undefined,
loading,
}
}
@@ -0,0 +1,190 @@
import type {
ListItemNode,
ListNode,
MarkdownToken,
ParsedNode,
ParseOptions,
} from '../../types'
import { parseInlineTokens } from '../inline-parsers'
import { parseCommonBlockToken } from './block-token-parser'
import { parseBlockquote } from './blockquote-parser'
import { containerTokenHandlers } from './container-token-handlers'
function trimInlineTokenTail(token: MarkdownToken) {
const rawContent = String(token.content ?? '')
const trimmed = rawContent.replace(/[ \t\r\n]+$/g, '')
if (trimmed === rawContent)
return
token.content = trimmed
const children = token.children as MarkdownToken[] | undefined
if (!Array.isArray(children) || children.length === 0)
return
while (children.length) {
const last = children[children.length - 1]
if (!last) {
children.pop()
continue
}
if (last.type === 'softbreak' || last.type === 'hardbreak') {
children.pop()
continue
}
if (last.type === 'text') {
const lastContent = String(last.content ?? '')
const next = lastContent.replace(/[ \t\r\n]+$/g, '')
if (next === lastContent)
break
if (next) {
last.content = next
break
}
children.pop()
continue
}
break
}
}
function stripLeakedOrderedListMarkerSuffix(token: MarkdownToken) {
// In streaming mode markdown-it can occasionally leak the next ordered-list
// marker (e.g. "\n\n2" or "\n\n2.") into the previous list item's paragraph
// content. Because our renderer uses `whitespace-pre-wrap`, those leaked
// newlines can create a large blank vertical gap until the next parse tick.
const rawContent = String(token.content ?? '')
const leak = rawContent.match(/\r?\n\s*\d+[.)]?\s*$/)
if (!leak || typeof leak.index !== 'number')
return
token.content = rawContent.slice(0, leak.index)
const children = token.children as MarkdownToken[] | undefined
if (!Array.isArray(children) || children.length === 0)
return
// Best-effort sync: drop trailing text/break tokens that likely correspond
// to the leaked marker and whitespace.
while (children.length) {
const last = children[children.length - 1]
if (!last) {
children.pop()
continue
}
if (last.type === 'softbreak' || last.type === 'hardbreak') {
children.pop()
continue
}
if (last.type === 'text') {
const lastContent = String(last.content ?? '')
if (/^[ \t\r\n\d.)]*$/.test(lastContent)) {
children.pop()
continue
}
const next = lastContent.replace(/[ \t\r\n\d.)]+$/g, '')
if (next !== lastContent) {
if (next)
last.content = next
else
children.pop()
}
}
break
}
}
export function parseList(
tokens: MarkdownToken[],
index: number,
options?: ParseOptions,
): [ListNode, number] {
const token = tokens[index]
const listItems: ListItemNode[] = []
let j = index + 1
while (
j < tokens.length
&& tokens[j].type !== 'bullet_list_close'
&& tokens[j].type !== 'ordered_list_close'
) {
if (tokens[j].type === 'list_item_open') {
// if (tokens[j].markup === '*') {
// j++
// continue
// }
const itemChildren: ParsedNode[] = []
let k = j + 1
while (k < tokens.length && tokens[k].type !== 'list_item_close') {
// Handle different block types inside list items
if (tokens[k].type === 'paragraph_open') {
const contentToken = tokens[k + 1]
const preToken = tokens[k - 1]
stripLeakedOrderedListMarkerSuffix(contentToken)
trimInlineTokenTail(contentToken)
itemChildren.push({
type: 'paragraph',
children: parseInlineTokens(contentToken.children || [], String(contentToken.content ?? ''), preToken, options as any),
raw: String(contentToken.content ?? ''),
})
k += 3 // Skip paragraph_open, inline, paragraph_close
}
else if (tokens[k].type === 'blockquote_open') {
// Parse blockquote within list item
const [blockquoteNode, newIndex] = parseBlockquote(tokens, k, options)
itemChildren.push(blockquoteNode)
k = newIndex
}
else if (
tokens[k].type === 'bullet_list_open'
|| tokens[k].type === 'ordered_list_open'
) {
const [nestedListNode, newIndex] = parseList(tokens, k, options)
itemChildren.push(nestedListNode)
k = newIndex
}
else {
const handled = parseCommonBlockToken(tokens, k, options, containerTokenHandlers)
if (handled) {
itemChildren.push(handled[0])
k = handled[1]
}
else {
k += 1
}
}
}
listItems.push({
type: 'list_item',
children: itemChildren,
raw: itemChildren.map(child => child.raw).join(''),
})
j = k + 1 // Move past list_item_close
}
else {
j += 1
}
}
const listNode: ListNode = {
type: 'list',
ordered: token.type === 'ordered_list_open',
// markdown-it may include attrs like [['start','2']] on ordered_list_open
start: (() => {
if (token.attrs && token.attrs.length) {
const found = token.attrs.find(a => a[0] === 'start')
if (found) {
const parsed = Number(found[1])
return Number.isFinite(parsed) && parsed !== 0 ? parsed : 1
}
}
return undefined
})(),
items: listItems,
raw: listItems.map(item => item.raw).join('\n'),
}
return [listNode, j + 1] // Move past list_close
}
@@ -0,0 +1,14 @@
import type { MarkdownToken, MathBlockNode } from '../../types'
// Parse a math_block token (block/display math expressions)
export function parseMathBlock(token: MarkdownToken): MathBlockNode {
const content = String(token.content ?? '')
const raw = token.raw === '$$' ? `$$${content}$$` : String(token.raw ?? '')
return {
type: 'math_block',
content,
loading: !!token.loading,
raw,
markup: token.markup,
}
}
@@ -0,0 +1,17 @@
import type { MarkdownToken, ParagraphNode, ParseOptions } from '../../types'
import { parseInlineTokens } from '../inline-parsers'
export function parseParagraph(
tokens: MarkdownToken[],
index: number,
options?: ParseOptions,
): ParagraphNode {
const paragraphContentToken = tokens[index + 1]
const paragraphContent = String(paragraphContentToken.content ?? '')
return {
type: 'paragraph',
children: parseInlineTokens(paragraphContentToken.children || [], paragraphContent, undefined, options as any),
raw: paragraphContent,
}
}
@@ -0,0 +1,117 @@
import type {
MarkdownToken,
ParseOptions,
TableCellNode,
TableNode,
TableRowNode,
} from '../../types'
import { parseInlineTokens } from '../inline-parsers'
// Extract alignment from attrs (e.g. ['style','text-align:left'])
function extractAlign(attrs: MarkdownToken['attrs']): 'left' | 'right' | 'center' | undefined {
if (!attrs)
return 'left'
for (const a of attrs) {
if (!a)
continue
const [key, val] = a
if (!val)
continue
const value = String(val).trim().toLowerCase()
if (key === 'style') {
const m = /text-align\s*:\s*(left|right|center)/i.exec(value)
if (m)
return m[1].toLowerCase() as any
}
}
return 'left'
}
export function parseTable(
tokens: MarkdownToken[],
index: number,
options?: ParseOptions,
): [TableNode, number] {
let j = index + 1
let headerRow: TableRowNode | null = null
const rows: TableRowNode[] = []
let isHeader = false
while (j < tokens.length && tokens[j].type !== 'table_close') {
if (tokens[j].type === 'thead_open') {
isHeader = true
j++
}
else if (tokens[j].type === 'thead_close') {
isHeader = false
j++
}
else if (
tokens[j].type === 'tbody_open'
|| tokens[j].type === 'tbody_close'
) {
j++
}
else if (tokens[j].type === 'tr_open') {
const cells: TableCellNode[] = []
let k = j + 1
while (k < tokens.length && tokens[k].type !== 'tr_close') {
if (tokens[k].type === 'th_open' || tokens[k].type === 'td_open') {
const isHeaderCell = tokens[k].type === 'th_open'
const contentToken = tokens[k + 1]
const content = String(contentToken.content ?? '')
const align = extractAlign(tokens[k].attrs)
cells.push({
type: 'table_cell',
header: isHeaderCell || isHeader,
children: parseInlineTokens(contentToken.children || [], content, undefined, options as any),
raw: content,
align,
})
k += 3 // Skip th_open/td_open, inline, th_close/td_close
}
else {
k++
}
}
const rowNode: TableRowNode = {
type: 'table_row',
cells,
raw: cells.map(cell => cell.raw).join('|'),
}
if (isHeader) {
headerRow = rowNode
}
else {
rows.push(rowNode)
}
j = k + 1 // Skip tr_close
}
else {
j++
}
}
if (!headerRow) {
// Default empty header if none found
headerRow = {
type: 'table_row',
cells: [],
raw: '',
}
}
const tableNode: TableNode = {
type: 'table',
header: headerRow,
rows,
loading: tokens[index].loading ?? false,
raw: [headerRow, ...rows].map(row => row.raw).join('\n'),
}
return [tableNode, j + 1] // Skip table_close
}
@@ -0,0 +1,8 @@
import type { ThematicBreakNode } from '../../types'
export function parseThematicBreak(): ThematicBreakNode {
return {
type: 'thematic_break',
raw: '---',
}
}