markdown for pages #338
11 changed files with 891 additions and 127 deletions
|
@ -33,11 +33,13 @@
|
||||||
"insert-text-at-cursor": "0.3.0",
|
"insert-text-at-cursor": "0.3.0",
|
||||||
"json5": "2.2.1",
|
"json5": "2.2.1",
|
||||||
"katex": "0.16.0",
|
"katex": "0.16.0",
|
||||||
|
"marked": "4.0.8",
|
||||||
"matter-js": "0.18.0",
|
"matter-js": "0.18.0",
|
||||||
"mfm-js": "0.23.3",
|
"mfm-js": "0.23.3",
|
||||||
"photoswipe": "5.2.8",
|
"photoswipe": "5.2.8",
|
||||||
"prismjs": "1.28.0",
|
"prismjs": "1.28.0",
|
||||||
"punycode": "2.1.1",
|
"punycode": "2.1.1",
|
||||||
|
"sanitize-html": "2.8.0",
|
||||||
"sass": "1.53.0",
|
"sass": "1.53.0",
|
||||||
"stringz": "2.1.0",
|
"stringz": "2.1.0",
|
||||||
"syuilo-password-strength": "0.0.1",
|
"syuilo-password-strength": "0.0.1",
|
||||||
|
@ -57,8 +59,10 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/katex": "0.14.0",
|
"@types/katex": "0.14.0",
|
||||||
|
"@types/marked": "4.0.8",
|
||||||
"@types/matter-js": "0.17.7",
|
"@types/matter-js": "0.17.7",
|
||||||
"@types/punycode": "2.1.0",
|
"@types/punycode": "2.1.0",
|
||||||
|
"@types/sanitize-html": "2.8.0",
|
||||||
"@types/throttle-debounce": "5.0.0",
|
"@types/throttle-debounce": "5.0.0",
|
||||||
"@types/tinycolor2": "1.4.3",
|
"@types/tinycolor2": "1.4.3",
|
||||||
"@types/uuid": "8.3.4",
|
"@types/uuid": "8.3.4",
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="adhpbeos">
|
<div class="adhpbeos">
|
||||||
<div class="label" @click="focus"><slot name="label"></slot></div>
|
<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
|
<textarea
|
||||||
ref="inputEl"
|
ref="inputEl"
|
||||||
v-model="v"
|
v-model="v"
|
||||||
|
@ -20,6 +20,10 @@
|
||||||
@keydown="onKeydown($event)"
|
@keydown="onKeydown($event)"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
></textarea>
|
></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>
|
||||||
<div class="caption"><slot name="caption"></slot></div>
|
<div class="caption"><slot name="caption"></slot></div>
|
||||||
|
|
||||||
|
@ -56,6 +60,7 @@ const props = withDefaults(defineProps<{
|
||||||
debounce?: boolean;
|
debounce?: boolean;
|
||||||
manualSave?: boolean;
|
manualSave?: boolean;
|
||||||
max?: number;
|
max?: number;
|
||||||
|
markdown?: boolean;
|
||||||
}>(), {
|
}>(), {
|
||||||
pattern: undefined,
|
pattern: undefined,
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
|
@ -63,6 +68,7 @@ const props = withDefaults(defineProps<{
|
||||||
tall: false,
|
tall: false,
|
||||||
pre: false,
|
pre: false,
|
||||||
manualSave: false,
|
manualSave: false,
|
||||||
|
markdown: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { modelValue } = toRefs(props);
|
const { modelValue } = toRefs(props);
|
||||||
|
@ -166,10 +172,8 @@ onMounted(() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.focused {
|
&.focused > textarea {
|
||||||
> textarea {
|
border-color: var(--accent) !important;
|
||||||
border-color: var(--accent) !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.disabled {
|
&.disabled {
|
||||||
|
@ -180,15 +184,33 @@ onMounted(() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.tall {
|
&.tall > textarea {
|
||||||
> textarea {
|
min-height: 200px;
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.pre {
|
&.pre > textarea {
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.markdown {
|
||||||
> textarea {
|
> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
87
packages/client/src/components/markdown.ts
Normal file
87
packages/client/src/components/markdown.ts
Normal 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)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
|
@ -45,8 +45,7 @@ export default defineComponent({
|
||||||
|
|
||||||
const validTime = (t: string | true) => {
|
const validTime = (t: string | true) => {
|
||||||
if (typeof t !== 'string') return null;
|
if (typeof t !== 'string') return null;
|
||||||
|
return t.match(/^[0-9.]+m?s$/) ? t : null;
|
||||||
return t.match(/^[0-9.]+s$/) ? t : null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const genEl = (ast: mfm.MfmNode[]) => ast.map((token): VNode | VNode[] => {
|
const genEl = (ast: mfm.MfmNode[]) => ast.map((token): VNode | VNode[] => {
|
||||||
|
@ -82,140 +81,63 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'fn': {
|
case 'fn': {
|
||||||
// TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる
|
const attributes = Object.keys(token.props.args).reduce((acc, x) => {
|
||||||
let style;
|
if (!['deg', 'speed', 'color'].includes(x)) acc['data-mfm-' + x] = true;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
switch (token.props.name) {
|
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': {
|
case 'sparkle': {
|
||||||
if (!this.$store.state.animatedMfm) {
|
if (!this.$store.state.animatedMfm) {
|
||||||
return genEl(token.children);
|
return genEl(token.children);
|
||||||
}
|
}
|
||||||
return h(MkSparkle, {}, 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': {
|
case 'position': {
|
||||||
const x = parseFloat(token.props.args.x ?? '0');
|
const x = parseFloat(token.props.args.x ?? '0');
|
||||||
const y = parseFloat(token.props.args.y ?? '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;
|
break;
|
||||||
}
|
}
|
||||||
case 'scale': {
|
case 'scale': {
|
||||||
const x = Math.min(parseFloat(token.props.args.x ?? '1'), 5);
|
const x = Math.min(parseFloat(token.props.args.x ?? '1'), 5);
|
||||||
const y = Math.min(parseFloat(token.props.args.y ?? '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;
|
break;
|
||||||
}
|
}
|
||||||
case 'fg': {
|
case 'fg': {
|
||||||
let color = token.props.args.color ?? 'f00';
|
let color = token.props.args.color ?? 'f00';
|
||||||
if (!/^([0-9a-f]{3}){1,2}$/i.test(color)) color = 'f00';
|
if (!/^([0-9a-f]{3}){1,2}$/i.test(color)) color = 'f00';
|
||||||
style = `color: #${color};`;
|
attributes.style = `color: #${color};`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'bg': {
|
case 'bg': {
|
||||||
let color = token.props.args.color ?? '0f0';
|
let color = token.props.args.color ?? '0f0';
|
||||||
if (!/^([0-9a-f]{3}){1,2}$/i.test(color)) color = '0f0';
|
if (!/^([0-9a-f]{3}){1,2}$/i.test(color)) color = '0f0';
|
||||||
style = `background-color: #${color};`;
|
attributes.style = `background-color: #${color};`;
|
||||||
break;
|
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', {
|
||||||
return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children), ']']);
|
class: 'mfm-' + token.props.name,
|
||||||
} else {
|
...attributes,
|
||||||
return h('span', {
|
}, genEl(token.children));
|
||||||
style: 'display: inline-block;' + style,
|
|
||||||
}, genEl(token.children));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'small': {
|
case 'small': {
|
||||||
|
@ -225,8 +147,8 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'center': {
|
case 'center': {
|
||||||
return h('div', {
|
return h('span', {
|
||||||
style: 'text-align:center;',
|
class: 'mfm-center',
|
||||||
}, genEl(token.children));
|
}, genEl(token.children));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -334,6 +256,8 @@ export default defineComponent({
|
||||||
}).flat();
|
}).flat();
|
||||||
|
|
||||||
// Parse ast to DOM
|
// Parse ast to DOM
|
||||||
return h('span', genEl(ast));
|
return h('span', {
|
||||||
|
class: this.$store.state.animatedMfm ? 'animated-mfm' : '',
|
||||||
|
}, genEl(ast));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import '@/style.scss';
|
import '@/style.scss';
|
||||||
|
import '@/mfm.scss';
|
||||||
|
|
||||||
//#region account indexedDB migration
|
//#region account indexedDB migration
|
||||||
import { set } from '@/scripts/idb-proxy';
|
import { set } from '@/scripts/idb-proxy';
|
||||||
|
|
274
packages/client/src/mfm.scss
Normal file
274
packages/client/src/mfm.scss
Normal 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); }
|
||||||
|
}
|
|
@ -45,7 +45,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="tab === 'contents'">
|
<div v-else-if="tab === 'contents'">
|
||||||
<MkTextarea v-model="text" :readonly="readonly"/>
|
<MkTextarea v-model="text" :readonly="readonly" markdown/>
|
||||||
</div>
|
</div>
|
||||||
</MkSpacer>
|
</MkSpacer>
|
||||||
</MkStickyContainer>
|
</MkStickyContainer>
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
<img v-if="page.eyeCatchingImageId" :src="page.eyeCatchingImage.url"/>
|
<img v-if="page.eyeCatchingImageId" :src="page.eyeCatchingImage.url"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="content" :class="{ center: page.alignCenter, serif: page.font === 'serif' }">
|
<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>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<div class="like">
|
<div class="like">
|
||||||
|
@ -65,6 +65,7 @@ import MkFollowButton from '@/components/follow-button.vue';
|
||||||
import MkContainer from '@/components/ui/container.vue';
|
import MkContainer from '@/components/ui/container.vue';
|
||||||
import MkPagination from '@/components/ui/pagination.vue';
|
import MkPagination from '@/components/ui/pagination.vue';
|
||||||
import MkPagePreview from '@/components/page-preview.vue';
|
import MkPagePreview from '@/components/page-preview.vue';
|
||||||
|
import { Markdown } from '@/components/markdown.ts';
|
||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||||
|
|
||||||
|
|
|
@ -5,9 +5,9 @@ import { unique } from '@/scripts/array';
|
||||||
// [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ]
|
// [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ]
|
||||||
const removeHash = (x: string) => x.replace(/#[^#]*$/, '');
|
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) => {
|
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));
|
const urls: string[] = unique(urlNodes.map(x => x.props.url));
|
||||||
|
|
||||||
|
|
358
packages/client/src/scripts/markdown.ts
Normal file
358
packages/client/src/scripts/markdown.ts
Normal 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;
|
||||||
|
}
|
95
yarn.lock
95
yarn.lock
|
@ -2146,6 +2146,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@types/matter-js@npm:0.17.7":
|
||||||
version: 0.17.7
|
version: 0.17.7
|
||||||
resolution: "@types/matter-js@npm:0.17.7"
|
resolution: "@types/matter-js@npm:0.17.7"
|
||||||
|
@ -2364,6 +2371,15 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@types/semver@npm:7.3.12":
|
||||||
version: 7.3.12
|
version: 7.3.12
|
||||||
resolution: "@types/semver@npm:7.3.12"
|
resolution: "@types/semver@npm:7.3.12"
|
||||||
|
@ -4688,8 +4704,10 @@ __metadata:
|
||||||
"@rollup/pluginutils": ^4.2.1
|
"@rollup/pluginutils": ^4.2.1
|
||||||
"@syuilo/aiscript": 0.11.1
|
"@syuilo/aiscript": 0.11.1
|
||||||
"@types/katex": 0.14.0
|
"@types/katex": 0.14.0
|
||||||
|
"@types/marked": 4.0.8
|
||||||
"@types/matter-js": 0.17.7
|
"@types/matter-js": 0.17.7
|
||||||
"@types/punycode": 2.1.0
|
"@types/punycode": 2.1.0
|
||||||
|
"@types/sanitize-html": 2.8.0
|
||||||
"@types/throttle-debounce": 5.0.0
|
"@types/throttle-debounce": 5.0.0
|
||||||
"@types/tinycolor2": 1.4.3
|
"@types/tinycolor2": 1.4.3
|
||||||
"@types/uuid": 8.3.4
|
"@types/uuid": 8.3.4
|
||||||
|
@ -4719,11 +4737,13 @@ __metadata:
|
||||||
insert-text-at-cursor: 0.3.0
|
insert-text-at-cursor: 0.3.0
|
||||||
json5: 2.2.1
|
json5: 2.2.1
|
||||||
katex: 0.16.0
|
katex: 0.16.0
|
||||||
|
marked: 4.0.8
|
||||||
matter-js: 0.18.0
|
matter-js: 0.18.0
|
||||||
mfm-js: 0.23.3
|
mfm-js: 0.23.3
|
||||||
photoswipe: 5.2.8
|
photoswipe: 5.2.8
|
||||||
prismjs: 1.28.0
|
prismjs: 1.28.0
|
||||||
punycode: 2.1.1
|
punycode: 2.1.1
|
||||||
|
sanitize-html: 2.8.0
|
||||||
sass: 1.53.0
|
sass: 1.53.0
|
||||||
stringz: 2.1.0
|
stringz: 2.1.0
|
||||||
syuilo-password-strength: 0.0.1
|
syuilo-password-strength: 0.0.1
|
||||||
|
@ -5982,6 +6002,17 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"dom-serializer@npm:~0.1.0":
|
||||||
version: 0.1.1
|
version: 0.1.1
|
||||||
resolution: "dom-serializer@npm:0.1.1"
|
resolution: "dom-serializer@npm:0.1.1"
|
||||||
|
@ -5999,7 +6030,7 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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
|
version: 2.3.0
|
||||||
resolution: "domelementtype@npm:2.3.0"
|
resolution: "domelementtype@npm:2.3.0"
|
||||||
checksum: ee837a318ff702622f383409d1f5b25dd1024b692ef64d3096ff702e26339f8e345820f29a68bcdcea8cfee3531776b3382651232fbeae95612d6f0a75efb4f6
|
checksum: ee837a318ff702622f383409d1f5b25dd1024b692ef64d3096ff702e26339f8e345820f29a68bcdcea8cfee3531776b3382651232fbeae95612d6f0a75efb4f6
|
||||||
|
@ -6042,6 +6073,15 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"domutils@npm:1.5.1":
|
||||||
version: 1.5.1
|
version: 1.5.1
|
||||||
resolution: "domutils@npm:1.5.1"
|
resolution: "domutils@npm:1.5.1"
|
||||||
|
@ -6073,6 +6113,17 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"dotenv@npm:^16.0.0":
|
||||||
version: 16.0.1
|
version: 16.0.1
|
||||||
resolution: "dotenv@npm:16.0.1"
|
resolution: "dotenv@npm:16.0.1"
|
||||||
|
@ -6269,6 +6320,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"entities@npm:^4.3.0":
|
||||||
version: 4.3.1
|
version: 4.3.1
|
||||||
resolution: "entities@npm:4.3.1"
|
resolution: "entities@npm:4.3.1"
|
||||||
|
@ -8770,6 +8828,18 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"http-assert@npm:^1.3.0":
|
||||||
version: 1.5.0
|
version: 1.5.0
|
||||||
resolution: "http-assert@npm:1.5.0"
|
resolution: "http-assert@npm:1.5.0"
|
||||||
|
@ -11597,6 +11667,15 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"matchdep@npm:^2.0.0":
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
resolution: "matchdep@npm:2.0.0"
|
resolution: "matchdep@npm:2.0.0"
|
||||||
|
@ -15053,6 +15132,20 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"sass@npm:1.53.0":
|
||||||
version: 1.53.0
|
version: 1.53.0
|
||||||
resolution: "sass@npm:1.53.0"
|
resolution: "sass@npm:1.53.0"
|
||||||
|
|
Loading…
Reference in a new issue