add more MFM features & specialties
This commit is contained in:
parent
95aa73c614
commit
cc78d3fc5f
3 changed files with 189 additions and 2 deletions
|
@ -5,6 +5,9 @@ import { host } from '@/config';
|
|||
import MkMention from '@/components/mention.vue';
|
||||
import MkSparkle from '@/components/sparkle.vue';
|
||||
import MkEmoji from '@/components/global/emoji.vue';
|
||||
import MkLink from '@/components/link.vue';
|
||||
import MkCode from '@/components/code.vue';
|
||||
import MkFormula from '@/components/formula.vue';
|
||||
|
||||
export const Markdown = defineComponent({
|
||||
props: {
|
||||
|
@ -27,6 +30,12 @@ export const Markdown = defineComponent({
|
|||
switch (node.nodeName) {
|
||||
case '#text':
|
||||
return node.textContent;
|
||||
case 'A':
|
||||
return h(MkLink, {
|
||||
url: node.getAttribute('href'),
|
||||
rel: 'nofollow noopener',
|
||||
}, node.textContent);
|
||||
break;
|
||||
case 'SPAN':
|
||||
if (node.classList.contains('mfm-sparkle')) {
|
||||
return h(MkSparkle, {}, mapNodes(node.childNodes));
|
||||
|
@ -37,6 +46,17 @@ export const Markdown = defineComponent({
|
|||
});
|
||||
} else if (node.classList.contains('mfm-emoji')) {
|
||||
return h(MkEmoji, { emoji: node.textContent, customEmojis: this.customEmojis });
|
||||
} else if (node.classList.contains('mfm-codeblock') || node.classList.contains('mfm-inline-code')) {
|
||||
return h(MkCode, {
|
||||
code: node.innerText,
|
||||
lang: node.getAttribute("data-mfm-language") ?? undefined,
|
||||
inline: node.classList.contains('mfm-inline-code'),
|
||||
}, node.innerText);
|
||||
} else if (node.classList.contains('mfm-katex')) {
|
||||
return h(MkFormula, {
|
||||
formula: node.innerText,
|
||||
block: !node.hasAttribute('data-mfm-inline'),
|
||||
});
|
||||
}
|
||||
// fallthrough, just handle like ordinary nodes
|
||||
default:
|
||||
|
|
|
@ -69,6 +69,28 @@
|
|||
}
|
||||
}
|
||||
|
||||
.markdown-container {
|
||||
blockquote {
|
||||
display: block;
|
||||
margin: 8px;
|
||||
padding: 6px 0 6px 12px;
|
||||
color: var(--fg);
|
||||
border-left: solid 3px var(--fg);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
blockquote blockquote blockquote {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
blockquote > p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
blockquote > p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animated-mfm {
|
||||
.mfm-jelly {
|
||||
display: inline-block;
|
||||
|
|
|
@ -4,7 +4,7 @@ import * as foundkey from 'foundkey-js';
|
|||
import { MFM_TAGS } from '@/scripts/mfm-tags';
|
||||
|
||||
const sanitizerOptions = {
|
||||
allowedTags: ["h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "dd", "dl", "dt", "hr", "li", "ol", "p", "pre", "ul", "a", "b", "br", "code", "em", "i", "kbd", "mark", "q", "rp", "rt", "ruby", "s", "small", "span", "strong", "sub", "sup", "u", "wbr", "caption", "col", "colgroup", "table", "tbody", "td", "tfoot", "th", "thead", "tr", "img"],
|
||||
allowedTags: ["h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "dd", "dl", "dt", "hr", "li", "ol", "p", "pre", "ul", "a", "b", "br", "code", "em", "i", "kbd", "mark", "q", "rp", "rt", "ruby", "s", "small", "span", "strong", "sub", "sup", "u", "wbr", "caption", "col", "colgroup", "table", "tbody", "td", "tfoot", "th", "thead", "tr", "img", "del"],
|
||||
allowedAttributes: {
|
||||
a: [ 'href', 'name', 'target' ],
|
||||
img: [ 'src', 'srcset', 'alt', 'title', 'width', 'height', 'loading' ],
|
||||
|
@ -39,6 +39,50 @@ marked.setOptions({
|
|||
sanitizer: (html) => sanitizeHtml(html, inputSanitizerOptions),
|
||||
});
|
||||
marked.use({
|
||||
tokenizer: {
|
||||
blockquote(src) {
|
||||
// custom behaviour: don't continue blockquotes onto lines that do not start with a `>`
|
||||
// but allow a single empty line to continue the quote
|
||||
const match = src.match(/^(?: {0,3}>.*(?:\r\n|\n|$){1,2})+/);
|
||||
if (!match) return;
|
||||
|
||||
const lines = match[0].replaceAll('\r\n', '\n')
|
||||
.split('\n')
|
||||
.map(line => {
|
||||
if (line === '') { return line; }
|
||||
|
||||
const initial = line.match(/^ {0,3}>[ \t]?/);
|
||||
return line.replace(initial[0], '');
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const tokens = [];
|
||||
this.lexer.blockTokens(lines, tokens);
|
||||
|
||||
return {
|
||||
type: 'blockquote',
|
||||
raw: match[0],
|
||||
text: lines,
|
||||
tokens,
|
||||
};
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
code(code, infostring, _escaped) {
|
||||
// TODO what does escaped mean here?
|
||||
const elem = document.createElement("span");
|
||||
elem.classList.add('mfm-codeblock');
|
||||
elem.setAttribute("data-mfm-language", infostring);
|
||||
elem.innerText = code;
|
||||
return elem.outerHTML;
|
||||
},
|
||||
codespan(code) {
|
||||
const elem = document.createElement("span");
|
||||
elem.classList.add('mfm-inline-code');
|
||||
elem.innerText = code;
|
||||
return elem.outerHTML;
|
||||
},
|
||||
},
|
||||
extensions: [
|
||||
{
|
||||
name: 'center-tag',
|
||||
|
@ -59,7 +103,7 @@ marked.use({
|
|||
{
|
||||
name: 'mention',
|
||||
level: 'inline',
|
||||
start(src) { return src.match(/(^|\s)@/)?.index; },
|
||||
start(src) { return src.match(/(?<=^|\s)@/)?.index; },
|
||||
tokenizer(src) {
|
||||
const match = src.match(/^@([-_a-z0-9]+)(?:@(\p{L}+(?:[-.]*\p{L}+)*))?/iu);
|
||||
if (!match) return;
|
||||
|
@ -96,6 +140,107 @@ marked.use({
|
|||
return `<span class="mfm-emoji">${token.raw}</span>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'katex-block',
|
||||
level: 'block',
|
||||
start(src) { return src.match(/^\\\[/)?.index; },
|
||||
tokenizer(src) {
|
||||
const match = src.match(/^\\\[[\r\n]*(.+?)[\r\n]*\\\]/s);
|
||||
if (!match) return;
|
||||
|
||||
return {
|
||||
type: 'katex-block',
|
||||
raw: match[0],
|
||||
katex: match[1],
|
||||
};
|
||||
},
|
||||
renderer(token) {
|
||||
const elem = document.createElement('span');
|
||||
elem.classList.add('mfm-katex');
|
||||
elem.innerText = token.katex;
|
||||
return elem.outerHTML;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'katex-inline',
|
||||
level: 'inline',
|
||||
start(src) { return src.match(/^\\\(/)?.index; },
|
||||
tokenizer(src) {
|
||||
const match = src.match(/^\\\((.+?)\\\)/);
|
||||
if (!match) return;
|
||||
|
||||
return {
|
||||
type: 'katex-inline',
|
||||
raw: match[0],
|
||||
katex: match[1],
|
||||
};
|
||||
},
|
||||
renderer(token) {
|
||||
const elem = document.createElement('span');
|
||||
elem.classList.add('mfm-katex');
|
||||
elem.setAttribute('data-mfm-inline', '1');
|
||||
elem.innerText = token.katex;
|
||||
return elem.outerHTML;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'hashtag',
|
||||
level: 'inline',
|
||||
start(src) { return src.match(/(?<=^|\p{P}|\s)#/)?.index; },
|
||||
tokenizer(src) {
|
||||
if (!src.startsWith("#")) return;
|
||||
|
||||
function recognizeHashtag(src) {
|
||||
// SECURITY: these regexes must not allow any "HTML dangerous" characters.
|
||||
const ordinaries = src.match(/^[-\p{L}\p{N}\p{M}\p{Sk}\p{Pc}]*/u);
|
||||
const open = src.slice(ordinaries[0].length).match(/^[\[({「]*/);
|
||||
|
||||
if (ordinaries[0].length === 0 && open[0].length === 0) {
|
||||
// end of text or hashtag
|
||||
return '';
|
||||
}
|
||||
|
||||
const close = open[0].split("").map(char => {
|
||||
return {
|
||||
'[': '\\]',
|
||||
'(': '\\)',
|
||||
'{': '\\}',
|
||||
'「': '」',
|
||||
}[char];
|
||||
}).join("");
|
||||
const sub = src.slice(ordinaries[0].length).match(new RegExp(
|
||||
open[0].replaceAll(/([\[({])/g, '\\$1') // escape open brackets/parens
|
||||
+ '(.*)'
|
||||
+ close,
|
||||
'u'));
|
||||
if (!sub || sub[1] !== recognizeHashtag(sub[1])) return ordinaries[0];
|
||||
|
||||
const recognized = ordinaries[0] + sub[0];
|
||||
const remainder = recognizeHashtag(src.slice(recognized.length));
|
||||
|
||||
if (sub[1] === '' && remainder === '') {
|
||||
// don't recognize parens with nothing in them at the end
|
||||
return ordinaries[0];
|
||||
} else {
|
||||
return recognized + remainder;
|
||||
}
|
||||
}
|
||||
|
||||
let hashtag = recognizeHashtag(src.slice(1));
|
||||
// all numeric strings cannot be hashtags
|
||||
if (hashtag.match(/^\p{N}+$/u)) return;
|
||||
|
||||
return {
|
||||
type: 'hashtag',
|
||||
raw: '#' + hashtag,
|
||||
tag: hashtag.normalize('NFKC'),
|
||||
};
|
||||
},
|
||||
renderer(token) {
|
||||
// SECURITY: token.raw cannot contain "HTML dangerous" characters
|
||||
return `<a href="/explore/tags/${encodeURIComponent(token.tag)}" class="mfm-hashtag">${token.raw}</a>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mfm-function',
|
||||
level: 'inline',
|
||||
|
|
Loading…
Reference in a new issue