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:
1 parent
919575f3e7
commit
75000fc571
15 files changed
+1991
-47
No files matched your search
@@ -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(['---'])
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
@@ -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<b">a<b</span> <span data-emoji="smile">:smile:</span></p>\n')
|
||||
expect(renderMarkdown('$$\n"><img src=x onerror=alert(1)>\n$$')).toBe(
|
||||
'<div data-math=""><img src=x onerror=alert(1)>">"><img src=x onerror=alert(1)></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
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user