markdown for pages #338

Open
Johann150 wants to merge 20 commits from markdown into main
11 changed files with 891 additions and 127 deletions

View file

@ -33,11 +33,13 @@
"insert-text-at-cursor": "0.3.0",
"json5": "2.2.1",
"katex": "0.16.0",
"marked": "4.0.8",
"matter-js": "0.18.0",
"mfm-js": "0.23.3",
"photoswipe": "5.2.8",
"prismjs": "1.28.0",
"punycode": "2.1.1",
"sanitize-html": "2.8.0",
"sass": "1.53.0",
"stringz": "2.1.0",
"syuilo-password-strength": "0.0.1",
@ -57,8 +59,10 @@
},
"devDependencies": {
"@types/katex": "0.14.0",
"@types/marked": "4.0.8",
"@types/matter-js": "0.17.7",
"@types/punycode": "2.1.0",
"@types/sanitize-html": "2.8.0",
"@types/throttle-debounce": "5.0.0",
"@types/tinycolor2": "1.4.3",
"@types/uuid": "8.3.4",

View file

@ -1,7 +1,7 @@
<template>
<div class="adhpbeos">
<div class="label" @click="focus"><slot name="label"></slot></div>
<div class="input" :class="{ disabled, focused, tall, pre }">
<div class="input" :class="{ disabled, focused, tall, pre, markdown }">
<textarea
ref="inputEl"
v-model="v"
@ -20,6 +20,10 @@
@keydown="onKeydown($event)"
@input="onInput"
></textarea>
<a v-if="markdown" href="/mfm-cheat-sheet" class="md-hint">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 208 128"><path d="M193 128H15a15 15 0 0 1-15-15V15A15 15 0 0 1 15 0h178a15 15 0 0 1 15 15v98a15 15 0 0 1-15 15zM50 98V59l20 25 20-25v39h20V30H90L70 55 50 30H30v68zm134-34h-20V30h-20v34h-20l30 35z"/></svg>
Markdown supported
</a>
</div>
<div class="caption"><slot name="caption"></slot></div>
@ -56,6 +60,7 @@ const props = withDefaults(defineProps<{
debounce?: boolean;
manualSave?: boolean;
max?: number;
markdown?: boolean;
}>(), {
pattern: undefined,
placeholder: '',
@ -63,6 +68,7 @@ const props = withDefaults(defineProps<{
tall: false,
pre: false,
manualSave: false,
markdown: false,
});
const { modelValue } = toRefs(props);
@ -166,10 +172,8 @@ onMounted(() => {
}
}
&.focused {
> textarea {
border-color: var(--accent) !important;
}
&.focused > textarea {
border-color: var(--accent) !important;
}
&.disabled {
@ -180,15 +184,33 @@ onMounted(() => {
}
}
&.tall {
> textarea {
min-height: 200px;
}
&.tall > textarea {
min-height: 200px;
}
&.pre {
&.pre > textarea {
white-space: pre;
}
&.markdown {
> textarea {
white-space: pre;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
border-bottom-color: var(--fg);
}
> .md-hint {
background-color: var(--panel);
border-bottom-right-radius: 6px;
border-bottom-left-radius: 6px;
padding: .3em;
display: block;
> svg {
height: .7em;
fill: var(--fg);
padding: 0 .3em;
}
}
}
}

View file

@ -0,0 +1,87 @@
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';
import MkLink from '@/components/link.vue';
import MkCode from '@/components/code.vue';
import MkFormula from '@/components/formula.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 'A':
return h(MkLink, {
url: node.getAttribute('href'),
rel: 'nofollow noopener',
}, mapNodes(node.childNodes));
case 'CODE':
return h(MkCode, {
code: node.innerText,
inline: true,
});
case 'PRE':
if (node.childNodes.length === 1 && node.childNodes[0].tagName === 'CODE') {
return h(MkCode, {
code: node.childNodes[0].textContent,
// TODO: lang attribute for language highlighting
inline: false,
});
}
// fallthrough
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 });
} 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:
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 ' + (this.$store.state.animatedMfm ? 'animated-mfm' : ''),
},
mapNodes(dom.childNodes)
);
},
});

View file

