Frontend: Switched to MMM rendering where possible
ci/woodpecker/push/ociImagePush Pipeline was successful Details

This commit is contained in:
Natty 2023-12-24 23:46:36 +01:00
parent 7ad46191ec
commit 76c4d0267f
Signed by: natty
GPG Key ID: BF6CB659ADEE60EC
12 changed files with 1095 additions and 662 deletions

View File

@ -0,0 +1,59 @@
<template>
<MkLink class="mention" :url="url" @click.stop>
<span class="icon">matrix /</span>
<span class="main">
<span class="username">@{{ username }}</span>
<span class="host">:{{ toUnicode(host) }}</span>
</span>
</MkLink>
</template>
<script lang="ts" setup>
import { toUnicode } from "punycode";
import MkLink from "@/components/MkLink.vue";
import { computed } from "vue";
const props = defineProps<{
username: string;
host: string;
}>();
const url = computed(
() => `https://matrix.to/#/@${props.username}:${props.host}`
);
</script>
<style lang="scss" scoped>
.mention {
position: relative;
display: inline-block;
padding: 2px 8px 2px 2px;
margin-block: 2px;
border-radius: 999px;
max-width: 100%;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--mention);
isolation: isolate;
&::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--mention);
opacity: 0.1;
z-index: -1;
}
> .icon {
margin: 0 0.2em 0 0.35em;
border-radius: 100%;
opacity: 0.7;
}
> .main > .host {
opacity: 0.8;
}
}
</style>

View File

@ -60,6 +60,7 @@
> >
<Mfm <Mfm
v-if="user.description" v-if="user.description"
:mm="user.description_mm"
:text="user.description" :text="user.description"
:author="user" :author="user"
:i="$i" :i="$i"
@ -86,6 +87,7 @@
</dt> </dt>
<dd class="value"> <dd class="value">
<Mfm <Mfm
:mm="field.value_mm"
:text="field.value" :text="field.value"
:author="user" :author="user"
:i="$i" :i="$i"

View File

