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:
+29
@@ -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
+21
@@ -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]' : '[ ]',
|
||||
}
|
||||
}
|
||||
+12
@@ -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}:`,
|
||||
}
|
||||
}
|
||||
+37
@@ -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 }
|
||||
}
|
||||
+126
@@ -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,
|
||||
}
|
||||
}
|
||||
+12
@@ -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 ?? '')}]`,
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import type { HardBreakNode } from '../../types'
|
||||
|
||||
export function parseHardbreakToken(): HardBreakNode {
|
||||
return {
|
||||
type: 'hardbreak',
|
||||
raw: '\\\n',
|
||||
}
|
||||
}
|
||||
+37
@@ -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 }
|
||||
}
|
||||
+392
@@ -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,
|
||||
]
|
||||
}
|
||||
+61
@@ -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 ``).
|
||||
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,
|
||||
}
|
||||
}
|
||||
+1846
File diff suppressed because it is too large
Load Diff
+10
@@ -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,
|
||||
}
|
||||
}
|
||||
+37
@@ -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 }
|
||||
}
|
||||
+112
@@ -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 }
|
||||
}
|
||||
+14
@@ -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,
|
||||
}
|
||||
}
|
||||
+12
@@ -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,
|
||||
}
|
||||
}
|
||||
+42
@@ -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 }
|
||||
}
|
||||
+71
@@ -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 }
|
||||
}
|
||||
+48
@@ -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 }
|
||||
}
|
||||
+47
@@ -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 }
|
||||
}
|
||||
+10
@@ -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)
|
||||
}
|
||||
+66
@@ -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
|
||||
}
|
||||
+518
@@ -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
|
||||
}
|
||||
+63
@@ -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
|
||||
}
|
||||
+29
@@ -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,
|
||||
}
|
||||
}
|
||||
+137
@@ -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]
|
||||
}
|
||||
+29
@@ -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,
|
||||
}
|
||||
+76
@@ -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
|
||||
}
|
||||
+43
@@ -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
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import type { HardBreakNode } from '../../types'
|
||||
|
||||
export function parseHardBreak(): HardBreakNode {
|
||||
return {
|
||||
type: 'hardbreak',
|
||||
raw: '\\\n',
|
||||
}
|
||||
}
|
||||
+31
@@ -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,
|
||||
}
|
||||
}
|
||||
+100
@@ -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,
|
||||
}
|
||||
}
|
||||
+190
@@ -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
|
||||
}
|
||||
+14
@@ -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,
|
||||
}
|
||||
}
|
||||
+17
@@ -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,
|
||||
}
|
||||
}
|
||||
+117
@@ -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
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import type { ThematicBreakNode } from '../../types'
|
||||
|
||||
export function parseThematicBreak(): ThematicBreakNode {
|
||||
return {
|
||||
type: 'thematic_break',
|
||||
raw: '---',
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user