mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
import MarkdownIt from 'markdown-it'
|
|
import cjkFriendly from 'markdown-it-cjk-friendly'
|
|
import footnote from 'markdown-it-footnote'
|
|
import type {
|
|
AlignType,
|
|
Code,
|
|
Nodes,
|
|
Parent,
|
|
PhrasingContent,
|
|
Root,
|
|
RootContent,
|
|
Table
|
|
} from 'mdast'
|
|
import remend from 'remend'
|
|
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
|
|
import { endLinkifyUrlsAtProsePunctuation } from './markdown-linkify-prose-boundary'
|
|
|
|
// CommonMark with GitHub's tables, strikethrough, autolinks, footnotes and CJK-friendly
|
|
// emphasis: the rules desktop's renderer follows, read by a parser fast enough for the phone's
|
|
// JavaScript engine.
|
|
const markdown = new MarkdownIt({ html: true, linkify: true }).use(footnote).use(cjkFriendly)
|
|
// Every href parses as a link, file: included; routeMarkdownHref decides what a tap opens.
|
|
markdown.validateLink = () => true
|
|
endLinkifyUrlsAtProsePunctuation(markdown.linkify)
|
|
|
|
type Token = ReturnType<MarkdownIt['parse']>[number]
|
|
|
|
const PATH_FLANK = /[\w\\/]/
|
|
const TASK_MARKER = /^\[([ xX])\](?:\s+|$)/
|
|
const LINE_BREAK_TAG = /^<br\s*\/?>$/i
|
|
const HEADING_DEPTHS = [1, 2, 3, 4, 5, 6] as const
|
|
/** Parents that hold blocks; any other parent holds inline content. */
|
|
const BLOCK_PARENTS = new Set(['root', 'blockquote', 'listItem', 'footnoteDefinition'])
|
|
const CLOSING_TOKENS = new Set([
|
|
'paragraph_close',
|
|
'heading_close',
|
|
'blockquote_close',
|
|
'bullet_list_close',
|
|
'ordered_list_close',
|
|
'list_item_close',
|
|
'table_close',
|
|
'tr_close',
|
|
'th_close',
|
|
'td_close',
|
|
'footnote_close'
|
|
])
|
|
|
|
/** The text a node draws, as mdast-util-to-string reads it. */
|
|
export function plainText(node: Nodes): string {
|
|
if ('value' in node) {
|
|
return node.value
|
|
}
|
|
if (node.type === 'image') {
|
|
return node.alt ?? ''
|
|
}
|
|
return 'children' in node ? node.children.map(plainText).join('') : ''
|
|
}
|
|
|
|
function pushText(nodes: PhrasingContent[], value: string): void {
|
|
const previous = nodes.at(-1)
|
|
if (previous?.type === 'text') {
|
|
previous.value += value
|
|
} else {
|
|
nodes.push({ type: 'text', value })
|
|
}
|
|
}
|
|
|
|
/** Inline tokens up to `close`, and the index of that closing token. */
|
|
function phrasing(
|
|
tokens: readonly Token[],
|
|
start: number,
|
|
close?: string
|
|
): { nodes: PhrasingContent[]; next: number } {
|
|
const nodes: PhrasingContent[] = []
|
|
let index = start
|
|
while (index < tokens.length && tokens[index]!.type !== close) {
|
|
const token = tokens[index]!
|
|
index += 1
|
|
if (token.type === 'text') {
|
|
pushText(nodes, token.content)
|
|
} else if (token.type === 'softbreak') {
|
|
pushText(nodes, '\n')
|
|
} else if (token.type === 'hardbreak') {
|
|
nodes.push({ type: 'break' })
|
|
} else if (token.type === 'code_inline') {
|
|
nodes.push({ type: 'inlineCode', value: token.content })
|
|
} else if (token.type === 'html_inline') {
|
|
nodes.push({ type: 'html', value: token.content })
|
|
} else if (token.type === 'image') {
|
|
nodes.push({ type: 'image', url: token.attrGet('src') ?? '', alt: token.content || null })
|
|
} else if (token.type === 'footnote_ref') {
|
|
const label = String(token.meta?.label ?? '')
|
|
nodes.push({ type: 'footnoteReference', identifier: label, label })
|
|
} else if (token.nesting === 1) {
|
|
const inner = phrasing(tokens, index, token.type.replace(/_open$/, '_close'))
|
|
index = inner.next + 1
|
|
const previous = nodes.at(-1)
|
|
const before = previous?.type === 'text' ? previous.value.slice(-1) : ''
|
|
const after = tokens[index]?.type === 'text' ? (tokens[index]?.content[0] ?? '') : ''
|
|
if (token.type === 'link_open') {
|
|
nodes.push({ type: 'link', url: token.attrGet('href') ?? '', children: inner.nodes })
|
|
} else if (
|
|
// `_` emphasis against a path separator stays literal, so a dunder segment
|
|
// (`a/__tests__/x.ts`) keeps its path whole and tappable.
|
|
token.markup.startsWith('_') &&
|
|
(PATH_FLANK.test(before) || PATH_FLANK.test(after))
|
|
) {
|
|
const literal = inner.nodes.map(plainText).join('')
|
|
pushText(nodes, `${token.markup}${literal}${token.markup}`)
|
|
} else if (token.type === 'strong_open') {
|
|
nodes.push({ type: 'strong', children: inner.nodes })
|
|
} else if (token.type === 'em_open') {
|
|
nodes.push({ type: 'emphasis', children: inner.nodes })
|
|
} else if (token.type === 's_open') {
|
|
nodes.push({ type: 'delete', children: inner.nodes })
|
|
} else {
|
|
nodes.push(...inner.nodes)
|
|
}
|
|
}
|
|
}
|
|
return { nodes, next: index }
|
|
}
|
|
|
|
/** Whether a code block's closing fence has arrived; an unclosed one is still streaming in. */
|
|
export function codeFenceClosed(node: Code): boolean {
|
|
return !('closed' in node) || node.closed !== false
|
|
}
|
|
|
|
function codeNode(token: Token, lines: readonly string[]): Code & { closed: boolean } {
|
|
if (token.type !== 'fence') {
|
|
return { type: 'code', lang: null, value: token.content.replace(/\n$/, ''), closed: true }
|
|
}
|
|
const [start, end] = token.map ?? [0, 0]
|
|
// A fence inside a quote carries the quote's marker on its closing line.
|
|
const closing = end - start > 1 ? (lines[end - 1] ?? '').replace(/^[\s>]*/, '').trim() : ''
|
|
return {
|
|
type: 'code',
|
|
lang: token.info.trim().split(/\s+/)[0] || null,
|
|
value: token.content.replace(/\n$/, ''),
|
|
closed:
|
|
closing.length >= token.markup.length &&
|
|
[...closing].every((char) => char === token.markup[0])
|
|
}
|
|
}
|
|
|
|
function alignOf(token: Token): AlignType {
|
|
const align = /text-align:\s*(left|right|center)/.exec(token.attrGet('style') ?? '')?.[1]
|
|
return align === 'left' || align === 'right' || align === 'center' ? align : null
|
|
}
|
|
|
|
/** GitHub task list items: `[ ]` or `[x]` opening the item's first paragraph. */
|
|
function readTaskMarker(item: Parent & { checked?: boolean | null }): void {
|
|
const paragraph = item.children[0]
|
|
const text = paragraph?.type === 'paragraph' ? paragraph.children[0] : undefined
|
|
const marker = text?.type === 'text' ? TASK_MARKER.exec(text.value) : null
|
|
if (text?.type === 'text' && marker) {
|
|
item.checked = marker[1] !== ' '
|
|
text.value = text.value.slice(marker[0].length)
|
|
}
|
|
}
|
|
|
|
/** markdown-it's flat token stream as the mdast tree the renderer draws. */
|
|
function toTree(tokens: readonly Token[], source: string): Root {
|
|
const root: Root = { type: 'root', children: [] }
|
|
const lines = source.split('\n')
|
|
const stack: Parent[] = [root]
|
|
let table: Table | undefined
|
|
const open = (node: Parent & RootContent): void => {
|
|
stack.at(-1)!.children.push(node)
|
|
stack.push(node)
|
|
}
|
|
for (const token of tokens) {
|
|
const parent = stack.at(-1)!
|
|
switch (token.type) {
|
|
case 'paragraph_open':
|
|
open({ type: 'paragraph', children: [] })
|
|
break
|
|
case 'heading_open':
|
|
open({
|
|
type: 'heading',
|
|
depth: HEADING_DEPTHS.find((depth) => token.tag === `h${depth}`) ?? 6,
|
|
children: []
|
|
})
|
|
break
|
|
case 'blockquote_open':
|
|
open({ type: 'blockquote', children: [] })
|
|
break
|
|
case 'bullet_list_open':
|
|
case 'ordered_list_open':
|
|
open({
|
|
type: 'list',
|
|
ordered: token.type === 'ordered_list_open',
|
|
start: Number(token.attrGet('start') ?? 1),
|
|
children: []
|
|
})
|
|
break
|
|
case 'list_item_open':
|
|
open({ type: 'listItem', checked: null, children: [] })
|
|
break
|
|
case 'table_open':
|
|
table = { type: 'table', align: [], children: [] }
|
|
open(table)
|
|
break
|
|
case 'tr_open':
|
|
open({ type: 'tableRow', children: [] })
|
|
break
|
|
case 'th_open':
|
|
case 'td_open':
|
|
if (token.type === 'th_open') {
|
|
table?.align?.push(alignOf(token))
|
|
}
|
|
open({ type: 'tableCell', children: [] })
|
|
break
|
|
case 'footnote_open': {
|
|
const label = String(token.meta?.label ?? '')
|
|
open({ type: 'footnoteDefinition', identifier: label, label, children: [] })
|
|
break
|
|
}
|
|
case 'inline':
|
|
parent.children.push(...phrasing(token.children ?? [], 0).nodes)
|
|
break
|
|
case 'fence':
|
|
case 'code_block':
|
|
parent.children.push(codeNode(token, lines))
|
|
break
|
|
case 'hr':
|
|
parent.children.push({ type: 'thematicBreak' })
|
|
break
|
|
case 'html_block':
|
|
parent.children.push({ type: 'html', value: token.content })
|
|
break
|
|
default:
|
|
if (CLOSING_TOKENS.has(token.type)) {
|
|
const closed = stack.pop()
|
|
if (closed?.type === 'listItem') {
|
|
readTaskMarker(closed)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return root
|
|
}
|
|
|
|
/**
|
|
* Raw HTML the parser found, and only that: code is never an `html` node, so nothing inside a
|
|
* code block or span is touched. A block is read as the Markdown the preview cleanup makes of
|
|
* it (README headers and badges); an inline tag becomes the text the cleanup leaves, if any.
|
|
* `nested`: a block the cleanup produced, where leftover block HTML stays text.
|
|
*/
|
|
function withoutHtml(parent: Parent, nested: boolean): void {
|
|
const inline = !BLOCK_PARENTS.has(parent.type)
|
|
parent.children = parent.children.flatMap((child): RootContent[] => {
|
|
if (child.type !== 'html') {
|
|
if ('children' in child) {
|
|
withoutHtml(child, nested)
|
|
}
|
|
return [child]
|
|
}
|
|
if (inline && LINE_BREAK_TAG.test(child.value.trim())) {
|
|
return [{ type: 'break' }]
|
|
}
|
|
const text = normalizeMobileMarkdownPreviewHtml(child.value)
|
|
if (!text) {
|
|
return []
|
|
}
|
|
if (inline) {
|
|
return [{ type: 'text', value: text }]
|
|
}
|
|
if (nested) {
|
|
return [{ type: 'paragraph', children: [{ type: 'text', value: text }] }]
|
|
}
|
|
const blocks = toTree(markdown.parse(text, {}), text)
|
|
withoutHtml(blocks, true)
|
|
return blocks.children
|
|
})
|
|
}
|
|
|
|
/** The text with the markup its last top-level block leaves open closed, as desktop closes it.
|
|
* Only that block: a lone `*` earlier in the reply is no opener, and remend would close it. */
|
|
function withLastBlockClosed(source: string, tokens: readonly Token[]): string {
|
|
const lastBlock = tokens.findLast((token) => token.level === 0 && token.map)?.map?.[0] ?? 0
|
|
const lines = source.split('\n')
|
|
const tail = lines.slice(lastBlock).join('\n')
|
|
const closed = remend(tail, { linkMode: 'text-only' })
|
|
return closed === tail ? source : [...lines.slice(0, lastBlock), closed].join('\n')
|
|
}
|
|
|
|
/** `growing`: the text is still arriving, so markup its end leaves open is closed first, instead
|
|
* of flashing raw markers and half-written links. */
|
|
export function parseMobileMarkdownTree(source: string, growing: boolean): Root {
|
|
let tokens = markdown.parse(source, {})
|
|
let text = source
|
|
if (growing) {
|
|
text = withLastBlockClosed(source, tokens)
|
|
tokens = text === source ? tokens : markdown.parse(text, {})
|
|
}
|
|
const root = toTree(tokens, text)
|
|
withoutHtml(root, false)
|
|
return root
|
|
}
|
|
|
|
/** The 0-based lines markdown-it reads as code, at any depth. */
|
|
export function mobileMarkdownCodeLines(source: string): Set<number> {
|
|
const lines = new Set<number>()
|
|
for (const token of markdown.parse(source, {})) {
|
|
if ((token.type === 'fence' || token.type === 'code_block') && token.map) {
|
|
for (let line = token.map[0]; line < token.map[1]; line += 1) {
|
|
lines.add(line)
|
|
}
|
|
}
|
|
}
|
|
return lines
|
|
}
|