@ -38,6 +38,7 @@
<Mfm <Mfm
v-if="note.cw != ''" v-if="note.cw != ''"
class="text" class="text"
:mm="magMaybeProperty(note, 'cw_mm')"
:text="note.cw" :text="note.cw"
:author="note.user" :author="note.user"
:i="$i" :i="$i"
@ -126,6 +127,7 @@
</template> </template>
<Mfm <Mfm
v-if="note.text" v-if="note.text"
:mm="magMaybeProperty(note, 'text_mm')"
:text="note.text" :text="note.text"
:author="note.user" :author="note.user"
:i="$i" :i="$i"
@ -236,7 +238,7 @@ import { extractMfmWithAnimation } from "@/scripts/extract-mfm";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";
import { defaultStore } from "@/store"; import { defaultStore } from "@/store";
import { $i } from "@/account"; import { $i } from "@/account";
import { magTransProperty } from "@/scripts-mag/mag-util"; import { magMaybeProperty, magTransProperty } from "@/scripts-mag/mag-util";
import { packed } from "magnetar-common"; import { packed } from "magnetar-common";
const props = defineProps<{ const props = defineProps<{

View File

@ -1,5 +1,6 @@
<template> <template>
<MfmCore <MfmCore
:mm="mm"
:text="text" :text="text"
:plain="plain" :plain="plain"
:nowrap="nowrap" :nowrap="nowrap"
@ -9,19 +10,23 @@
class="mfm-object" class="mfm-object"
:class="{ :class="{
nowrap, nowrap,
advancedMfm: defaultStore.state.advancedMfm || advancedMfm, advancedMfm: advancedMfmStore || advancedMfm,
}" }"
/> />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import MfmCore from "@/components/mfm"; import MfmCore from "@/components/mfm.vue";
import { defaultStore } from "@/store";
import * as Misskey from "calckey-js"; import * as Misskey from "calckey-js";
import { packed } from "magnetar-common"; import { packed } from "magnetar-common";
import { defaultStore } from "@/store";
import { ref } from "vue";
const advancedMfmStore = ref(defaultStore.state.advancedMfm);
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
mm?: string;
text: string; text: string;
plain?: boolean; plain?: boolean;
nowrap?: boolean; nowrap?: boolean;

View File

@ -1,6 +1,7 @@
<template> <template>
<Mfm <Mfm
:class="$style.root" :class="$style.root"
:mm="magMaybeProperty(user, 'display_name_mm')"
:text="magTransUsername(user)" :text="magTransUsername(user)"
:plain="true" :plain="true"
:nowrap="nowrap" :nowrap="nowrap"
@ -11,7 +12,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import * as misskey from "calckey-js"; import * as misskey from "calckey-js";
import { packed } from "magnetar-common"; import { packed } from "magnetar-common";
import { magTransUsername } from "@/scripts-mag/mag-util"; import { magMaybeProperty, magTransUsername } from "@/scripts-mag/mag-util";
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{

View File

@ -1,654 +0,0 @@
import type { VNode } from "vue";
import { defineComponent, h } from "vue";
import * as mfm from "mfm-js";
import MkUrl from "@/components/global/MkUrl.vue";
import MkLink from "@/components/MkLink.vue";
import MagMention from "@/components/MagMention.vue";
import { concat } from "@/scripts/array";
import MkFormula from "@/components/MkFormula.vue";
import MkCode from "@/components/MkCode.vue";
import MkSparkle from "@/components/MkSparkle.vue";
import MkA from "@/components/global/MkA.vue";
import { host } from "@/config";
import { reducedMotion } from "@/scripts/reduced-motion";
import MagEmoji from "@/components/global/MagEmoji.vue";
import {
magConvertReaction,
magIsMissingEmoji,
magReactionEquals,
magTransProperty,
} from "@/scripts-mag/mag-util";
export default defineComponent({
props: {
text: {
type: String,
required: true,
},
plain: {
type: Boolean,
default: false,
},
nowrap: {
type: Boolean,
default: false,
},
author: {
type: Object,
default: null,
},
i: {
type: Object,
default: null,
},
customEmojis: {
required: false,
},
isNote: {
type: Boolean,
default: true,
},
},
render() {
if (this.text == null || this.text === "") return;
const isPlain = this.plain;
const ast = (isPlain ? mfm.parseSimple : mfm.parse)(this.text);
const validTime = (t: string | null | undefined) => {
if (t == null) return null;
return t.match(/^[0-9.]+s$/) ? t : null;
};
const validNumber = (n: string | null | undefined) => {
if (n == null) return null;
const parsed = parseFloat(n);
return !isNaN(parsed) && isFinite(parsed) && parsed > 0;
};
// const validEase = (e: string | null | undefined) => {
// if (e == null) return null;
// return e.match(/(steps)?\(-?[0-9.]+,-?[0-9.]+,-?[0-9.]+,-?[0-9.]+\)/)
// ? (e.startsWith("steps") ? e : "cubic-bezier" + e)
// : null
// }
const genEl = (ast: mfm.MfmNode[]) =>
concat(
ast.map((token, index): VNode[] => {
switch (token.type) {
case "text": {
const text = token.props.text.replace(
/(\r\n|\n|\r)/g,
"\n"
);
if (!this.plain) {
const res = [];
for (const t of text.split("\n")) {
res.push(h("br"));
res.push(t);
}
res.shift();
return res;
} else {
return [text.replace(/\n/g, " ")];
}
}
case "bold": {
return [h("b", genEl(token.children))];
}
case "strike": {
return [h("del", genEl(token.children))];
}
case "italic": {
return h(
"i",
{
style: "font-style: oblique;",
},
genEl(token.children)
);
}
case "fn": {
// TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる
let style: string;
switch (token.props.name) {
case "tada": {
const speed =
validTime(token.props.args.speed) ||
"1s";
const delay =
validTime(token.props.args.delay) ||
"0s";
const loop =
validNumber(token.props.args.loop) ||
"infinite";
// const ease = validEase(token.props.args.ease) || "linear";
style = `font-size: 150%; animation: tada ${speed} ${delay} linear ${loop} both;`;
break;
}
case "jelly": {
const speed =
validTime(token.props.args.speed) ||
"1s";
const delay =
validTime(token.props.args.delay) ||
"0s";
const loop =
validNumber(token.props.args.loop) ||
"infinite";
style = `animation: mfm-rubberBand ${speed} ${delay} linear ${loop} both;`;
break;
}
case "twitch": {
const speed =
validTime(token.props.args.speed) ||
"0.5s";
const delay =
validTime(token.props.args.delay) ||
"0s";
const loop =
validNumber(token.props.args.loop) ||
"infinite";
style = `animation: mfm-twitch ${speed} ${delay} ease ${loop};`;
break;
}
case "shake": {
const speed =
validTime(token.props.args.speed) ||
"0.5s";
const delay =
validTime(token.props.args.delay) ||
"0s";
const loop =
validNumber(token.props.args.loop) ||
"infinite";
style = `animation: mfm-shake ${speed} ${delay} ease ${loop};`;
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";
const delay =
validTime(token.props.args.delay) ||
"0s";
const loop =
validNumber(token.props.args.loop) ||
"infinite";
style = `animation: ${anime} ${speed} ${delay} linear ${loop}; animation-direction: ${direction};`;
break;
}
case "jump": {
const speed =
validTime(token.props.args.speed) ||
"0.75s";
const delay =
validTime(token.props.args.delay) ||
"0s";
const loop =
validNumber(token.props.args.loop) ||
"infinite";
style = `animation: mfm-jump ${speed} ${delay} linear ${loop};`;
break;
}
case "bounce": {
const speed =
validTime(token.props.args.speed) ||
"0.75s";
const delay =
validTime(token.props.args.delay) ||
"0s";
const loop =
validNumber(token.props.args.loop) ||
"infinite";
style = `animation: mfm-bounce ${speed} ${delay} linear ${loop}; transform-origin: center bottom;`;
break;
}
case "rainbow": {
const speed =
validTime(token.props.args.speed) ||
"1s";
const delay =
validTime(token.props.args.delay) ||
"0s";
const loop =
validNumber(token.props.args.loop) ||
"infinite";
style = `animation: mfm-rainbow ${speed} ${delay} linear ${loop};`;
break;
}
case "sparkle": {
if (reducedMotion()) {
return genEl(token.children);
}
return h(
MkSparkle,
{},
genEl(token.children)
);
}
case "fade": {
const direction = token.props.args.out
? "alternate-reverse"
: "alternate";
const speed =
validTime(token.props.args.speed) ||
"1.5s";
const delay =
validTime(token.props.args.delay) ||
"0s";
const loop =
validNumber(token.props.args.loop) ||
"infinite";
style = `animation: mfm-fade ${speed} ${delay} linear ${loop}; animation-direction: ${direction};`;
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: "_blur_text",
},
genEl(token.children)
);
}
case "rotate": {
const rotate = token.props.args.x
? "perspective(128px) rotateX"
: token.props.args.y
? "perspective(128px) rotateY"
: "rotate";
const degrees =
parseInt(token.props.args.deg) || "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: translateX(${x}em) translateY(${y}em);`;
break;
}
case "crop": {
const top = parseFloat(
token.props.args.top ?? "0"
);
const right = parseFloat(
token.props.args.right ?? "0"
);
const bottom = parseFloat(
token.props.args.bottom ?? "0"
);
const left = parseFloat(
token.props.args.left ?? "0"
);
style = `clip-path: inset(${top}% ${right}% ${bottom}% ${left}%);`;
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});`;
break;
}
case "fg": {
let color = token.props.args.color;
if (!/^[0-9a-f]{3,6}$/i.test(color))
color = "f00";
style = `color: #${color};`;
break;
}
case "bg": {
let color = token.props.args.color;
if (!/^[0-9a-f]{3,6}$/i.test(color))
color = "f00";
style = `background-color: #${color};`;
break;
}
case "small": {
return h(
"small",
{
style: "opacity: 0.7;",
},
genEl(token.children)
);
}
case "center": {
return h(
"div",
{
style: "text-align: center;",
},
genEl(token.children)
);
}
}
if (style == null) {
return h("span", {}, [
"$[",
token.props.name,
" ",
...genEl(token.children),
"]",
]);
} else {
return h(
"span",
{
style: `display: inline-block;${style}`,
},
genEl(token.children)
);
}
}
case "small": {
return [
h(
"small",
{
style: "opacity: 0.7;",
},
genEl(token.children)
),
];
}
case "center": {
return [
h(
"div",
{
style: "text-align: center;",
},
genEl(token.children)
),
];
}
case "url": {
return [
h(MkUrl, {
key: Math.random(),
url: token.props.url,
rel: "nofollow noopener",
}),
];
}
case "link": {
return [
h(
MkLink,
{
key: Math.random(),
url: token.props.url,
rel: "nofollow noopener",
},
genEl(token.children)
),
];
}
case "mention": {
return [
h(MagMention, {
key: Math.random(),
username: token.props.username,
host:
(token.props.host == null &&
this.author &&
this.author.host != null
? this.author.host
: token.props.host) || host,
}),
];
}
case "hashtag": {
return [
h(
MkA,
{
key: Math.random(),
to: `/tags/${encodeURIComponent(
token.props.hashtag
)}`,
style: "color:var(--hashtag);",
},
`#${token.props.hashtag}`
),
];
}
case "blockCode": {
return [
h(MkCode, {
key: Math.random(),
code: token.props.code,
lang: token.props.lang,
}),
];
}
case "inlineCode": {
return [
h(MkCode, {
key: Math.random(),
code: token.props.code,
inline: true,
}),
];
}
case "quote": {
if (!this.nowrap) {
return [h("blockquote", genEl(token.children))];
} else {
return [
h(
"span",
{
class: "quote",
},
genEl(token.children)
),
];
}
}
case "emojiCode": {
const shortcode = `:${token.props.name}:`;
const emoji = magConvertReaction(
shortcode,
(name, host) =>
this.customEmojis.find((e) =>
magReactionEquals(
magConvertReaction(
`:${magTransProperty(
e,
"shortcode",
"name"
)}:`
),
{ name, host, url: null! }
)
)?.url ?? null
);
if (magIsMissingEmoji(emoji)) {
return [shortcode];
}
return [
h(MagEmoji, {
key: Math.random(),
emoji,
normal: this.plain,
}),
];
}
case "unicodeEmoji": {
return [
h(MagEmoji, {
key: Math.random(),
emoji: token.props.emoji,
normal: this.plain,
}),
];
}
case "mathInline": {
return [
h(MkFormula, {
key: Math.random(),
formula: token.props.formula,
block: false,
}),
];
}
case "mathBlock": {
return [
h(MkFormula, {
key: Math.random(),
formula: token.props.formula,
block: true,
}),
];
}
case "search": {
const sentinel = "#";
let ast2 = (isPlain ? mfm.parseSimple : mfm.parse)(
token.props.content + sentinel
);
if (
ast2[ast2.length - 1].type === "text" &&
ast2[ast2.length - 1].props.text.endsWith(
sentinel
)
) {
ast2[ast2.length - 1].props.text = ast2[
ast2.length - 1
].props.text.slice(0, -1);
}
let prefix = "\n";
if (
index === 0 ||
[
"blockCode",
"center",
"mathBlock",
"quote",
"search",
].includes(ast[index - 1].type)
) {
prefix = "";
}
return [prefix, ...genEl(ast2)];
}
case "plain": {
return [h("span", genEl(token.children))];
}
default: {
console.error("unrecognized ast type:", token.type);
return [];
}
}
})
);
// Parse ast to DOM
return h("span", genEl(ast));
},
});

