Add a marked extension that tokenizes math delimiters and renders them with KaTeX: `$$ ... $$` / `\[ ... \]` as block, `$ ... $` / `\( ... \)` and mid-paragraph `$$ ... $$` as inline. Inline `$ ... $` requires a non-space after the opening `$`, no newline inside, and a non-digit after the closing `$`, so prose like "$100 から $200" is left alone; `\$` still escapes a literal dollar sign. Parse errors fall back to KaTeX's red inline message instead of throwing, and math whose closing delimiter has not streamed in yet stays plain text. DOMPurify strips `semantics` and `annotation` by default, which would leak the raw TeX out of KaTeX's MathML output, so both are allowed explicitly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
104 lines
3.8 KiB
TypeScript
104 lines
3.8 KiB
TypeScript
import katex from 'katex'
|
|
import type { MarkedExtension, TokenizerAndRendererExtension, Tokens } from 'marked'
|
|
|
|
/**
|
|
* marked 拡張として数式 (KaTeX) を描画する。
|
|
*
|
|
* 対応する区切り記号:
|
|
* - ブロック: `$$ ... $$` / `\[ ... \]`(単独行)
|
|
* - インライン: `$ ... $` / `\( ... \)` / 行中の `$$ ... $$`
|
|
*
|
|
* `$` はドル記号としても書かれるため、インラインの `$ ... $` だけは
|
|
* 誤爆を避けるための条件を付けている(下記 INLINE_DOLLAR 参照)。
|
|
* 明示的にドル記号を書きたい場合は Markdown のエスケープ `\$` が使える。
|
|
*/
|
|
|
|
// ブロック: 行頭(インデント 3 まで)から始まる $$ ... $$ / \[ ... \]
|
|
const BLOCK_DOLLAR = /^ {0,3}\$\$([\s\S]+?)\$\$[ \t]*(?:\n+|$)/
|
|
const BLOCK_BRACKET = /^ {0,3}\\\[([\s\S]+?)\\\][ \t]*(?:\n+|$)/
|
|
|
|
// インライン: \( ... \) と、段落の途中に置かれた $$ ... $$
|
|
const INLINE_PAREN = /^\\\(((?:\\[^]|[^\\])+?)\\\)/
|
|
const INLINE_DOUBLE = /^\$\$((?:\\[^]|[^\\])+?)\$\$/
|
|
// インライン: $ ... $
|
|
// - 開き `$` の直後に空白・`$` が来るものは対象外
|
|
// - 中身に改行と裸の `$` を含まない
|
|
// - 閉じ `$` の直後が数字のものは対象外("$100から$200" のような金額表記の除外)
|
|
// - 中身が空白で終わるものは対象外(tokenizer 側で判定)
|
|
const INLINE_DOLLAR = /^\$(?![\s$])((?:\\.|[^\\$\n])+?)\$(?!\d)/
|
|
|
|
function escapeHtml(src: string): string {
|
|
return src
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
}
|
|
|
|
function renderMath(tex: string, displayMode: boolean, raw: string): string {
|
|
try {
|
|
return katex.renderToString(tex.trim(), {
|
|
displayMode,
|
|
throwOnError: false, // 構文エラーは例外にせず赤字で表示する
|
|
errorColor: '#f87171', // ダーク背景で読める色にする
|
|
strict: false, // 日本語混じりなどの警告を出さない
|
|
output: 'htmlAndMathml',
|
|
})
|
|
} catch {
|
|
// KaTeX が想定外の失敗をした場合は元の文字列をそのまま見せる
|
|
return escapeHtml(raw)
|
|
}
|
|
}
|
|
|
|
const blockMath: TokenizerAndRendererExtension = {
|
|
name: 'blockMath',
|
|
level: 'block',
|
|
start(src: string) {
|
|
return src.match(/(?:^|\n) {0,3}(?:\$\$|\\\[)/)?.index
|
|
},
|
|
tokenizer(src: string) {
|
|
const match = BLOCK_DOLLAR.exec(src) ?? BLOCK_BRACKET.exec(src)
|
|
if (!match) return undefined
|
|
return { type: 'blockMath', raw: match[0], text: match[1] }
|
|
},
|
|
renderer(token: Tokens.Generic) {
|
|
return renderMath(token.text as string, true, token.raw as string)
|
|
},
|
|
}
|
|
|
|
const inlineMath: TokenizerAndRendererExtension = {
|
|
name: 'inlineMath',
|
|
level: 'inline',
|
|
start(src: string) {
|
|
// エスケープされた \$ は数式の開始とみなさない
|
|
const match = /(^|[^\\])(\$|\\\()/.exec(src)
|
|
return match ? match.index + match[1].length : undefined
|
|
},
|
|
tokenizer(src: string) {
|
|
const paren = INLINE_PAREN.exec(src)
|
|
if (paren) return { type: 'inlineMath', raw: paren[0], text: paren[1], display: false }
|
|
|
|
const double = INLINE_DOUBLE.exec(src)
|
|
if (double) return { type: 'inlineMath', raw: double[0], text: double[1], display: true }
|
|
|
|
const single = INLINE_DOLLAR.exec(src)
|
|
if (single && !/[\s\\]$/.test(single[1])) {
|
|
return { type: 'inlineMath', raw: single[0], text: single[1], display: false }
|
|
}
|
|
return undefined
|
|
},
|
|
renderer(token: Tokens.Generic) {
|
|
return renderMath(token.text as string, token.display === true, token.raw as string)
|
|
},
|
|
}
|
|
|
|
export const mathExtension: MarkedExtension = {
|
|
extensions: [blockMath, inlineMath],
|
|
}
|
|
|
|
/** KaTeX が出力する MathML 用に DOMPurify へ追加で許可するタグ・属性 */
|
|
export const mathSanitizeConfig = {
|
|
ADD_TAGS: ['semantics', 'annotation'],
|
|
ADD_ATTR: ['encoding'],
|
|
}
|