Add Typora extension syntax

- Parser extensions shared by the editor and the HTML renderer: ==highlight==, $inline math$
  (Pandoc rules, so amounts like $5 are not math), $$ math blocks, footnote references and
  definitions, [TOC], and YAML front matter
- Live preview: highlight, superscript/subscript, emoji names (gemoji), footnote references
  with the definition as tooltip, a clickable table of contents, styled front matter
- Math (KaTeX) and Mermaid render in place; while editing a block the source is shown with a
  live preview below. KaTeX, Mermaid and the emoji table are loaded only when a note uses them
- KaTeX is pinned to 0.18 so Mermaid shares the same copy
- Move editor focus tracking out of table.ts so block widgets and tables use the same state
This commit is contained in:
eezd committed 2026-10-17 13:38:53 +08:00
1 parent 919575f3e7
commit 75000fc571
15 files changed
+1991 -47

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
import { parser as base } from '@lezer/markdown'
import { GFM } from '@lezer/markdown'
import { describe, expect, it } from 'vitest'
import { typoraExtensions } from './extensions'
const parser = base.configure([GFM, ...typoraExtensions])
/** 列出指定类型节点覆盖的原文 */
function nodes(src: string, name: string): string[] {
const out: string[] = []
parser.parse(src).iterate({
enter(n) {
if (n.name === name) out.push(src.slice(n.from, n.to))
},
})
return out
}
describe('Typora 扩展语法', () => {
it('==高亮==', () => {
expect(nodes('a ==b c== d', 'Highlight')).toEqual(['==b c=='])
expect(nodes('a == b ==', 'Highlight')).toEqual([])
expect(nodes('x === y', 'Highlight')).toEqual([])
})
it('行内公式:金额与转义不误判', () => {
expect(nodes('设 $x^2 + y$ 为', 'InlineMath')).toEqual(['$x^2 + y$'])
expect(nodes('价格 $5 和 $10', 'InlineMath')).toEqual([])
expect(nodes('$ x$ 与 $x $', 'InlineMath')).toEqual([])
expect(nodes('\\$x$ 不是公式', 'InlineMath')).toEqual([])
expect(nodes('$a\\$b$', 'InlineMath')).toEqual(['$a\\$b$'])
expect(nodes('`$x$`', 'InlineMath')).toEqual([])
})
it('公式块:可打断段落,只在顶层识别,未闭合延续到文末', () => {
expect(nodes('段落\n$$\na^2\n$$\n后文', 'BlockMath')).toEqual(['$$\na^2\n$$'])
expect(nodes('> $$\n> x\n> $$\n\n外面', 'BlockMath')).toEqual([])
expect(nodes('$$\nx\n', 'BlockMath')).toEqual(['$$\nx\n'])
expect(nodes('$$x$$', 'BlockMath')).toEqual([])
})
it('脚注引用与定义', () => {
expect(nodes('正文[^1]与[^note]。', 'FootnoteReference')).toEqual(['[^1]', '[^note]'])
expect(nodes('[^ 1] 不是脚注', 'FootnoteReference')).toEqual([])
expect(nodes('[^1]: 第一行\n继续\n\n下一段', 'FootnoteDefinition')).toEqual(['[^1]: 第一行\n继续'])
expect(nodes('[^1]: 有 **粗体**', 'StrongEmphasis')).toEqual(['**粗体**'])
expect(nodes('[^1]: x', 'LinkReference')).toEqual([])
})
it('[TOC] 独占一行', () => {
expect(nodes('[TOC]\n\n# 标题', 'TableOfContents')).toEqual(['[TOC]'])
expect(nodes('[toc] ', 'TableOfContents')).toEqual(['[toc] '])
expect(nodes('见 [TOC] 处', 'TableOfContents')).toEqual([])
})
it('YAML front matter 只在文档首行', () => {
expect(nodes('---\ntitle: a\n---\n# 正文', 'FrontMatter')).toEqual(['---\ntitle: a\n---'])
expect(nodes('---\ntitle: a\n...\n正文', 'FrontMatter')).toEqual(['---\ntitle: a\n...'])
expect(nodes('正文\n\n---\na: b\n---', 'FrontMatter')).toEqual([])
expect(nodes('正文\n\n---\n', 'HorizontalRule')).toEqual(['---'])
// 闭合后的 --- 照常解析为分隔线
expect(nodes('---\na: 1\n---\n\n---\n', 'HorizontalRule')).toEqual(['---'])
})
})
+198
View File
@@ -0,0 +1,198 @@
// Typora 扩展语法:==高亮==、$行内公式$、$$公式块$$、脚注、[TOC]、YAML front matter。
// 上标 ^x^、下标 ~x~、:emoji: 由 @lezer/markdown 自带的扩展解析
import { tags } from '@lezer/highlight'
import type { BlockContext, LeafBlockParser, Line, MarkdownConfig } from '@lezer/markdown'
const PUNCTUATION = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~\xA1‐-‧]/
const highlightDelim = { resolve: 'Highlight', mark: 'HighlightMark' }
/** ==高亮==,定界规则与 GFM 删除线相同 */
const Highlight: MarkdownConfig = {
defineNodes: [
{ name: 'Highlight', style: { 'Highlight/...': tags.special(tags.content) } },
{ name: 'HighlightMark', style: tags.processingInstruction },
],
parseInline: [
{
name: 'Highlight',
parse(cx, next, pos) {
if (next !== 61 /* = */ || cx.char(pos + 1) !== 61 || cx.char(pos + 2) === 61) return -1
const before = cx.slice(pos - 1, pos)
const after = cx.slice(pos + 2, pos + 3)
const sBefore = /\s|^$/.test(before)
const sAfter = /\s|^$/.test(after)
const pBefore = PUNCTUATION.test(before)
const pAfter = PUNCTUATION.test(after)
return cx.addDelimiter(
highlightDelim,
pos,
pos + 2,
!sAfter && (!pAfter || sBefore || pBefore),
!sBefore && (!pBefore || sAfter || pAfter),
)
},
after: 'Emphasis',
},
],
}
/**
* $行内公式$(Pandoc 规则):开头 $ 后不能是空白,结尾 $ 前不能是空白、后面不能紧跟数字,
* 因此"$5 和 $10"这类金额不会被当成公式;\$ 为转义
*/
const InlineMath: MarkdownConfig = {
defineNodes: [
{ name: 'InlineMath', style: tags.monospace },
{ name: 'InlineMathMark', style: tags.processingInstruction },
],
parseInline: [
{
name: 'InlineMath',
parse(cx, next, pos) {
if (next !== 36 /* $ */ || cx.char(pos + 1) === 36 || /\s/.test(cx.slice(pos + 1, pos + 2))) return -1
for (let i = pos + 1; i < cx.end; i++) {
const ch = cx.char(i)
if (ch === 92 /* \ */) i++
else if (ch === 36 && i > pos + 1 && !/\s/.test(cx.slice(i - 1, i)) && !/\d/.test(cx.slice(i + 1, i + 2))) {
return cx.addElement(cx.elt('InlineMath', pos, i + 1, [cx.elt('InlineMathMark', pos, pos + 1), cx.elt('InlineMathMark', i, i + 1)]))
}
}
return -1
},
before: 'Emphasis',
},
],
}
const isMathFence = (line: Line) => /^\$\$\s*$/.test(line.text.slice(line.pos))
/**
* 独占一行的 $$ 之间为公式块,未闭合时延续到文档末尾。只在顶层识别:解析器的公开接口无法判断
* 下一行是否仍属于引用、列表等容器,与表格一样,容器内的公式块以源码显示
*/
const BlockMath: MarkdownConfig = {
defineNodes: [
{ name: 'BlockMath', block: true, style: tags.monospace },
{ name: 'BlockMathMark', style: tags.processingInstruction },
],
parseBlock: [
{
name: 'BlockMath',
parse(cx, line) {
if (cx.parentType().name !== 'Document' || !isMathFence(line)) return false
const from = cx.lineStart + line.pos
const marks = [cx.elt('BlockMathMark', from, from + 2)]
while (cx.nextLine()) {
if (isMathFence(line)) {
marks.push(cx.elt('BlockMathMark', cx.lineStart + line.pos, cx.lineStart + line.pos + 2))
cx.nextLine()
break
}
}
cx.addElement(cx.elt('BlockMath', from, cx.prevLineEnd(), marks))
return true
},
endLeaf: (cx, line) => cx.parentType().name === 'Document' && isMathFence(line),
before: 'FencedCode',
},
],
}
const FOOTNOTE_LABEL = /^\[\^([^\]\s]+)\]/
/** 脚注引用 [^label] 与脚注定义 [^label]: 内容(定义可跨行,直到空行) */
const Footnote: MarkdownConfig = {
defineNodes: [
{ name: 'FootnoteReference', style: tags.labelName },
{ name: 'FootnoteDefinition', block: true },
{ name: 'FootnoteLabel', style: tags.labelName },
],
parseInline: [
{
name: 'FootnoteReference',
parse(cx, next, pos) {
if (next !== 91 /* [ */ || cx.char(pos + 1) !== 94 /* ^ */) return -1
const m = FOOTNOTE_LABEL.exec(cx.slice(pos, cx.end))
return m ? cx.addElement(cx.elt('FootnoteReference', pos, pos + m[0].length)) : -1
},
before: 'Link',
},
],
parseBlock: [
{
name: 'FootnoteDefinition',
leaf(_cx, leaf) {
const m = /^\[\^[^\]\s]+\]:/.exec(leaf.content)
if (!m) return null
const self: LeafBlockParser = {
// "[^1]: 文字"同时也是合法的链接引用定义;本解析器排在前面,续行时去掉其他候选,整块归为脚注定义。
// 须原地修改:调用方正在遍历这个数组
nextLine(_cx, _line, leaf) {
leaf.parsers.splice(0, leaf.parsers.length, self)
return false
},
finish(cx, leaf) {
const label = cx.elt('FootnoteLabel', leaf.start, leaf.start + m[0].length)
const body = cx.parser.parseInline(leaf.content.slice(m[0].length), leaf.start + m[0].length)
cx.addLeafElement(leaf, cx.elt('FootnoteDefinition', leaf.start, leaf.start + leaf.content.length, [label, ...body]))
return true
},
}
return self
},
before: 'LinkReference',
},
],
}
/** 独占一行的 [TOC](不区分大小写)显示为目录 */
const TableOfContents: MarkdownConfig = {
defineNodes: [{ name: 'TableOfContents', block: true, style: tags.meta }],
parseBlock: [
{
name: 'TableOfContents',
parse(cx, line) {
if (!/^\[toc\]\s*$/i.test(line.text.slice(line.pos))) return false
cx.addElement(cx.elt('TableOfContents', cx.lineStart + line.pos, cx.lineStart + line.text.length))
cx.nextLine()
return true
},
before: 'LinkReference',
},
],
}
const isFrontMatterEnd = (line: Line) => /^(---|\.\.\.)\s*$/.test(line.text)
/**
* 文档首行为 --- 时,到下一个 --- 或 ... 为 YAML front matter。解析器无法向后预读整个文档,
* 未闭合时延续到文末——与 Typora(首行输入 --- 即创建 front matter)及 GitHub、Hugo 的识别方式一致
*/
const FrontMatter: MarkdownConfig = {
defineNodes: [
{ name: 'FrontMatter', block: true, style: tags.monospace },
{ name: 'FrontMatterMark', style: tags.processingInstruction },
],
parseBlock: [
{
name: 'FrontMatter',
parse(cx: BlockContext, line: Line) {
if (cx.lineStart !== 0 || cx.parentType().name !== 'Document' || !/^---\s*$/.test(line.text)) return false
const marks = [cx.elt('FrontMatterMark', 0, 3)]
while (cx.nextLine()) {
if (isFrontMatterEnd(line)) {
marks.push(cx.elt('FrontMatterMark', cx.lineStart, cx.lineStart + 3))
cx.nextLine()
break
}
}
cx.addElement(cx.elt('FrontMatter', 0, cx.prevLineEnd(), marks))
return true
},
before: 'HorizontalRule',
},
],
}
export const typoraExtensions: MarkdownConfig[] = [FrontMatter, Highlight, InlineMath, BlockMath, Footnote, TableOfContents]
+15
View File
@@ -56,6 +56,21 @@ describe('安全模式', () => {
})
})
describe('Typora 扩展', () => {
it('高亮、上下标、脚注引用', () => {
expect(renderMarkdown('==a== x^2^ H~2~O[^1]')).toBe('<p><mark>a</mark> x<sup>2</sup> H<sub>2</sub>O<sup class="footnote-ref">1</sup></p>\n')
})
it('公式与 emoji 输出待填充的占位元素,内容被转义', () => {
expect(renderMarkdown('$a<b$ :smile:')).toBe('<p><span data-math="a&lt;b">a&lt;b</span> <span data-emoji="smile">:smile:</span></p>\n')
expect(renderMarkdown('$$\n"><img src=x onerror=alert(1)>\n$$')).toBe(
'<div data-math="&quot;&gt;&lt;img src=x onerror=alert(1)&gt;">&quot;&gt;&lt;img src=x onerror=alert(1)&gt;</div>\n',
)
})
it('脚注定义、front matter 与 [TOC]', () => {
expect(renderMarkdown('---\ntitle: x\n---\n[TOC]\n\n[^1]: 说明 **重点**')).toBe('<div class="footnote"><sup>1</sup> 说明 <strong>重点</strong></div>\n')
})
})
describe('CommonMark spec', () => {
for (const e of examples) {
const test = PARSER_DEVIATIONS.has(e.number) ? it.fails : it
+28 -2
View File
@@ -1,8 +1,9 @@
import type { SyntaxNode } from '@lezer/common'
import { Emoji, GFM, parser as commonmarkParser, Subscript, Superscript, type MarkdownParser } from '@lezer/markdown'
import { typoraExtensions } from './extensions'
// 与编辑器(lang-markdown 的 markdownLanguage)一致的语法集
export const markdownParser = commonmarkParser.configure([GFM, Subscript, Superscript, Emoji])
// 与编辑器一致的语法集(lang-markdown 的 markdownLanguage 加 Typora 扩展)
export const markdownParser = commonmarkParser.configure([GFM, Subscript, Superscript, Emoji, ...typoraExtensions])
export { commonmarkParser }
@@ -263,6 +264,19 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
return `<sup>${innerOf(node, 'SuperscriptMark')}</sup>`
case 'Subscript':
return `<sub>${innerOf(node, 'SubscriptMark')}</sub>`
case 'Highlight':
return `<mark>${innerOf(node, 'HighlightMark')}</mark>`
// 公式与 emoji 输出占位元素,由 render.ts 的 hydrate 按需加载依赖后填充
case 'InlineMath': {
const tex = text(node.from + 1, node.to - 1)
return `<span data-math="${escapeHtml(tex)}">${escapeHtml(tex)}</span>`
}
case 'Emoji': {
const raw = text(node.from, node.to)
return `<span data-emoji="${escapeHtml(raw.slice(1, -1))}">${escapeHtml(raw)}</span>`
}
case 'FootnoteReference':
return `<sup class="footnote-ref">${escapeHtml(text(node.from + 2, node.to - 1))}</sup>`
case 'InlineCode': {
const marks = node.getChildren('CodeMark')
let code = text(marks[0].to, marks[marks.length - 1].from).replace(/\n/g, ' ')
@@ -416,6 +430,18 @@ export function renderMarkdown(src: string, { parser = markdownParser, unsafe =
return rawHtml(contentLines(node, false, false).map((l) => l.rest).join('\n')) + '\n'
case 'Table':
return table(node)
case 'BlockMath': {
const marks = node.getChildren('BlockMathMark')
const from = Math.min(lineEnd(node.from) + 1, node.to)
const to = marks.length > 1 ? Math.max(from, lineStart(marks[marks.length - 1].from) - 1) : node.to
const tex = text(from, to)
return `<div data-math="${escapeHtml(tex)}">${escapeHtml(tex)}</div>\n`
}
case 'FootnoteDefinition': {
const label = node.getChild('FootnoteLabel')!
return `<div class="footnote"><sup>${escapeHtml(text(label.from + 2, label.to - 2))}</sup> ${inline(node, label.to, node.to).trim()}</div>\n`
}
// front matter 是元数据,不输出;[TOC] 在只读分享页面(M4)中生成
default:
return ''
}
+64
View File
@@ -0,0 +1,64 @@
// 数学公式、Mermaid 图表、emoji 的渲染。依赖体积较大,首次用到时才加载(各自打成独立的包)
import type { MermaidConfig } from 'mermaid'
let katexLoad: Promise<typeof import('katex').default> | undefined
function loadKatex() {
katexLoad ??= Promise.all([import('katex'), import('katex/dist/katex.min.css')]).then(([m]) => m.default)
return katexLoad
}
/** 渲染公式;语法错误以红色显示错误信息而不是抛出 */
export async function renderMath(el: HTMLElement, tex: string, displayMode: boolean): Promise<void> {
const katex = await loadKatex()
katex.render(tex, el, { displayMode, throwOnError: false, output: 'htmlAndMathml' })
}
let mermaidLoad: Promise<typeof import('mermaid').default> | undefined
let mermaidSeq = 0
function loadMermaid() {
mermaidLoad ??= import('mermaid').then(({ default: mermaid }) => {
const dark = matchMedia('(prefers-color-scheme: dark)').matches
// strict:图表中的 HTML 会被清理,点击事件等交互被禁用
const config: MermaidConfig = { startOnLoad: false, securityLevel: 'strict', theme: dark ? 'dark' : 'default' }
mermaid.initialize(config)
return mermaid
})
return mermaidLoad
}
/** 渲染 Mermaid 图表,返回 SVG 标记;语法错误时抛出带说明的 Error */
export async function renderMermaid(code: string): Promise<string> {
const mermaid = await loadMermaid()
const id = `rm-mermaid-${++mermaidSeq}`
try {
return (await mermaid.render(id, code)).svg
} catch (err) {
// 出错时 mermaid 会把临时节点留在 body 中
document.getElementById('d' + id)?.remove()
throw err instanceof Error ? err : new Error(String(err))
}
}
let emojiLoad: Promise<Map<string, string>> | undefined
/** 按 GitHub/Typora 的名称查找 emoji,如 smile → 😄;未知名称返回 undefined */
export async function emojiFor(name: string): Promise<string | undefined> {
emojiLoad ??= import('gemoji').then(({ gemoji }) => {
const map = new Map<string, string>()
for (const g of gemoji) for (const n of g.names) map.set(n, g.emoji)
return map
})
return (await emojiLoad).get(name)
}
/** 为渲染器输出的占位元素(表格单元格等处)填充公式与 emoji */
export function hydrate(root: HTMLElement) {
for (const el of root.querySelectorAll<HTMLElement>('[data-math]')) {
void renderMath(el, el.dataset.math!, el.tagName === 'DIV')
}
for (const el of root.querySelectorAll<HTMLElement>('[data-emoji]')) {
void emojiFor(el.dataset.emoji!).then((e) => {
if (e) el.textContent = e
})
}
}