View File

@ -0,0 +1,697 @@
<template v-slot="{ mfmTree }">
<span>
<component :is="mfmTree" />
</span>
</template>
<script lang="ts" setup>
import { h, shallowRef, VNodeArrayChildren, VNodeChild, watch } from "vue";
import * as mfm from "mfm-js";
import MkUrl from "@/components/global/MkUrl.vue";
import MkLink from "@/components/MkLink.vue";
import MagMention from "@/components/MagMention.vue";
import MagMatrixMention from "@/components/MagMatrixMention.vue";
import { concat } from "@/scripts/array";
import MkFormula from "@/components/MkFormula.vue";
import MkCode from "@/components/MkCode.vue";
import MkSparkle from "@/components/MkSparkle.vue";
import MkA from "@/components/global/MkA.vue";
import { host } from "@/config";
import { reducedMotion } from "@/scripts/reduced-motion";
import MagEmoji from "@/components/global/MagEmoji.vue";
import {
magConvertReaction,
magIsMissingEmoji,
magReactionEquals,
magTransProperty,
} from "@/scripts-mag/mag-util";
import * as Misskey from "calckey-js";
import { packed } from "magnetar-common";
import {
magnetarMarkdownToMfm,
MagnetarParseError,
MagNode,
parseMagnetarMarkdownXml,
} from "@/scripts-mag/mmm-util";
import { Result } from "@/types/result";
const props = withDefaults(
defineProps<{
mm?: string;
text: string;
plain?: boolean;
nowrap?: boolean;
author?: any;
customEmojis?: (packed.PackEmojiBase | Misskey.entities.CustomEmoji)[];
isNote?: boolean;
}>(),
{
plain: false,
nowrap: false,
author: null,
isNote: true,
}
);
function render() {
let ast: MagNode[];
let result: Result<MagNode[], MagnetarParseError> | null = null;
if (props.mm) {
result = parseMagnetarMarkdownXml(props.mm).flatMap(
magnetarMarkdownToMfm
);
}
if (result && result.isOk()) {
ast = result.unwrap();
} else if (!props.text) {
return h("span");
} else {
const isPlain = props.plain;
ast = (isPlain ? mfm.parseSimple : mfm.parse)(props.text);
}
const validTime = (t: string | boolean | null | undefined) => {
if (t == null) return null;
if (typeof t === "boolean") return null;
return t.match(/^[0-9.]+s$/) ? t : null;
};
const validNumber = (n: string | boolean | null | undefined) => {
if (n == null) return null;
if (typeof n === "boolean") return null;
const parsed = parseFloat(n);
return !isNaN(parsed) && isFinite(parsed) && parsed > 0;
};
// const validEase = (e: string | null | undefined) => {
// if (e == null) return null;
// return e.match(/(steps)?\(-?[0-9.]+,-?[0-9.]+,-?[0-9.]+,-?[0-9.]+\)/)
// ? (e.startsWith("steps") ? e : "cubic-bezier" + e)
// : null
// }
const genEl = (ast: MagNode[]) =>
concat(
ast.map((token, index): VNodeChild[] => {
switch (token.type) {
case "text": {
const text = token.props.text.replace(
/(\r\n|\n|\r)/g,
"\n"
);
if (!props.plain) {
const res = [] as VNodeArrayChildren;
for (const t of text.split("\n")) {
res.push(h("br"));
res.push(t);
}
res.shift();
return res;
} else {
return [text.replace(/\n/g, " ")];
}
}
case "bold": {
return [h("b", genEl(token.children))];
}
case "strike": {
return [h("del", genEl(token.children))];
}
case "italic": {
return [
h(
"i",
{
style: "font-style: oblique;",
},
genEl(token.children)
),
];
}
case "fn": {
let style: string | null = null;
switch (token.props.name) {
case "tada": {
const speed =
validTime(token.props.args.speed) || "1s";
const delay =
validTime(token.props.args.delay) || "0s";
const loop =
validNumber(token.props.args.loop) ||
"infinite";
// const ease = validEase(token.props.args.ease) || "linear";
style = `font-size: 150%; animation: tada ${speed} ${delay} linear ${loop} both;`;
break;
}
case "jelly": {
const speed =
validTime(token.props.args.speed) || "1s";
const delay =
validTime(token.props.args.delay) || "0s";
const loop =
validNumber(token.props.args.loop) ||
"infinite";
style = `animation: mfm-rubberBand ${speed} ${delay} linear ${loop} both;`;
break;
}
case "twitch": {
const speed =
validTime(token.props.args.speed) || "0.5s";
const delay =
validTime(token.props.args.delay) || "0s";
const loop =
validNumber(token.props.args.loop) ||
"infinite";
style = `animation: mfm-twitch ${speed} ${delay} ease ${loop};`;
break;
}
case "shake": {
const speed =
validTime(token.props.args.speed) || "0.5s";
const delay =
validTime(token.props.args.delay) || "0s";
const loop =
validNumber(token.props.args.loop) ||
"infinite";
style = `animation: mfm-shake ${speed} ${delay} ease ${loop};`;
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";
const delay =
validTime(token.props.args.delay) || "0s";
const loop =
validNumber(token.props.args.loop) ||
"infinite";
style = `animation: ${anime} ${speed} ${delay} linear ${loop}; animation-direction: ${direction};`;
break;
}
case "jump": {
const speed =
validTime(token.props.args.speed) ||
"0.75s";
const delay =
validTime(token.props.args.delay) || "0s";
const loop =
validNumber(token.props.args.loop) ||
"infinite";
style = `animation: mfm-jump ${speed} ${delay} linear ${loop};`;
break;
}
case "bounce": {
const speed =
validTime(token.props.args.speed) ||
"0.75s";
const delay =
validTime(token.props.args.delay) || "0s";
const loop =
validNumber(token.props.args.loop) ||
"infinite";
style = `animation: mfm-bounce ${speed} ${delay} linear ${loop}; transform-origin: center bottom;`;
break;
}
case "rainbow": {
const speed =
validTime(token.props.args.speed) || "1s";
const delay =
validTime(token.props.args.delay) || "0s";
const loop =
validNumber(token.props.args.loop) ||
"infinite";
style = `animation: mfm-rainbow ${speed} ${delay} linear ${loop};`;
break;
}
case "sparkle": {
if (reducedMotion()) {
return genEl(token.children);
}
return [
h(MkSparkle, {}, genEl(token.children)),
];
}
case "fade": {
const direction = token.props.args.out
? "alternate-reverse"
: "alternate";
const speed =
validTime(token.props.args.speed) || "1.5s";
const delay =
validTime(token.props.args.delay) || "0s";
const loop =
validNumber(token.props.args.loop) ||
"infinite";
style = `animation: mfm-fade ${speed} ${delay} linear ${loop}; animation-direction: ${direction};`;
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: "_blur_text",
},
genEl(token.children)
),
];
}
case "rotate": {
const rotate = token.props.args.x
? "perspective(128px) rotateX"
: token.props.args.y
? "perspective(128px) rotateY"
: "rotate";
const degrees =
parseInt("" + token.props.args.deg) || "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: translateX(${x}em) translateY(${y}em);`;
break;
}
case "crop": {
const top = parseFloat(
"" + token.props.args.top ?? "0"
);
const right = parseFloat(
"" + token.props.args.right ?? "0"
);
const bottom = parseFloat(
"" + token.props.args.bottom ?? "0"
);
const left = parseFloat(
"" + token.props.args.left ?? "0"
);
style = `clip-path: inset(${top}% ${right}% ${bottom}% ${left}%);`;
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});`;
break;
}
case "fg": {
let color = token.props.args.color;
if (!/^[0-9a-f]{3,6}$/i.test("" + color))
color = "f00";
style = `color: #${color};`;
break;
}
case "bg": {
let color = token.props.args.color;
if (!/^[0-9a-f]{3,6}$/i.test("" + color))
color = "f00";
style = `background-color: #${color};`;
break;
}
case "small": {
return [
h(
"small",
{
style: "opacity: 0.7;",
},
genEl(token.children)
),
];
}
case "center": {
return [
h(
"div",
{
style: "text-align: center;",
},
genEl(token.children)
),
];
}
}
if (style) {
return [
h(
"span",
{
style: `display: inline-block;${style}`,
},
genEl(token.children)
),
];
}
return [
h("span", {}, [
"$[",
token.props.name,
" ",
...genEl(token.children),
"]",
]),
];
}
case "small": {
return [
h(
"small",
{
style: "opacity: 0.7;",
},
genEl(token.children)
),
];
}
case "center": {
return [
h(
"div",
{
style: "text-align: center;",
},
genEl(token.children)
),
];
}
case "url": {
return [
h(MkUrl, {
key: Math.random(),
url: token.props.url,
rel: "nofollow noopener",
}),
];
}
case "link": {
return [
h(
MkLink,
{
key: Math.random(),
url: token.props.url,
rel: "nofollow noopener",
},
genEl(token.children)
),
];
}
case "mention": {
return [
h(MagMention, {
key: Math.random(),
username: token.props.username,
host:
(token.props.host == null &&
props.author &&
props.author.host != null
? props.author.host
: token.props.host) || host,
}),
];
}
case "matrixMention": {
return [
h(MagMatrixMention, {
key: Math.random(),
username: token.props.username,
host: token.props.host,
}),
];
}
case "hashtag": {
return [
h(
MkA,
{
key: Math.random(),
to: `/tags/${encodeURIComponent(
token.props.hashtag
)}`,
style: "color:var(--hashtag);",
},
`#${token.props.hashtag}`
),
];
}
case "blockCode": {
return [
h(MkCode, {
key: Math.random(),
code: token.props.code,
lang: token.props.lang,
}),
];
}
case "inlineCode": {
return [
h(MkCode, {
key: Math.random(),
code: token.props.code,
inline: true,
}),
];
}
case "quote": {
if (!props.nowrap) {
return [h("blockquote", genEl(token.children))];
} else {
return [
h(
"span",
{
class: "quote",
},
genEl(token.children)
),
];
}
}
case "emojiCode": {
const shortcode = `:${token.props.name}:`;
const emoji = magConvertReaction(
shortcode,
(name, host) =>
props.customEmojis?.find((e) =>
magReactionEquals(
magConvertReaction(
`:${magTransProperty(
e,
"shortcode",
"name"
)}:`
),
{ name, host, url: null! }
)
)?.url ?? null!
);
if (magIsMissingEmoji(emoji)) {
return [shortcode];
}
return [
h(MagEmoji, {
key: Math.random(),
emoji,
normal: props.plain,
}),
];
}
case "unicodeEmoji": {
return [
h(MagEmoji, {
key: Math.random(),
emoji: token.props.emoji,
normal: props.plain,
}),
];
}
case "mathInline": {
return [
h(MkFormula, {
key: Math.random(),
formula: token.props.formula,
block: false,
}),
];
}
case "mathBlock": {
return [
h(MkFormula, {
key: Math.random(),
formula: token.props.formula,
block: true,
}),
];
}
case "search": {
const sentinel = "#";
let ast2 = (props.plain ? mfm.parseSimple : mfm.parse)(
token.props.content + sentinel
);
const lastNode = ast2[ast2.length - 1];
if (
lastNode.type === "text" &&
(
lastNode.props as
| mfm.MfmText["props"]
| undefined
)?.text?.endsWith(sentinel)
) {
lastNode.props.text = lastNode.props.text.slice(
0,
-1
);
}
let prefix = "\n";
if (
index === 0 ||
[
"blockCode",
"center",
"mathBlock",
"quote",
"search",
].includes(ast[index - 1].type)
) {
prefix = "";
}
return [prefix, ...genEl(ast2)];
}
case "plain": {
return [h("span", genEl(token.children))];
}
default: {
console.error(
"unrecognized ast type:",
(token as any)?.type
);
return [];
}
}
})
);
return h("span", genEl(ast));
}
const mfmTree = shallowRef<VNodeChild>(props.text);
watch(
() => props.text,
() => {
mfmTree.value = render();
},
{
immediate: true,
}
);
</script>