@ -45,8 +45,7 @@ export default defineComponent({
const validTime = (t: string | true) => {
if (typeof t !== 'string') return null;
return t.match(/^[0-9.]+s$/) ? t : null;
return t.match(/^[0-9.]+m?s$/) ? t : null;
};
const genEl = (ast: mfm.MfmNode[]) => ast.map((token): VNode | VNode[] => {
@ -82,140 +81,63 @@ export default defineComponent({
}
case 'fn': {
// TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる
let style;
const attributes = Object.keys(token.props.args).reduce((acc, x) => {
if (!['deg', 'speed', 'color'].includes(x)) acc['data-mfm-' + x] = true;
return acc;
}, {});
switch (token.props.name) {
case 'tada': {
const speed = validTime(token.props.args.speed) || '1s';
style = 'font-size: 150%;' + (this.$store.state.animatedMfm ? `animation: mfm-tada ${speed} linear infinite both;` : '');
break;
}
case 'jelly': {
const speed = validTime(token.props.args.speed) || '1s';
style = (this.$store.state.animatedMfm ? `animation: mfm-rubberBand ${speed} linear infinite both;` : '');
break;
}
case 'twitch': {
const speed = validTime(token.props.args.speed) || '0.5s';
style = this.$store.state.animatedMfm ? `animation: mfm-twitch ${speed} ease infinite;` : '';
break;
}
case 'shake': {
const speed = validTime(token.props.args.speed) || '0.5s';
style = this.$store.state.animatedMfm ? `animation: mfm-shake ${speed} ease infinite;` : '';
break;
}
case 'spin': {
const direction =
token.props.args.left ? 'reverse' :
token.props.args.alternate ? 'alternate' :
'normal';
const anime =
token.props.args.x ? 'mfm-spinX' :
token.props.args.y ? 'mfm-spinY' :
'mfm-spin';
const speed = validTime(token.props.args.speed) || '1.5s';
style = this.$store.state.animatedMfm ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : '';
break;
}
case 'jump': {
const speed = validTime(token.props.args.speed) || '0.75s';
style = this.$store.state.animatedMfm ? `animation: mfm-jump ${speed} linear infinite;` : '';
break;
}
case 'bounce': {
const speed = validTime(token.props.args.speed) || '0.75s';
style = this.$store.state.animatedMfm ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom;` : '';
break;
}
case 'flip': {
const transform =
(token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' :
token.props.args.v ? 'scaleY(-1)' :
'scaleX(-1)';
style = `transform: ${transform};`;
break;
}
case 'x2': {
return h('span', {
class: 'mfm-x2'
}, genEl(token.children));
}
case 'x3': {
return h('span', {
class: 'mfm-x3'
}, genEl(token.children));
}
case 'x4': {
return h('span', {
class: 'mfm-x4'
}, genEl(token.children));
}
case 'font': {
const family =
token.props.args.serif ? 'serif' :
token.props.args.monospace ? 'monospace' :
token.props.args.cursive ? 'cursive' :
token.props.args.fantasy ? 'fantasy' :
token.props.args.emoji ? 'emoji' :
token.props.args.math ? 'math' :
null;
if (family) style = `font-family: ${family};`;
break;
}
case 'blur': {
return h('span', {
class: '_mfm_blur_',
}, genEl(token.children));
}
case 'rainbow': {
const speed = validTime(token.props.args.speed) || '1s';
style = this.$store.state.animatedMfm ? `animation: mfm-rainbow ${speed} linear infinite;` : '';
break;
}
case 'sparkle': {
if (!this.$store.state.animatedMfm) {
return genEl(token.children);
}
return h(MkSparkle, {}, genEl(token.children));
}
case 'rotate': {
const degrees = (typeof token.props.args.deg === 'string' ? parseInt(token.props.args.deg) : null) || '90';
style = `transform: rotate(${degrees}deg); transform-origin: center center;`;
break;
}
case 'position': {
const x = parseFloat(token.props.args.x ?? '0');
const y = parseFloat(token.props.args.y ?? '0');
style = `transform: translate(${x}em, ${y}em);`;
attributes.style = `transform: translate(${x}em, ${y}em);`;
break;
}
case 'scale': {
const x = Math.min(parseFloat(token.props.args.x ?? '1'), 5);
const y = Math.min(parseFloat(token.props.args.y ?? '1'), 5);
style = `transform: scale(${x}, ${y});`;
attributes.style = `transform: scale(${x}, ${y});`;
break;
}
case 'fg': {
let color = token.props.args.color ?? 'f00';
if (!/^([0-9a-f]{3}){1,2}$/i.test(color)) color = 'f00';
style = `color: #${color};`;
attributes.style = `color: #${color};`;
break;
}
case 'bg': {
let color = token.props.args.color ?? '0f0';
if (!/^([0-9a-f]{3}){1,2}$/i.test(color)) color = '0f0';
style = `background-color: #${color};`;
attributes.style = `background-color: #${color};`;
break;
}
case 'bounce':
case 'jelly':
case 'jump':
case 'rainbow':
case 'shake':
case 'spin':
case 'tada':
case 'twitch':
if (token.props.args.speed) {
attributes.style = '--mfm-speed: ' + validTime(token.props.args.speed);
}
break;
case 'rotate':
if (!isNaN(parseInt(token.props.args.deg))) {
attributes.style = '--mfm-deg: ' + parseInt(token.props.args.deg);
}
break;
}
if (style == null) {
return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children), ']']);
} else {
return h('span', {
style: 'display: inline-block;' + style,
}, genEl(token.children));
}
return h('span', {
class: 'mfm-' + token.props.name,
...attributes,
}, genEl(token.children));
}
case 'small': {
@ -225,8 +147,8 @@ export default defineComponent({
}
case 'center': {
return h('div', {
style: 'text-align:center;',
return h('span', {
class: 'mfm-center',
}, genEl(token.children));
}
@ -334,6 +256,8 @@ export default defineComponent({
}).flat();
// Parse ast to DOM
return h('span', genEl(ast));
return h('span', {
class: this.$store.state.animatedMfm ? 'animated-mfm' : '',
}, genEl(ast));
},
});

View file

@ -3,6 +3,7 @@
*/
import '@/style.scss';
import '@/mfm.scss';
//#region account indexedDB migration
import { set } from '@/scripts/idb-proxy';

View file

@ -0,0 +1,274 @@
.mfm-center {
display: block;
text-align: center;
}
.mfm-flip {
display: inline-block;
transform: scaleX(-1);
}
.mfm-flip[data-mfm-v] {
transform: scaleY(-1);
}
.mfm-flip[data-mfm-v][data-mfm-h] {
transform: scale(-1, -1);
}
.mfm-font[data-mfm-serif] {
font-family: serif;
}
.mfm-font[data-mfm-monospace] {
font-family: monospace;
}
.mfm-font[data-mfm-cursive] {
font-family: cursive;
}
.mfm-font[data-mfm-fantasy] {
font-family: fantasy;
}
.mfm-font[data-mfm-emoji] {
font-family: emoji;
}
.mfm-font[data-mfm-math] {
font-family: math;
}
.mfm-blur {
filter: blur(6px);
transition: filter 0.3s;
&:hover {
filter: blur(0px);
}
}
.mfm-rotate {
display: inline-block;
transform: rotate(calc(var(--mfm-deg, 90) * 1deg));
transform-origin: center center;
}
.mfm-x2 {
--mfm-zoom-size: 200%;
}
.mfm-x3 {
--mfm-zoom-size: 400%;
}
.mfm-x4 {
--mfm-zoom-size: 600%;
}
.mfm-x2, .mfm-x3, .mfm-x4, .mfm-tada {
font-size: var(--mfm-zoom-size);
.mfm-x2, .mfm-x3, .mfm-x4, .mfm-tada {
/* only half effective */
font-size: calc(var(--mfm-zoom-size) / 2 + 50%);
.mfm-x2, .mfm-x3, .mfm-x4, .mfm-tada {
/* disabled */
font-size: 100%;
}
}
}
.mfm-position {
transform: translate(calc(var(--mfm-x, 0) * 1em), calc(var(--mfm-y, 0) * 1em));
}
.mfm-scale {
transform: scale(var(--mfm-x, 1), var(--mfm-y, 1));
}
.mfm-fg {
color: var(--mfm-color, #f00);
}
.mfm-bg {
background-color: var(--mfm-color, #0f0);
}
.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;
animation: mfm-rubberBand var(--mfm-speed, 1s) linear infinite both;
}
.mfm-twitch {
display: inline-block;
animation: mfm-twitch var(--mfm-speed, .5s) ease infinite;
}
.mfm-shake {
display: inline-block;
animation: mfm-shake var(--mfm-speed, .5s) ease infinite;
}
.mfm-spin {
display: inline-block;
animation: mfm-spin var(--mfm-speed, 1.5s) linear infinite;
}
.mfm-spin[data-mfm-y] {
animation-name: mfm-spinY;
}
.mfm-spin[data-mfm-x] {
animation-name: mfm-spinX;
}
.mfm-spin[data-mfm-alternate] {
animation-direction: alternate;
}
.mfm-spin[data-mfm-left] {
animation-direction: reverse;
}
.mfm-jump {
display: inline-block;
animation: mfm-jump var(--mfm-speed, .75s) linear infinite;
}
.mfm-bounce {
display: inline-block;
animation: mfm-bounce var(--mfm-speed, .75s) linear infinite;
transform-origin: center bottom;
}
.mfm-rainbow {
animation: mfm-rainbow var(--mfm-speed, 1s) linear infinite;
}
.mfm-tada {
display: inline-block;
animation: mfm-tada var(--mfm-speed, 1s) linear infinite both;
--mfm-zoom-size: 150%;
}
}
/* animation keyframes */
@keyframes mfm-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes mfm-spinX {
0% { transform: perspective(128px) rotateX(0deg); }
100% { transform: perspective(128px) rotateX(360deg); }
}
@keyframes mfm-spinY {
0% { transform: perspective(128px) rotateY(0deg); }
100% { transform: perspective(128px) rotateY(360deg); }
}
@keyframes mfm-jump {
0% { transform: translateY(0); }
25% { transform: translateY(-16px); }
50% { transform: translateY(0); }
75% { transform: translateY(-8px); }
100% { transform: translateY(0); }
}
@keyframes mfm-bounce {
0% { transform: translateY(0) scale(1, 1); }
25% { transform: translateY(-16px) scale(1, 1); }
50% { transform: translateY(0) scale(1, 1); }
75% { transform: translateY(0) scale(1.5, 0.75); }
100% { transform: translateY(0) scale(1, 1); }
}
// const val = () => `translate(${Math.floor(Math.random() * 20) - 10}px, ${Math.floor(Math.random() * 20) - 10}px)`;
// let css = '';
// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
@keyframes mfm-twitch {
0% { transform: translate(7px, -2px) }
5% { transform: translate(-3px, 1px) }
10% { transform: translate(-7px, -1px) }
15% { transform: translate(0px, -1px) }
20% { transform: translate(-8px, 6px) }
25% { transform: translate(-4px, -3px) }
30% { transform: translate(-4px, -6px) }
35% { transform: translate(-8px, -8px) }
40% { transform: translate(4px, 6px) }
45% { transform: translate(-3px, 1px) }
50% { transform: translate(2px, -10px) }
55% { transform: translate(-7px, 0px) }
60% { transform: translate(-2px, 4px) }
65% { transform: translate(3px, -8px) }
70% { transform: translate(6px, 7px) }
75% { transform: translate(-7px, -2px) }
80% { transform: translate(-7px, -8px) }
85% { transform: translate(9px, 3px) }
90% { transform: translate(-3px, -2px) }
95% { transform: translate(-10px, 2px) }
100% { transform: translate(-2px, -6px) }
}
// const val = () => `translate(${Math.floor(Math.random() * 6) - 3}px, ${Math.floor(Math.random() * 6) - 3}px) rotate(${Math.floor(Math.random() * 24) - 12}deg)`;
// let css = '';
// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
@keyframes mfm-shake {
0% { transform: translate(-3px, -1px) rotate(-8deg) }
5% { transform: translate(0px, -1px) rotate(-10deg) }
10% { transform: translate(1px, -3px) rotate(0deg) }
15% { transform: translate(1px, 1px) rotate(11deg) }
20% { transform: translate(-2px, 1px) rotate(1deg) }
25% { transform: translate(-1px, -2px) rotate(-2deg) }
30% { transform: translate(-1px, 2px) rotate(-3deg) }
35% { transform: translate(2px, 1px) rotate(6deg) }
40% { transform: translate(-2px, -3px) rotate(-9deg) }
45% { transform: translate(0px, -1px) rotate(-12deg) }
50% { transform: translate(1px, 2px) rotate(10deg) }
55% { transform: translate(0px, -3px) rotate(8deg) }
60% { transform: translate(1px, -1px) rotate(8deg) }
65% { transform: translate(0px, -1px) rotate(-7deg) }
70% { transform: translate(-1px, -3px) rotate(6deg) }
75% { transform: translate(0px, -2px) rotate(4deg) }
80% { transform: translate(-2px, -1px) rotate(3deg) }
85% { transform: translate(1px, -3px) rotate(-10deg) }
90% { transform: translate(1px, 0px) rotate(3deg) }
95% { transform: translate(-2px, 0px) rotate(-3deg) }
100% { transform: translate(2px, 1px) rotate(2deg) }
}
@keyframes mfm-rubberBand {
from { transform: scale3d(1, 1, 1); }
30% { transform: scale3d(1.25, 0.75, 1); }
40% { transform: scale3d(0.75, 1.25, 1); }
50% { transform: scale3d(1.15, 0.85, 1); }
65% { transform: scale3d(0.95, 1.05, 1); }
75% { transform: scale3d(1.05, 0.95, 1); }
to { transform: scale3d(1, 1, 1); }
}
@keyframes mfm-rainbow {
0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); }
100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); }
}
@keyframes mfm-tada {
0%, 100% { transform: scale3d(1, 1, 1); }
10%, 20% { transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg); }
30%, 50%, 70%, 90% { transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg); }
40%, 60%, 80% { transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg); }
}

