markdown for pages #338
4 changed files with 239 additions and 32 deletions
58
packages/client/src/components/markdown.ts
Normal file
58
packages/client/src/components/markdown.ts
Normal file
|
@ -0,0 +1,58 @@
|
|||
import { VNode, defineComponent, h } from 'vue';
|
||||
import * as foundkey from 'foundkey-js';
|
||||
import { markdownToDom } from '@/scripts/markdown';
|
||||
import { host } from '@/config';
|
||||
import MkMention from '@/components/mention.vue';
|
||||
import MkSparkle from '@/components/sparkle.vue';
|
||||
import MkEmoji from '@/components/global/emoji.vue';
|
||||
|
||||
export const Markdown = defineComponent({
|
||||
props: {
|
||||
text: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
author: Object, // foundkey.entities.User
|
||||
customEmojis: Array, // foundkey.entities.CustomEmoji[]
|
||||
isNote: Boolean,
|
||||
},
|
||||
|
||||
render() {
|
||||
if (!this.text) return;
|
||||
const dom = markdownToDom(this.text);
|
||||
|
||||
const mapNodes = (nodes: NodeList): (VNode | string)[] => Array.from(nodes).map(mapNode);
|
||||
|
||||
const mapNode = (node: Node): VNode | string => {
|
||||
switch (node.nodeName) {
|
||||
case '#text':
|
||||
return node.textContent;
|
||||
case 'SPAN':
|
||||
if (node.classList.contains('mfm-sparkle')) {
|
||||
return h(MkSparkle, {}, mapNodes(node.childNodes));
|
||||
} else if (node.classList.contains('mfm-mention')) {
|
||||
return h(MkMention, {
|
||||
username: node.querySelector('.mfm-user').textContent,
|
||||
host: node.querySelector('.mfm-host')?.textContent ?? this.author?.host ?? host,
|
||||
});
|
||||
} else if (node.classList.contains('mfm-emoji')) {
|
||||
return h(MkEmoji, { emoji: node.textContent, customEmojis: this.customEmojis });
|
||||
}
|
||||
// fallthrough, just handle like ordinary nodes
|
||||
default:
|
||||
const attrs = Array.from(node.attributes)
|
||||
.reduce((acc, {name, value}) => {
|
||||
acc[name] = value;
|
||||
return acc;
|
||||
}, {});
|
||||
return h(node.nodeName, attrs, mapNodes(node.childNodes));
|
||||
}
|
||||
};
|
||||
|
||||
return h(
|
||||
'span',
|
||||
{ class: 'markdown-container' },
|
||||
mapNodes(dom.childNodes)
|
||||
);
|
||||
},
|
||||
});
|
|
@ -1,31 +0,0 @@
|
|||
<template>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span v-html="rendered"></span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { marked } from 'marked'
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import * as foundkey from 'foundkey-js';
|
||||
|
||||
const props = defineProps<{
|
||||
text: string;
|
||||
author?: foundkey.entities.User;
|
||||
customEmojis?: foundkey.entities.CustomEmoji[];
|
||||
isNote?: boolean;
|
||||
}>();
|
||||
|
||||
// TODO use extensions
|
||||
// marked.use(...);
|
||||
|
||||
const rendered = sanitizeHtml(marked(props.text), {
|
||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat([ 'img' ]),
|
||||
allowedAttributes: {
|
||||
a: [ 'href', 'name', 'target' ],
|
||||
img: [ 'src', 'srcset', 'alt', 'title', 'width', 'height', 'loading' ],
|
||||
span: [ 'class', 'data-*' ],
|
||||
},
|
||||
allowedSchemes: sanitizeHtml.defaults.allowedSchemes.concat([ 'gopher', 'gemini' ]),
|
||||
disallowedTagsMode: 'escape',
|
||||
});
|
||||
</script>
|
|
@ -65,7 +65,7 @@ import MkFollowButton from '@/components/follow-button.vue';
|
|||
import MkContainer from '@/components/ui/container.vue';
|
||||
import MkPagination from '@/components/ui/pagination.vue';
|
||||
import MkPagePreview from '@/components/page-preview.vue';
|
||||
import Markdown from '@/components/markdown.vue';
|
||||
import { Markdown } from '@/components/markdown.ts';
|
||||
import { i18n } from '@/i18n';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
|
||||
|
|
180
packages/client/src/scripts/markdown.ts
Normal file
180
packages/client/src/scripts/markdown.ts
Normal file
|
@ -0,0 +1,180 @@
|
|||
import { marked } from 'marked';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
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"],
|
||||
allowedAttributes: {
|
||||
a: [ 'href', 'name', 'target' ],
|
||||
img: [ 'src', 'srcset', 'alt', 'title', 'width', 'height', 'loading' ],
|
||||
span: [ 'class', 'style', 'data-mfm-*' ],
|
||||
},
|
||||
allowedClasses: {
|
||||
span: [ /^mfm-/ ],
|
||||
},
|
||||
allowedStyles: {
|
||||
span: {
|
||||
'--mfm-speed': [/^\d*\.?\d+m?s$/],
|
||||
'--mfm-deg': [/^\d*\.?\d+$/],
|
||||
},
|
||||
},
|
||||
allowedSchemes: sanitizeHtml.defaults.allowedSchemes.concat([ 'gopher', 'gemini' ]),
|
||||
disallowedTagsMode: 'escape',
|
||||
};
|
||||
|
||||
const inputSanitizerOptions = {
|
||||
// disallow <span>s in the input and thus try to make sure people
|
||||
// don't sneak spoofed MFM spans in, which could upset the later processing
|
||||
allowedTags: sanitizerOptions.allowedTags.filter(x => x != 'span'),
|
||||
allowedSchemes: sanitizerOptions.allowedSchemes,
|
||||
disallowedTagsMode: 'discard',
|
||||
};
|
||||
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
xhtml: true,
|
||||
// sanitizes the *input* HTML that is already in the markdown before
|
||||
sanitizer: (html) => sanitizeHtml(html, inputSanitizerOptions),
|
||||
});
|
||||
marked.use({
|
||||
extensions: [
|
||||
{
|
||||
name: 'center-tag',
|
||||
level: 'block',
|
||||
start(src) { return src.match(/^<center>/i)?.index; },
|
||||
tokenizer(src) {
|
||||
const match = src.match(/^<center>[\r\n]*(.*)[\r\n]*<\/center>/i);
|
||||
if (match) return {
|
||||
type: 'center-tag',
|
||||
raw: match[0],
|
||||
tokens: this.lexer.blockTokens(match[1]),
|
||||
};
|
||||
},
|
||||
renderer(token) {
|
||||
return '<span class="mfm-center">' + this.parser.parse(token.tokens) + '</span>';
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mention',
|
||||
level: 'inline',
|
||||
start(src) { return src.match(/(^|\s)@/)?.index; },
|
||||
tokenizer(src) {
|
||||
const match = src.match(/^@([-_a-z0-9]+)(?:@(\p{L}+(?:[-.]*\p{L}+)*))?/iu);
|
||||
if (!match) return;
|
||||
|
||||
return {
|
||||
type: 'mention',
|
||||
raw: match[0],
|
||||
user: match[1],
|
||||
host: match.length < 2 ? null : match[2],
|
||||
};
|
||||
},
|
||||
renderer(token) {
|
||||
if (token.host) {
|
||||
return `<span class="mfm-mention">@<span class="mfm-user">${token.user}</span>@<span class="mfm-host">${token.host}</span></span>`;
|
||||
} else {
|
||||
return `<span class="mfm-mention">@<span class="mfm-user">${token.user}</span></span>`;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'custom-emoji',
|
||||
level: 'inline',
|
||||
start(src) { return src.match(/:[-a-z0-9_+]+:/i)?.index; },
|
||||
tokenizer(src) {
|
||||
const match = src.match(/^:([-a-z0-9_+]+):/i);
|
||||
if (!match) return;
|
||||
|
||||
return {
|
||||
type: 'custom-emoji',
|
||||
raw: match[0],
|
||||
};
|
||||
},
|
||||
renderer(token) {
|
||||
return `<span class="mfm-emoji">${token.raw}</span>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mfm-function',
|
||||
level: 'inline',
|
||||
start(src) { return src.indexOf('$['); },
|
||||
tokenizer(src) {
|
||||
/* ABNF of the regex below, the regex matches the <mfm-fn> rule
|
||||
name = 1*(ALPHA / DIGIT / "_") ; one or more "word" characters, Ecmascripts \w
|
||||
|
||||
argument = <name> ["=" <name>] ; arguments are key = value pairs
|
||||
|
||||
mfm-fn = "$[" <name> ; start of the function
|
||||
["." <argument> *("," <argument>)] ; optionally with parameters
|
||||
; note that multiple arguments are separated with commas not dots
|
||||
SP <content> "]" ; end of the function
|
||||
*/
|
||||
const match = src.match(/^\$\[(\w+)(\.\w+(?:=\w+)?(?:,\w+(?:=\w+)?)*)? (.+)\]/);
|
||||
if (!match || !MFM_TAGS.includes(match[1])) return;
|
||||
|
||||
let args = {};
|
||||
if (match[2]) {
|
||||
// parse args
|
||||
match[2]
|
||||
// slice off the initial dot
|
||||
.slice(1)
|
||||
// split arguments by comma
|
||||
.split(',')
|
||||
// split argument name and value
|
||||
.map((arg) => {
|
||||
console.log("mfm arg", arg);
|
||||
if (arg.includes('=')) {
|
||||
// split once at first equal sign
|
||||
const equalsIdx = arg.indexOf('=');
|
||||
return [arg.slice(0, equalsIdx), arg.slice(equalsIdx + 1)];
|
||||
} else {
|
||||
return [arg, null];
|
||||
}
|
||||
})
|
||||
// save arguments
|
||||
.forEach(([key, val]) => args[key] = val);
|
||||
}
|
||||
|
||||
const token = {
|
||||
type: 'mfm-function',
|
||||
raw: match[0],
|
||||
fn: match[1],
|
||||
args,
|
||||
tokens: [],
|
||||
};
|
||||
this.lexer.inline(match[3], token.tokens);
|
||||
return token;
|
||||
},
|
||||
renderer(token) {
|
||||
// arguments are mapped to `data-mfm-...` attributes for CSS selectors
|
||||
const argsAttrs = Object.entries(token.args)
|
||||
.reduce((acc, [key, value]) => {
|
||||
if (value == null) {
|
||||
return acc + ` data-mfm-${key}`;
|
||||
} else {
|
||||
return acc + ` data-mfm-${key}="${value}"`;
|
||||
}
|
||||
}, '');
|
||||
// ... and also to CSS variables so the values can be accessed in CSS
|
||||
const argsCss = Object.entries(token.args)
|
||||
.reduce((acc, [key, value]) => {
|
||||
if (value == null) {
|
||||
return acc + ` --mfm-${key}: 1;`;
|
||||
} else {
|
||||
return acc + ` --mfm-${key}: ${value};`;
|
||||
}
|
||||
}, '');
|
||||
|
||||
return `<span class="mfm-${token.fn}" style="${argsCss}"${argsAttrs}>${this.parser.parseInline(token.tokens)}</span>`;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export function markdownToDom(text) {
|
||||
const elem = document.createElement('span');
|
||||
elem.innerHTML = sanitizeHtml(marked(text), sanitizerOptions);
|
||||
return elem;
|
||||
}
|
Loading…
Reference in a new issue