View File

@ -12,6 +12,7 @@ import "@phosphor-icons/web/fill";
//#region account indexedDB migration //#region account indexedDB migration
import { set } from "@/scripts/idb-proxy"; import { set } from "@/scripts/idb-proxy";
import { import {
App,
computed, computed,
createApp, createApp,
defineAsyncComponent, defineAsyncComponent,
@ -42,8 +43,6 @@ import { reactionPicker } from "@/scripts/reaction-picker";
import { getUrlWithoutLoginId } from "@/scripts/login-id"; import { getUrlWithoutLoginId } from "@/scripts/login-id";
import { getAccountFromId } from "@/scripts/get-account-from-id"; import { getAccountFromId } from "@/scripts/get-account-from-id";
import { App } from "vue";
import Mfm from "./components/global/MkMisskeyFlavoredMarkdown.vue"; import Mfm from "./components/global/MkMisskeyFlavoredMarkdown.vue";
import MkA from "./components/global/MkA.vue"; import MkA from "./components/global/MkA.vue";
import MkAcct from "./components/global/MkAcct.vue"; import MkAcct from "./components/global/MkAcct.vue";

View File

@ -392,7 +392,7 @@ import { $i } from "@/account";
import MkFollowApproveButton from "@/components/MkFollowApproveButton.vue"; import MkFollowApproveButton from "@/components/MkFollowApproveButton.vue";
import MkUserName from "@/components/global/MkUserName.vue"; import MkUserName from "@/components/global/MkUserName.vue";
import MkAvatar from "@/components/global/MkAvatar.vue"; import MkAvatar from "@/components/global/MkAvatar.vue";
import Mfm from "@/components/mfm"; import Mfm from "@/components/mfm.vue";
import MkTime from "@/components/global/MkTime.vue"; import MkTime from "@/components/global/MkTime.vue";
import MkA from "@/components/global/MkA.vue"; import MkA from "@/components/global/MkA.vue";
import page from "@/components/page/page.vue"; import page from "@/components/page/page.vue";

View File

@ -33,6 +33,19 @@ export function magTransProperty<
return x[keyB]; return x[keyB];
} }
export function magMaybeProperty<
A extends Record<string, any>,
AA extends keyof UnionToIntersection<A> & string
>(x: A, keyA: AA): UnionIntersectionMerge<A>[AA] | undefined {
const a = x[keyA];
if (typeof a !== "undefined") {
return a;
}
return undefined;
}
export function magTransMap< export function magTransMap<
A extends Record<string, any>, A extends Record<string, any>,
AA extends keyof UnionIntersectionMerge<A> & string, AA extends keyof UnionIntersectionMerge<A> & string,