View file

@ -45,7 +45,7 @@
</div>
<div v-else-if="tab === 'contents'">
<MkTextarea v-model="text" :readonly="readonly"/>
<MkTextarea v-model="text" :readonly="readonly" markdown/>
</div>
</MkSpacer>
</MkStickyContainer>

View file

@ -9,7 +9,7 @@
<img v-if="page.eyeCatchingImageId" :src="page.eyeCatchingImage.url"/>
</div>
<div class="content" :class="{ center: page.alignCenter, serif: page.font === 'serif' }">
<Mfm :text="page.text" :is-note="false"/>
<Markdown v-once :text="page.text" :is-note="false"/>
</div>
<div class="actions">
<div class="like">
@ -65,6 +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.ts';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';

View file

@ -5,9 +5,9 @@ import { unique } from '@/scripts/array';
// [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ]
const removeHash = (x: string) => x.replace(/#[^#]*$/, '');
export function extractUrlFromMfm(nodes: mfm.MfmNode[], respectSilentFlag = true): string[] {
export function extractUrlFromMfm(nodes: mfm.MfmNode[]): string[] {
const urlNodes = mfm.extract(nodes, (node) => {
return (node.type === 'url') || (node.type === 'link' && (!respectSilentFlag || !node.props.silent));
return (node.type === 'url') || (node.type === 'link' && !node.props.silent);
});
const urls: string[] = unique(urlNodes.map(x => x.props.url));

View file

@ -0,0 +1,358 @@
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", "del"],
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$/], // decimal number (e.g. 1 or 1.0 or .1) followed by "ms" or "s"
'--mfm-deg': [/^\d*\.?\d+$/], // decimal number
'--mfm-x': [/^\d*\.?\d+$/], // decimal number
'--mfm-y': [/^\d*\.?\d+$/], // decimal number
'--mfm-color': [/^#([0-9a-f]{3}){1,2}$/i], // CSS hex color code without alpha
},
},
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({
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,
};
},
},
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: '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',
start(src) { return src.indexOf('$['); },
tokenizer(src) {
/*
* ABNF of the regex below, the regex matches the <mfm-fn> rule
* SECURITY: neither argument key nor value must contain any "HTML dangerous" characters
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) => {
if (arg.includes('=')) {
// split once at first equal sign
const equalsIdx = arg.indexOf('=');
const key = arg.slice(0, equalsIdx);
let value = arg.slice(equalsIdx + 1);
// add initial octothorpe to hex color code if necessary
if (key === 'color' && !value.startsWith('#')) {
value = '#' + value;
}
return [key, value];
} 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) {
// SECURITY: key does not need to be escaped because only "word" characters will be matched in the tokenizer
return acc + ` data-mfm-${key}`;
} else {
// SECURITY: key and value do not need to be escaped because only "word" characters will be matched in the tokenizer
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>`;
},
},
{
name: 'silent-link',
level: 'inline',
start(src) { return src.startsWith('?['),
tokenizer(src) {
// a markdown [title](link) with prepended question mark to signify
// that no link preview should be shown for this link
// regular expression abridged from marked.js internals
const match = src.match(/^\?\[((?:\\.|[^\[\]\\])*)\]\(\s*(?:<((?:\\.|[^\n<>\\])+)>|([^\s\x00-\x1f]*))\s*\)/);
if (!match) return;
try {
// match group 2 when enclosed in <> and match group 3 otherwise
const href = new URL(match[2] ?? match[3]).href;
} catch {
// invalid URL
return;
}
const token = {
type: 'silent-link',
raw: match[0],
tokens: [],
href,
};
this.lexer.inline(match[1], token.tokens);
return token;
},
renderer(token) {
const secureUrl = encodeURI(token.href).replaceAll("%25", "%");
return `<a href="${secureUrl}" rel="nofollow">${this.parser.parseInline(token.tokens)}</a>`;
},
},
],
});
export function markdownToDom(text) {
const elem = document.createElement('span');
elem.innerHTML = sanitizeHtml(marked(text), sanitizerOptions);
return elem;
}

View file

@ -2146,6 +2146,13 @@ __metadata:
languageName: node
linkType: hard
"@types/marked@npm:4.0.8":
version: 4.0.8
resolution: "@types/marked@npm:4.0.8"
checksum: 68278fa7acaa5d920cdc239d675b5daf842e0ad4779e4848cd617d9baf2ac1afccb5a264c331e37d80031d647e1640cb983cd31e73d45b28552670b4853fad8e
languageName: node
linkType: hard
"@types/matter-js@npm:0.17.7":
version: 0.17.7
resolution: "@types/matter-js@npm:0.17.7"
@ -2364,6 +2371,15 @@ __metadata:
languageName: node
linkType: hard
"@types/sanitize-html@npm:2.8.0":
version: 2.8.0
resolution: "@types/sanitize-html@npm:2.8.0"
dependencies:
htmlparser2: ^8.0.0
checksum: 6e583cac673832536fac8da53890073f753baf2c49826fd0c2831e615cb5527692d03b2b5ba9eb8caf8694de4bfb1c31fd12398d2b68331725590a6ceb8f82fe
languageName: node
linkType: hard
"@types/semver@npm:7.3.12":
version: 7.3.12
resolution: "@types/semver@npm:7.3.12"
@ -4688,8 +4704,10 @@ __metadata:
"@rollup/pluginutils": ^4.2.1
"@syuilo/aiscript": 0.11.1
"@types/katex": 0.14.0
"@types/marked": 4.0.8
"@types/matter-js": 0.17.7
"@types/punycode": 2.1.0
"@types/sanitize-html": 2.8.0
"@types/throttle-debounce": 5.0.0
"@types/tinycolor2": 1.4.3
"@types/uuid": 8.3.4
@ -4719,11 +4737,13 @@ __metadata:
insert-text-at-cursor: 0.3.0
json5: 2.2.1
katex: 0.16.0
marked: 4.0.8
matter-js: 0.18.0
mfm-js: 0.23.3
photoswipe: 5.2.8
prismjs: 1.28.0
punycode: 2.1.1
sanitize-html: 2.8.0
sass: 1.53.0
stringz: 2.1.0
syuilo-password-strength: 0.0.1
@ -5982,6 +6002,17 @@ __metadata:
languageName: node
linkType: hard
"dom-serializer@npm:^2.0.0":
version: 2.0.0
resolution: "dom-serializer@npm:2.0.0"
dependencies:
domelementtype: ^2.3.0
domhandler: ^5.0.2
entities: ^4.2.0
checksum: cd1810544fd8cdfbd51fa2c0c1128ec3a13ba92f14e61b7650b5de421b88205fd2e3f0cc6ace82f13334114addb90ed1c2f23074a51770a8e9c1273acbc7f3e6
languageName: node
linkType: hard
"dom-serializer@npm:~0.1.0":
version: 0.1.1
resolution: "dom-serializer@npm:0.1.1"
@ -5999,7 +6030,7 @@ __metadata:
languageName: node
linkType: hard
"domelementtype@npm:^2.0.1, domelementtype@npm:^2.2.0":
"domelementtype@npm:^2.0.1, domelementtype@npm:^2.2.0, domelementtype@npm:^2.3.0":
version: 2.3.0
resolution: "domelementtype@npm:2.3.0"
checksum: ee837a318ff702622f383409d1f5b25dd1024b692ef64d3096ff702e26339f8e345820f29a68bcdcea8cfee3531776b3382651232fbeae95612d6f0a75efb4f6
@ -6042,6 +6073,15 @@ __metadata:
languageName: node
linkType: hard
"domhandler@npm:^5.0.1, domhandler@npm:^5.0.2":
version: 5.0.3
resolution: "domhandler@npm:5.0.3"
dependencies:
domelementtype: ^2.3.0
checksum: 0f58f4a6af63e6f3a4320aa446d28b5790a009018707bce2859dcb1d21144c7876482b5188395a188dfa974238c019e0a1e610d2fc269a12b2c192ea2b0b131c
languageName: node
linkType: hard
"domutils@npm:1.5.1":
version: 1.5.1
resolution: "domutils@npm:1.5.1"
@ -6073,6 +6113,17 @@ __metadata:
languageName: node
linkType: hard
"domutils@npm:^3.0.1":
version: 3.0.1
resolution: "domutils@npm:3.0.1"
dependencies:
dom-serializer: ^2.0.0
domelementtype: ^2.3.0
domhandler: ^5.0.1
checksum: 23aa7a840572d395220e173cb6263b0d028596e3950100520870a125af33ff819e6f609e1606d6f7d73bd9e7feb03bb404286e57a39063b5384c62b724d987b3
languageName: node
linkType: hard
"dotenv@npm:^16.0.0":
version: 16.0.1
resolution: "dotenv@npm:16.0.1"
@ -6269,6 +6320,13 @@ __metadata:
languageName: node
linkType: hard
"entities@npm:^4.2.0":
version: 4.4.0
resolution: "entities@npm:4.4.0"
checksum: 84d250329f4b56b40fa93ed067b194db21e8815e4eb9b59f43a086f0ecd342814f6bc483de8a77da5d64e0f626033192b1b4f1792232a7ea6b970ebe0f3187c2
languageName: node
linkType: hard
"entities@npm:^4.3.0":
version: 4.3.1
resolution: "entities@npm:4.3.1"
@ -8770,6 +8828,18 @@ __metadata:
languageName: node
linkType: hard
"htmlparser2@npm:^8.0.0":
version: 8.0.1
resolution: "htmlparser2@npm:8.0.1"
dependencies:
domelementtype: ^2.3.0
domhandler: ^5.0.2
domutils: ^3.0.1
entities: ^4.3.0
checksum: 06d5c71e8313597722bc429ae2a7a8333d77bd3ab07ccb916628384b37332027b047f8619448d8f4a3312b6609c6ea3302a4e77435d859e9e686999e6699ca39
languageName: node
linkType: hard
"http-assert@npm:^1.3.0":
version: 1.5.0
resolution: "http-assert@npm:1.5.0"
@ -11597,6 +11667,15 @@ __metadata:
languageName: node
linkType: hard
"marked@npm:4.0.8":
version: 4.0.8
resolution: "marked@npm:4.0.8"
bin:
marked: bin/marked.js
checksum: b2dba00757640724a3ba93d3ceeffbd5d5ef91f03d826da66375cb07510e7396a4d0d86e216dc9d9da5e84a5368864ab9f37cfc559fb2ca492f65603db85cc34
languageName: node
linkType: hard
"matchdep@npm:^2.0.0":
version: 2.0.0
resolution: "matchdep@npm:2.0.0"
@ -15053,6 +15132,20 @@ __metadata:
languageName: node
linkType: hard
"sanitize-html@npm:2.8.0":
version: 2.8.0
resolution: "sanitize-html@npm:2.8.0"
dependencies:
deepmerge: ^4.2.2
escape-string-regexp: ^4.0.0
htmlparser2: ^8.0.0
is-plain-object: ^5.0.0
parse-srcset: ^1.0.2
postcss: ^8.3.11
checksum: 3617dc6a99e87c5875e3dfd80df77ca273ab0729f825ddbffcf40a7dd353208ccfe7b0bb01ac48d03e18c2dd88f7bb934f689b6e4393d7564ee8a4ec039bc840
languageName: node
linkType: hard
"sass@npm:1.53.0":
version: 1.53.0
resolution: "sass@npm:1.53.0"