blob: eccd8cbb983b43da6b1e06a1be09309e0c29ce88 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import katex from 'katex';
import type { MarkdownInlinePlugin } from '@astryxdesign/core/Markdown';
export interface PreparedMarkdownMath {
text: string;
settledText?: string;
plugin: MarkdownInlinePlugin;
}
interface MathTokenValue {
formula: string;
displayMode: boolean;
}
export function prepareMarkdownMath(
source: string,
settledSource?: string,
): PreparedMarkdownMath {
const registry = createMathTokenRegistry([source, settledSource]);
return {
text: protectMathOutsideCode(source, registry.register),
...(settledSource === undefined
? {}
: { settledText: protectMathOutsideCode(settledSource, registry.register) }),
plugin: {
pattern: registry.pattern,
render: (match, key) => {
const value = registry.values.get(match[0]);
if (!value) return match[0];
const html = katex.renderToString(value.formula, {
displayMode: value.displayMode,
output: 'htmlAndMathml',
strict: 'warn',
throwOnError: false,
trust: false,
});
return (
<span
key={key}
className={
value.displayMode ? 'maka-math maka-math-display' : 'maka-math maka-math-inline'
}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
},
},
};
}
function createMathTokenRegistry(sources: Array<string | undefined>): {
pattern: RegExp;
register: (formula: string, displayMode: boolean) => string;
values: Map<string, MathTokenValue>;
} {
let namespaceIndex = 0;
let namespace = '';
do {
namespace = `\uE000MAKAMATH:${namespaceIndex}:`;
namespaceIndex += 1;
} while (sources.some((source) => source?.includes(namespace)));
const tokenEnd = ':\uE001';
const values = new Map<string, MathTokenValue>();
const tokensByValue = new Map<string, string>();
let nextTokenId = 0;
return {
pattern: new RegExp(`${escapeRegExp(namespace)}\\d+${escapeRegExp(tokenEnd)}`, 'g'),
register: (formula, displayMode) => {
const valueKey = JSON.stringify([displayMode, formula]);
const existingToken = tokensByValue.get(valueKey);
if (existingToken) return existingToken;
const token = `${namespace}${nextTokenId}${tokenEnd}`;
nextTokenId += 1;
values.set(token, { formula, displayMode });
tokensByValue.set(valueKey, token);
return token;
},
values,
};
}
function protectMathOutsideCode(
source: string,
register: (formula: string, displayMode: boolean) => string,
): string {
const lines = source.split('\n');
let fence: { character: string; length: number } | undefined;
let proseLines: string[] = [];
const protectedParts: string[] = [];
const flushProse = () => {
if (proseLines.length === 0) return;
protectedParts.push(protectMathInProse(proseLines.join('\n'), register));
proseLines = [];
};
for (const line of lines) {
const opening = /^( {0,3})(`{3,}|~{3,})/.exec(line);
if (opening) {
flushProse();
const marker = opening[2] ?? '';
const character = marker[0] ?? '';
if (!fence) {
fence = { character, length: marker.length };
} else if (character === fence.character && marker.length >= fence.length) {
fence = undefined;
}
protectedParts.push(line);
} else if (fence) {
protectedParts.push(line);
} else {
proseLines.push(line);
}
}
flushProse();
return protectedParts.join('\n');
}
function protectMathInProse(
source: string,
register: (formula: string, displayMode: boolean) => string,
): string {
let output = '';
let index = 0;
while (index < source.length) {
if (source[index] === '`') {
const run = /^`+/.exec(source.slice(index))?.[0] ?? '`';
const close = source.indexOf(run, index + run.length);
if (close >= 0) {
output += source.slice(index, close + run.length);
index = close + run.length;
continue;
}
}
const delimited =
readDelimitedMath(source, index, '\\(', '\\)', false, false)
?? readDelimitedMath(source, index, '\\[', '\\]', true, true)
?? readDelimitedMath(source, index, '$$', '$$', true, true);
if (delimited) {
output += register(delimited.formula, delimited.displayMode);
index = delimited.end;
continue;
}
output += source[index];
index += 1;
}
return output;
}
function readDelimitedMath(
line: string,
index: number,
opening: string,
closing: string,
displayMode: boolean,
allowNewlines: boolean,
): { formula: string; displayMode: boolean; end: number } | undefined {
if (!line.startsWith(opening, index)) return undefined;
const contentStart = index + opening.length;
const close = line.indexOf(closing, contentStart);
if (close < 0) return undefined;
if (!allowNewlines && line.slice(contentStart, close).includes('\n')) return undefined;
const formula = line.slice(contentStart, close).trim();
if (!formula) return undefined;
return { formula, displayMode, end: close + closing.length };
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}