View File

@ -0,0 +1,222 @@
import { Err, Ok, Result } from "@/types/result";
import * as mfm from "mfm-js";
import { types } from "magnetar-common";
export type MagnetarParseError =
| "XMLParseError"
| "XMLParseException"
| "InvalidRootNode";
export function parseMagnetarMarkdownXml(
xml: types.MmXml
): Result<XMLDocument, MagnetarParseError> {
const parser = new DOMParser();
const document = parser.parseFromString(
xml,
"application/xml"
) as XMLDocument;
const error = document.querySelector("parsererror");
if (error) {
return Err("XMLParseError");
}
if (document.documentElement.tagName !== "mmm") {
return Err("InvalidRootNode");
}
return Ok(document);
}
export declare type MmmMatrixMention = {
type: "matrixMention";
props: {
username: string;
host: string;
};
children?: [];
};
type MagnetarChildren<N extends { children?: any[] }> = {
[T in keyof N]: T extends "children"
? N[T] extends []
? []
: MagNode[]
: N[T];
};
export type MagNodeInline = mfm.MfmNode | MmmMatrixMention;
export type MagNode = MagnetarChildren<mfm.MfmNode> | MmmMatrixMention;
const mkBase = <T>(type: T): { type: T; props: Record<string, unknown> } => ({
type,
props: {},
});
const mk = <T, P extends Record<string, unknown>, C>(
type: T,
props: P,
children?: C
): {
type: T;
props: P;
children: C extends (infer I & {})[] ? I[] : [];
} => {
return {
...mkBase(type),
props,
...(typeof children === "undefined" ? children : { children }),
} as {
type: T;
props: P;
children: C extends (infer I & {})[] ? I[] : [];
};
};
export function mapMmXmlNodeListToMfm(nodes: NodeListOf<ChildNode>): MagNode[] {
return Array.from(nodes).map(mapMmXmlNodeToMfm);
}
export function mapMmXmlNodeToMfm(node: Node): MagNode {
switch (node.nodeType) {
case Node.TEXT_NODE:
return mfm.TEXT(node.textContent ?? "");
case Node.ELEMENT_NODE: {
const el = node as Element;
switch (el.tagName) {
case "quote":
case "small":
case "center":
return mk(
el.tagName,
{},
mapMmXmlNodeListToMfm(el.childNodes)
);
case "b":
return mk("bold", {}, mapMmXmlNodeListToMfm(el.childNodes));
case "i":
return mk(
"italic",
{},
mapMmXmlNodeListToMfm(el.childNodes)
);
case "s":
return mk(
"strike",
{},
mapMmXmlNodeListToMfm(el.childNodes)
);
case "inline-code":
return mk("inlineCode", {
code: el.textContent ?? "",
});
case "inline-math":
return mk("mathInline", {
formula: el.textContent ?? "",
});
case "a":
const url = el.getAttribute("href");
if (!url) break;
return mk(
"link",
{
url,
silent: el.getAttribute("embed") !== "true",
},
mapMmXmlNodeListToMfm(el.childNodes)
);
case "code":
return mk("blockCode", {
code: el.textContent ?? "",
lang: el.getAttribute("lang"),
});
case "math":
return mk("mathBlock", {
formula: el.textContent ?? "",
});
case "hashtag":
if (!el.textContent) break;
return mk("hashtag", {
hashtag: el.textContent,
});
case "function":
return mk("mathBlock", {
formula: el.textContent ?? "",
});
case "ue":
if (!el.textContent) break;
return mk("unicodeEmoji", {
emoji: el.textContent,
});
case "ee":
if (!el.textContent) break;
return mk("emojiCode", {
name: el.textContent,
});
case "mention":
const username = el.getAttribute("name");
const host = el.getAttribute("host");
const type = el.getAttribute("type");
if (!username || (type === "matrix_user" && !host)) break;
switch (type) {
case "matrix_user":
return mk("matrixMention", {
username,
host: host!,
});
default:
return mk("mention", {
username,
host,
acct: host
? `@${username}@${host}`
: `@${username}`,
});
}
case "fn":
const name = el.getAttribute("name") ?? "";
const args = el
.getAttributeNames()
.filter((v) => v.startsWith("arg-"))
.map((v) => [
v.substring("arg-".length),
el.getAttribute(v) || true,
])
.reduce<Record<string, string | true>>(
(acc, [k, v]) => ({
...acc,
[k as string]: v as string | true,
}),
{}
);
return mk(
"fn",
{
name,
args,
},
mapMmXmlNodeListToMfm(el.childNodes)
);
}
}
}
return mfm.TEXT(node.textContent ?? "");
}
export function magnetarMarkdownToMfm(
doc: XMLDocument
): Result<MagNode[], MagnetarParseError> {
const el = doc.documentElement;
if (el.tagName !== "mmm") {
return Err("InvalidRootNode");
}
return Ok(mapMmXmlNodeListToMfm(el.childNodes));
}

