markdown for pages #338

Open
Johann150 wants to merge 20 commits from markdown into main
4 changed files with 239 additions and 32 deletions
Showing only changes of commit f6387323c2 - Show all commits

View 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)
);
},
});

View file

@ -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>

View file

@ -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';

View 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;
}