278 lines
8.2 KiB
JavaScript
278 lines
8.2 KiB
JavaScript
import DiffMatchPatch from 'diff-match-patch';
|
|
import Prism from 'prismjs';
|
|
import MarkdownIt from 'markdown-it';
|
|
import markdownGrammarSvc from './markdownGrammarSvc';
|
|
import extensionSvc from './extensionSvc';
|
|
|
|
const htmlSectionMarker = '\uF111\uF222\uF333\uF444';
|
|
const diffMatchPatch = new DiffMatchPatch();
|
|
|
|
// Create aliases for syntax highlighting
|
|
const languageAliases = ({
|
|
js: 'javascript',
|
|
json: 'javascript',
|
|
html: 'markup',
|
|
svg: 'markup',
|
|
xml: 'markup',
|
|
py: 'python',
|
|
rb: 'ruby',
|
|
yml: 'yaml',
|
|
ps1: 'powershell',
|
|
psm1: 'powershell',
|
|
});
|
|
Object.keys(languageAliases).forEach((alias) => {
|
|
const language = languageAliases[alias];
|
|
Prism.languages[alias] = Prism.languages[language];
|
|
});
|
|
|
|
// Add programming language parsing capability to markdown fences
|
|
const insideFences = {};
|
|
Object.keys(Prism.languages).forEach((name) => {
|
|
const language = Prism.languages[name];
|
|
if (Prism.util.type(language) === 'Object') {
|
|
insideFences[`language-${name}`] = {
|
|
pattern: new RegExp(`(\`\`\`|~~~)${name}\\W[\\s\\S]*`),
|
|
inside: {
|
|
'cl cl-pre': /(```|~~~).*/,
|
|
rest: language,
|
|
},
|
|
};
|
|
}
|
|
});
|
|
|
|
// Disable spell checking in specific tokens
|
|
const noSpellcheckTokens = Object.create(null);
|
|
[
|
|
'code',
|
|
'pre',
|
|
'pre gfm',
|
|
'math block',
|
|
'math inline',
|
|
'math expr block',
|
|
'math expr inline',
|
|
'latex block',
|
|
]
|
|
.forEach((key) => {
|
|
noSpellcheckTokens[key] = true;
|
|
});
|
|
Prism.hooks.add('wrap', (env) => {
|
|
if (noSpellcheckTokens[env.type]) {
|
|
env.attributes.spellcheck = 'false';
|
|
}
|
|
});
|
|
|
|
function createFlagMap(arr) {
|
|
return arr.reduce((map, type) => ({ ...map, [type]: true }), {});
|
|
}
|
|
const startSectionBlockTypeMap = createFlagMap([
|
|
'paragraph_open',
|
|
'blockquote_open',
|
|
'heading_open',
|
|
'code',
|
|
'fence',
|
|
'table_open',
|
|
'html_block',
|
|
'bullet_list_open',
|
|
'ordered_list_open',
|
|
'hr',
|
|
'dl_open',
|
|
]);
|
|
const listBlockTypeMap = createFlagMap([
|
|
'bullet_list_open',
|
|
'ordered_list_open',
|
|
]);
|
|
const blockquoteBlockTypeMap = createFlagMap([
|
|
'blockquote_open',
|
|
]);
|
|
const tableBlockTypeMap = createFlagMap([
|
|
'table_open',
|
|
]);
|
|
const deflistBlockTypeMap = createFlagMap([
|
|
'dl_open',
|
|
]);
|
|
|
|
function hashArray(arr, valueHash, valueArray) {
|
|
const hash = [];
|
|
arr.forEach((str) => {
|
|
let strHash = valueHash[str];
|
|
if (strHash === undefined) {
|
|
strHash = valueArray.length;
|
|
valueArray.push(str);
|
|
valueHash[str] = strHash;
|
|
}
|
|
hash.push(strHash);
|
|
});
|
|
return String.fromCharCode.apply(null, hash);
|
|
}
|
|
|
|
// Default options for the markdown converter and the grammar
|
|
const defaultOptions = {
|
|
abbr: true,
|
|
breaks: true,
|
|
deflist: true,
|
|
del: true,
|
|
fence: true,
|
|
footnote: true,
|
|
linkify: true,
|
|
math: true,
|
|
sub: true,
|
|
sup: true,
|
|
table: true,
|
|
typographer: true,
|
|
insideFences,
|
|
};
|
|
|
|
const markdownConversionSvc = {
|
|
defaultOptions,
|
|
|
|
/**
|
|
* Creates a converter and init it with extensions.
|
|
* @returns {Object} A converter.
|
|
*/
|
|
createConverter(options) {
|
|
// Let the listeners add the rules
|
|
const converter = new MarkdownIt('zero');
|
|
converter.core.ruler.enable([], true);
|
|
converter.block.ruler.enable([], true);
|
|
converter.inline.ruler.enable([], true);
|
|
extensionSvc.initConverter(converter, options);
|
|
Object.keys(startSectionBlockTypeMap).forEach((type) => {
|
|
const rule = converter.renderer.rules[type] || converter.renderer.renderToken;
|
|
converter.renderer.rules[type] = (tokens, idx, opts, env, self) => {
|
|
if (tokens[idx].sectionDelimiter) {
|
|
// Add section delimiter
|
|
return htmlSectionMarker + rule.call(converter.renderer, tokens, idx, opts, env, self);
|
|
}
|
|
return rule.call(converter.renderer, tokens, idx, opts, env, self);
|
|
};
|
|
});
|
|
return converter;
|
|
},
|
|
|
|
/**
|
|
* Parse markdown sections by passing the 2 first block rules of the markdown-it converter.
|
|
* @param {Object} converter The markdown-it converter.
|
|
* @param {String} text The text to be parsed.
|
|
* @returns {Object} A parsing context to be passed to `convert`.
|
|
*/
|
|
parseSections(converter, text) {
|
|
const markdownState = new converter.core.State(text, converter, {});
|
|
const markdownCoreRules = converter.core.ruler.getRules('');
|
|
markdownCoreRules[0](markdownState); // Pass the normalize rule
|
|
markdownCoreRules[1](markdownState); // Pass the block rule
|
|
const lines = text.split('\n');
|
|
if (!lines[lines.length - 1]) {
|
|
// In cledit, last char is always '\n'.
|
|
// Remove it as one will be added by addSection
|
|
lines.pop();
|
|
}
|
|
const parsingCtx = {
|
|
sections: [],
|
|
converter,
|
|
markdownState,
|
|
markdownCoreRules,
|
|
};
|
|
let data = 'main';
|
|
let i = 0;
|
|
|
|
function addSection(maxLine) {
|
|
const section = {
|
|
text: '',
|
|
data,
|
|
};
|
|
for (; i < maxLine; i += 1) {
|
|
section.text += `${lines[i]}\n`;
|
|
}
|
|
if (section) {
|
|
parsingCtx.sections.push(section);
|
|
}
|
|
}
|
|
markdownState.tokens.forEach((token, index) => {
|
|
// index === 0 means there are empty lines at the begining of the file
|
|
if (token.level === 0 && startSectionBlockTypeMap[token.type] === true) {
|
|
if (index > 0) {
|
|
token.sectionDelimiter = true;
|
|
addSection(token.map[0]);
|
|
}
|
|
if (listBlockTypeMap[token.type] === true) {
|
|
data = 'list';
|
|
} else if (blockquoteBlockTypeMap[token.type] === true) {
|
|
data = 'blockquote';
|
|
} else if (tableBlockTypeMap[token.type] === true) {
|
|
data = 'table';
|
|
} else if (deflistBlockTypeMap[token.type] === true) {
|
|
data = 'deflist';
|
|
} else {
|
|
data = 'main';
|
|
}
|
|
}
|
|
});
|
|
addSection(lines.length);
|
|
return parsingCtx;
|
|
},
|
|
|
|
/**
|
|
* Convert markdown sections previously parsed with `parseSections`.
|
|
* @param {Object} parsingCtx The parsing context returned by `parseSections`.
|
|
* @param {Object} previousConversionCtx The conversion context returned by a previous call
|
|
* to `convert`, in order to calculate the `htmlSectionDiff` of the returned conversion context.
|
|
* @returns {Object} A conversion context.
|
|
*/
|
|
convert(parsingCtx, previousConversionCtx) {
|
|
// This function can be called twice without editor modification
|
|
// so prevent from converting it again.
|
|
if (!parsingCtx.markdownState.isConverted) {
|
|
// Skip 2 first rules previously passed in parseSections
|
|
parsingCtx.markdownCoreRules.slice(2).forEach(rule => rule(parsingCtx.markdownState));
|
|
parsingCtx.markdownState.isConverted = true;
|
|
}
|
|
const tokens = parsingCtx.markdownState.tokens;
|
|
const html = parsingCtx.converter.renderer.render(
|
|
tokens,
|
|
parsingCtx.converter.options,
|
|
parsingCtx.markdownState.env,
|
|
);
|
|
const htmlSectionList = html.split(htmlSectionMarker);
|
|
if (htmlSectionList[0] === '') {
|
|
htmlSectionList.shift();
|
|
}
|
|
const valueHash = Object.create(null);
|
|
const valueArray = [];
|
|
const newSectionHash = hashArray(htmlSectionList, valueHash, valueArray);
|
|
let htmlSectionDiff;
|
|
if (previousConversionCtx) {
|
|
const oldSectionHash = hashArray(
|
|
previousConversionCtx.htmlSectionList, valueHash, valueArray);
|
|
htmlSectionDiff = diffMatchPatch.diff_main(oldSectionHash, newSectionHash, false);
|
|
} else {
|
|
htmlSectionDiff = [
|
|
[1, newSectionHash],
|
|
];
|
|
}
|
|
return {
|
|
sectionList: parsingCtx.sectionList,
|
|
htmlSectionList,
|
|
htmlSectionDiff,
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Helper to highlight arbitrary markdown
|
|
* @param {Object} markdown The markdown content to highlight.
|
|
* @param {Object} converter An optional converter.
|
|
* @param {Object} grammars Optional grammars.
|
|
* @returns {Object} The highlighted markdown in HTML format.
|
|
*/
|
|
highlight(markdown, converter = this.defaultConverter, grammars = this.defaultPrismGrammars) {
|
|
const parsingCtx = this.parseSections(converter, markdown);
|
|
return parsingCtx.sections.map(
|
|
section => Prism.highlight(section.text, grammars[section.data]),
|
|
).join('');
|
|
},
|
|
};
|
|
|
|
markdownConversionSvc.defaultConverter = markdownConversionSvc.createConverter(defaultOptions);
|
|
markdownConversionSvc.defaultPrismGrammars = markdownGrammarSvc.makeGrammars(defaultOptions);
|
|
|
|
export default markdownConversionSvc;
|