View File

@ -0,0 +1,87 @@
export class ResultOk<T, E> {
public ok: T;
constructor(value: T) {
this.ok = value;
}
public map<U>(mapper: (t: T) => U): Result<U, E> {
return new ResultOk(mapper(this.ok));
}
public mapError<UE>(mapper: (t: E) => UE): Result<T, UE> {
return new ResultOk(this.ok);
}
public flatMap<U, E2>(
mapper: (t: T) => Result<U, [E] extends [E2] ? E2 : E>
): Result<U, [E] extends [E2] ? E2 : E> {
return mapper(this.ok);
}
public errorInto<E2>(): Result<T, [E] extends [E2] ? E2 : E> {
return this as Result<T, [E] extends [E2] ? E2 : E>;
}
public isOk(): this is ResultOk<T, E> {
return true;
}
public isErr(): this is ResultErr<T, E> {
return false;
}
public unwrap(): T {
return this.ok;
}
}
export class ResultErr<T, E> {
public error: E;
constructor(errorValue: E) {
this.error = errorValue;
}
public map<U>(_mapper: (t: T) => U): Result<T, E> {
return this;
}
public mapError<UE>(mapper: (t: E) => UE): Result<T, UE> {
return new ResultErr(mapper(this.error));
}
public flatMap<U, E2>(
_mapper: (t: T) => Result<U, [E] extends [E2] ? E2 : E>
): Result<U, [E] extends [E2] ? E2 : E> {
return Err<[E] extends [E2] ? E2 : E, U>(
this.error as [E] extends [E2] ? E2 : E
);
}
public errorInto<E2>(): Result<T, [E] extends [E2] ? E2 : E> {
return this as Result<T, [E] extends [E2] ? E2 : E>;
}
public isOk(): this is ResultOk<T, E> {
return false;
}
public isErr(): this is ResultOk<T, E> {
return true;
}
public unwrap(): never {
throw this.error;
}
}
export function Ok<T, E = never>(value: T): Result<T, E> {
return new ResultOk(value);
}
export function Err<E, T = never>(errorValue: E): Result<T, E> {
return new ResultErr(errorValue);
}
export type Result<T, E> = ResultOk<T, E> | ResultErr<T, E>;