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 MkMention from '@/components/mention.vue';
|
||||||
import MkSparkle from '@/components/sparkle.vue';
|
import MkSparkle from '@/components/sparkle.vue';
|
||||||
import MkEmoji from '@/components/global/emoji.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({
|
export const Markdown = defineComponent({
|
||||||
props: {
|
props: {
|
||||||
|
@ -27,6 +30,12 @@ export const Markdown = defineComponent({
|
||||||
switch (node.nodeName) {
|
switch (node.nodeName) {
|
||||||
case '#text':
|
case '#text':
|
||||||
return node.textContent;
|
return node.textContent;
|
||||||
|
case 'A':
|
||||||
|
return h(MkLink, {
|
||||||
|
url: node.getAttribute('href'),
|
||||||
|
rel: 'nofollow noopener',
|
||||||
|
}, node.textContent);
|
||||||
|
break;
|
||||||
case 'SPAN':
|
case 'SPAN':
|
||||||
if (node.classList.contains('mfm-sparkle')) {
|
if (node.classList.contains('mfm-sparkle')) {
|
||||||
return h(MkSparkle, {}, mapNodes(node.childNodes));
|
return h(MkSparkle, {}, mapNodes(node.childNodes));
|
||||||
|
@ -37,6 +46,17 @@ export const Markdown = defineComponent({
|
||||||
});
|
});
|
||||||
} else if (node.classList.contains('mfm-emoji')) {
|
} else if (node.classList.contains('mfm-emoji')) {
|
||||||
return h(MkEmoji, { emoji: node.textContent, customEmojis: this.customEmojis });
|
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
|
// fallthrough, just handle like ordinary nodes
|
||||||
default:
|
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 {
|
.animated-mfm {
|
||||||
.mfm-jelly {
|
.mfm-jelly {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
|
@ -4,7 +4,7 @@ import * as foundkey from 'foundkey-js';
|
||||||
import { MFM_TAGS } from '@/scripts/mfm-tags';
|
import { MFM_TAGS } from '@/scripts/mfm-tags';
|
||||||
|
|
||||||
const sanitizerOptions = {
|
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: {
|
allowedAttributes: {
|
||||||
a: [ 'href', 'name', 'target' ],
|
a: [ 'href', 'name', 'target' ],
|
||||||
img: [ 'src', 'srcset', 'alt', 'title', 'width', 'height', 'loading' ],
|
img: [ 'src', 'srcset', 'alt', 'title', 'width', 'height', 'loading' ],
|
||||||
|
@ -39,6 +39,50 @@ marked.setOptions({
|
||||||
sanitizer: (html) => sanitizeHtml(html, inputSanitizerOptions),
|
sanitizer: (html) => sanitizeHtml(html, inputSanitizerOptions),
|
||||||
});
|
});
|
||||||
marked.use({
|
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: [
|
extensions: [
|
||||||
{
|
{
|
||||||
name: 'center-tag',
|
name: 'center-tag',
|
||||||
|
@ -59,7 +103,7 @@ marked.use({
|
||||||
{
|
{
|
||||||
name: 'mention',
|
name: 'mention',
|
||||||
level: 'inline',
|
level: 'inline',
|
||||||
start(src) { return src.match(/(^|\s)@/)?.index; },
|
start(src) { return src.match(/(?<=^|\s)@/)?.index; },
|
||||||
tokenizer(src) {
|
tokenizer(src) {
|
||||||
const match = src.match(/^@([-_a-z0-9]+)(?:@(\p{L}+(?:[-.]*\p{L}+)*))?/iu);
|
const match = src.match(/^@([-_a-z0-9]+)(?:@(\p{L}+(?:[-.]*\p{L}+)*))?/iu);
|
||||||
if (!match) return;
|
if (!match) return;
|
||||||
|
@ -96,6 +140,107 @@ marked.use({
|
||||||
return `<span class="mfm-emoji">${token.raw}</span>`;
|
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',
|
name: 'mfm-function',
|
||||||
level: 'inline',
|
level: 'inline',
|
||||||
|
|
Loading…
Reference in a new issue