This commit is contained in:
syuilo 2024-08-27 13:21:01 +09:00
parent fc7d22f4e8
commit db2b9d2669
12 changed files with 629 additions and 15 deletions

View File

@ -0,0 +1,466 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { VNode, h, SetupContext, provide } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import EmUrl from '@/components/EmUrl.vue';
import EmTime from '@/components/EmTime.vue';
import EmLink from '@/components/EmLink.vue';
import EmMention from '@/components/EmMention.vue';
import EmEmoji from '@/components/EmEmoji.vue';
import EmCustomEmoji from '@/components/EmCustomEmoji.vue';
import EmCode from '@/components/EmCode.vue';
import EmCodeInline from '@/components/EmCodeInline.vue';
import EmGoogle from '@/components/EmGoogle.vue';
import EmSparkle from '@/components/EmSparkle.vue';
import EmA from '@/components/EmA.vue';
import { host } from '@/config.js';
import { nyaize as doNyaize } from '@/to-be-shared/nyaize.js';
import { safeParseFloat } from '@/to-be-shared/safe-parse.js';
const QUOTE_STYLE = `
display: block;
margin: 8px;
padding: 6px 0 6px 12px;
color: var(--fg);
border-left: solid 3px var(--fg);
opacity: 0.7;
`.split('\n').join(' ');
type MfmProps = {
text: string;
plain?: boolean;
nowrap?: boolean;
author?: Misskey.entities.UserLite;
isNote?: boolean;
emojiUrls?: Record<string, string>;
rootScale?: number;
nyaize?: boolean | 'respect';
parsedNodes?: mfm.MfmNode[] | null;
enableEmojiMenu?: boolean;
enableEmojiMenuReaction?: boolean;
linkNavigationBehavior?: EmABehavior;
};
type MfmEvents = {
clickEv(id: string): void;
};
// eslint-disable-next-line import/no-default-export
export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEvents>['emit'] }) {
provide('linkNavigationBehavior', props.linkNavigationBehavior);
const isNote = props.isNote ?? true;
const shouldNyaize = props.nyaize ? props.nyaize === 'respect' ? props.author?.isCat : false : false;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (props.text == null || props.text === '') return;
const rootAst = props.parsedNodes ?? (props.plain ? 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 validColor = (c: unknown): string | null => {
if (typeof c !== 'string') return null;
return c.match(/^[0-9a-f]{3,6}$/i) ? c : null;
};
const useAnim = true;
/**
* Gen Vue Elements from MFM AST
* @param ast MFM AST
* @param scale How times large the text is
* @param disableNyaize Whether nyaize is disabled or not
*/
const genEl = (ast: mfm.MfmNode[], scale: number, disableNyaize = false) => ast.map((token): VNode | string | (VNode | string)[] => {
switch (token.type) {
case 'text': {
let text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
if (!disableNyaize && shouldNyaize) {
text = doNyaize(text);
}
if (!props.plain) {
const res: (VNode | string)[] = [];
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, scale))];
}
case 'strike': {
return [h('del', genEl(token.children, scale))];
}
case 'italic': {
return h('i', {
style: 'font-style: oblique;',
}, genEl(token.children, scale));
}
case 'fn': {
// TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる
let style: string | undefined;
switch (token.props.name) {
case 'tada': {
const speed = validTime(token.props.args.speed) ?? '1s';
const delay = validTime(token.props.args.delay) ?? '0s';
style = 'font-size: 150%;' + (useAnim ? `animation: global-tada ${speed} linear infinite both; animation-delay: ${delay};` : '');
break;
}
case 'jelly': {
const speed = validTime(token.props.args.speed) ?? '1s';
const delay = validTime(token.props.args.delay) ?? '0s';
style = (useAnim ? `animation: mfm-rubberBand ${speed} linear infinite both; animation-delay: ${delay};` : '');
break;
}
case 'twitch': {
const speed = validTime(token.props.args.speed) ?? '0.5s';
const delay = validTime(token.props.args.delay) ?? '0s';
style = useAnim ? `animation: mfm-twitch ${speed} ease infinite; animation-delay: ${delay};` : '';
break;
}
case 'shake': {
const speed = validTime(token.props.args.speed) ?? '0.5s';
const delay = validTime(token.props.args.delay) ?? '0s';
style = useAnim ? `animation: mfm-shake ${speed} ease infinite; animation-delay: ${delay};` : '';
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';
style = useAnim ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction}; animation-delay: ${delay};` : '';
break;
}
case 'jump': {
const speed = validTime(token.props.args.speed) ?? '0.75s';
const delay = validTime(token.props.args.delay) ?? '0s';
style = useAnim ? `animation: mfm-jump ${speed} linear infinite; animation-delay: ${delay};` : '';
break;
}
case 'bounce': {
const speed = validTime(token.props.args.speed) ?? '0.75s';
const delay = validTime(token.props.args.delay) ?? '0s';
style = useAnim ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom; animation-delay: ${delay};` : '';
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, scale * 2));
}
case 'x3': {
return h('span', {
class: 'mfm-x3',
}, genEl(token.children, scale * 3));
}
case 'x4': {
return h('span', {
class: 'mfm-x4',
}, genEl(token.children, scale * 4));
}
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, scale));
}
case 'rainbow': {
if (!useAnim) {
return h('span', {
class: '_mfm_rainbow_fallback_',
}, genEl(token.children, scale));
}
const speed = validTime(token.props.args.speed) ?? '1s';
const delay = validTime(token.props.args.delay) ?? '0s';
style = `animation: mfm-rainbow ${speed} linear infinite; animation-delay: ${delay};`;
break;
}
case 'sparkle': {
if (!useAnim) {
return genEl(token.children, scale);
}
return h(EmSparkle, {}, genEl(token.children, scale));
}
case 'rotate': {
const degrees = safeParseFloat(token.props.args.deg) ?? 90;
style = `transform: rotate(${degrees}deg); transform-origin: center center;`;
break;
}
case 'position': {
const x = safeParseFloat(token.props.args.x) ?? 0;
const y = safeParseFloat(token.props.args.y) ?? 0;
style = `transform: translateX(${x}em) translateY(${y}em);`;
break;
}
case 'scale': {
const x = Math.min(safeParseFloat(token.props.args.x) ?? 1, 5);
const y = Math.min(safeParseFloat(token.props.args.y) ?? 1, 5);
style = `transform: scale(${x}, ${y});`;
scale = scale * Math.max(x, y);
break;
}
case 'fg': {
let color = validColor(token.props.args.color);
color = color ?? 'f00';
style = `color: #${color}; overflow-wrap: anywhere;`;
break;
}
case 'bg': {
let color = validColor(token.props.args.color);
color = color ?? 'f00';
style = `background-color: #${color}; overflow-wrap: anywhere;`;
break;
}
case 'border': {
let color = validColor(token.props.args.color);
color = color ? `#${color}` : 'var(--accent)';
let b_style = token.props.args.style;
if (
typeof b_style !== 'string' ||
!['hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset']
.includes(b_style)
) b_style = 'solid';
const width = safeParseFloat(token.props.args.width) ?? 1;
const radius = safeParseFloat(token.props.args.radius) ?? 0;
style = `border: ${width}px ${b_style} ${color}; border-radius: ${radius}px;${token.props.args.noclip ? '' : ' overflow: clip;'}`;
break;
}
case 'ruby': {
if (token.children.length === 1) {
const child = token.children[0];
let text = child.type === 'text' ? child.props.text : '';
if (!disableNyaize && shouldNyaize) {
text = doNyaize(text);
}
return h('ruby', {}, [text.split(' ')[0], h('rt', text.split(' ')[1])]);
} else {
const rt = token.children.at(-1)!;
let text = rt.type === 'text' ? rt.props.text : '';
if (!disableNyaize && shouldNyaize) {
text = doNyaize(text);
}
return h('ruby', {}, [...genEl(token.children.slice(0, token.children.length - 1), scale), h('rt', text.trim())]);
}
}
case 'unixtime': {
const child = token.children[0];
const unixtime = parseInt(child.type === 'text' ? child.props.text : '');
return h('span', {
style: 'display: inline-block; font-size: 90%; border: solid 1px var(--divider); border-radius: 999px; padding: 4px 10px 4px 6px;',
}, [
h('i', {
class: 'ti ti-clock',
style: 'margin-right: 0.25em;',
}),
h(EmTime, {
key: Math.random(),
time: unixtime * 1000,
mode: 'detail',
}),
]);
}
case 'clickable': {
return h('span', { onClick(ev: MouseEvent): void {
ev.stopPropagation();
ev.preventDefault();
const clickEv = typeof token.props.args.ev === 'string' ? token.props.args.ev : '';
emit('clickEv', clickEv);
} }, genEl(token.children, scale));
}
}
if (style === undefined) {
return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children, scale), ']']);
} else {
return h('span', {
style: 'display: inline-block; ' + style,
}, genEl(token.children, scale));
}
}
case 'small': {
return [h('small', {
style: 'opacity: 0.7;',
}, genEl(token.children, scale))];
}
case 'center': {
return [h('div', {
style: 'text-align:center;',
}, genEl(token.children, scale))];
}
case 'url': {
return [h(EmUrl, {
key: Math.random(),
url: token.props.url,
rel: 'nofollow noopener',
})];
}
case 'link': {
return [h(EmLink, {
key: Math.random(),
url: token.props.url,
rel: 'nofollow noopener',
}, genEl(token.children, scale, true))];
}
case 'mention': {
return [h(EmMention, {
key: Math.random(),
host: (token.props.host == null && props.author && props.author.host != null ? props.author.host : token.props.host) ?? host,
username: token.props.username,
})];
}
case 'hashtag': {
return [h(EmA, {
key: Math.random(),
to: isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/user-tags/${encodeURIComponent(token.props.hashtag)}`,
style: 'color:var(--hashtag);',
}, `#${token.props.hashtag}`)];
}
case 'blockCode': {
return [h(EmCode, {
key: Math.random(),
code: token.props.code,
lang: token.props.lang ?? undefined,
})];
}
case 'inlineCode': {
return [h(EmCodeInline, {
key: Math.random(),
code: token.props.code,
})];
}
case 'quote': {
if (!props.nowrap) {
return [h('div', {
style: QUOTE_STYLE,
}, genEl(token.children, scale, true))];
} else {
return [h('span', {
style: QUOTE_STYLE,
}, genEl(token.children, scale, true))];
}
}
case 'emojiCode': {
if (props.author?.host == null) {
return [h(EmCustomEmoji, {
key: Math.random(),
name: token.props.name,
normal: props.plain,
host: null,
useOriginalSize: scale >= 2.5,
menu: props.enableEmojiMenu,
menuReaction: props.enableEmojiMenuReaction,
fallbackToImage: false,
})];
} else {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (props.emojiUrls && (props.emojiUrls[token.props.name] == null)) {
return [h('span', `:${token.props.name}:`)];
} else {
return [h(EmCustomEmoji, {
key: Math.random(),
name: token.props.name,
url: props.emojiUrls && props.emojiUrls[token.props.name],
normal: props.plain,
host: props.author.host,
useOriginalSize: scale >= 2.5,
})];
}
}
}
case 'unicodeEmoji': {
return [h(EmEmoji, {
key: Math.random(),
emoji: token.props.emoji,
menu: props.enableEmojiMenu,
menuReaction: props.enableEmojiMenuReaction,
})];
}
case 'mathInline': {
return [h('code', token.props.formula)];
}
case 'mathBlock': {
return [h('code', token.props.formula)];
}
case 'search': {
return [h(EmGoogle, {
key: Math.random(),
q: token.props.query,
})];
}
case 'plain': {
return [h('span', genEl(token.children, scale, true))];
}
default: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
console.error('unrecognized ast type:', (token as any).type);
return [];
}
}
}).flat(Infinity) as (VNode | string)[];
return h('span', {
// https://codeday.me/jp/qa/20190424/690106.html
style: props.nowrap ? 'white-space: pre; word-wrap: normal; overflow: hidden; text-overflow: ellipsis;' : 'white-space: pre-wrap;',
}, genEl(rootAst, props.rootScale ?? 1));
}

View File

@ -46,14 +46,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<EmNoteHeader :note="appearNote" :mini="true"/>
<div style="container-type: inline-size;">
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
<EmMfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
<EmButton rounded full small style="margin: 4px 0;" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</EmButton>
</p>
<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
<div :class="$style.text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<EmA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA>
<Mfm
<EmMfm
v-if="appearNote.text"
:parsedNodes="parsed"
:text="appearNote.text"
@ -67,7 +67,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<EmLoading v-if="translating" mini/>
<div v-else-if="translation">
<b>{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
<EmMfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
</div>
</div>
</div>
@ -120,6 +120,7 @@ import EmNoteSimple from '@/components/EmNoteSimple.vue';
import EmReactionsViewer from '@/components/EmReactionsViewer.vue';
import EmMediaList from '@/components/EmMediaList.vue';
import EmPoll from '@/components/EmPoll.vue';
import EmMfm from '@/components/EmMfm.js';
import { userPage } from '@/utils.js';
import { i18n } from '@/i18n.js';
import { shouldCollapsed } from '@/to-be-shared/collapsed.js';

View File

@ -58,13 +58,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</header>
<div :class="[$style.noteContent, { [$style.contentCollapsed]: collapsed }]">
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
<EmMfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
<EmButton rounded full small style="margin: 4px 0;" @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</EmButton>
</p>
<div v-show="appearNote.cw == null || showContent">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<EmA v-if="appearNote.replyId" :class="$style.noteReplyTarget" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA>
<Mfm
<EmMfm
v-if="appearNote.text"
:parsedNodes="parsed"
:text="appearNote.text"
@ -142,6 +142,7 @@ import { i18n } from '@/i18n.js';
import { shouldCollapsed } from '@/to-be-shared/collapsed.js';
import { instance } from '@/instance.js';
import { url } from '@/config.js';
import EmMfm from '@/components/EmMfm.js';
const props = defineProps<{
note: Misskey.entities.Note;

View File

@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<EmNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div>
<p v-if="note.cw != null" :class="$style.cw">
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
<EmMfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
<EmButton rounded full small @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</EmButton>
</p>
<div v-show="note.cw == null || showContent">
@ -26,6 +26,7 @@ import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import EmNoteHeader from '@/components/EmNoteHeader.vue';
import EmSubNoteContent from '@/components/EmSubNoteContent.vue';
import EmMfm from '@/components/EmMfm.js';
const props = defineProps<{
note: Misskey.entities.Note;

View File

@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<EmNoteHeader :class="$style.header" :note="note" :mini="true"/>
<div>
<p v-if="note.cw != null" :class="$style.cw">
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'"/>
<EmMfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'"/>
<EmButton rounded full small @click="showContent = !showContent">{{ showContent ? i18n.ts._cw.hide : i18n.ts._cw.show }}</EmButton>
</p>
<div v-show="note.cw == null || showContent">
@ -38,6 +38,7 @@ import EmSubNoteContent from '@/components/EmSubNoteContent.vue';
import { notePage } from '@/utils.js';
import { misskeyApi } from '@/misskey-api.js';
import { i18n } from '@/i18n.js';
import EmMfm from '@/components/EmMfm.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;

View File

@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.bg" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div>
<span :class="$style.fg">
<template v-if="choice.isVoted"><i class="ti ti-check" style="margin-right: 4px; color: var(--accent);"></i></template>
<Mfm :text="choice.text" :plain="true"/>
<EmMfm :text="choice.text" :plain="true"/>
<span v-if="showResult" style="margin-left: 4px; opacity: 0.7;">({{ i18n.tsx._poll.votesCount({ n: choice.votes }) }})</span>
</span>
</li>
@ -33,6 +33,7 @@ import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
import { i18n } from '@/i18n.js';
import { host } from '@/config.js';
import { useInterval } from '@/to-be-shared/use-interval.js';
import EmMfm from '@/components/EmMfm.js';
function sum(xs: number[]): number {
return xs.reduce((a, b) => a + b, 0);

View File

@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deletedNote }})</span>
<EmA v-if="note.replyId" :class="$style.reply" :to="`/notes/${note.replyId}`"><i class="ti ti-arrow-back-up"></i></EmA>
<Mfm v-if="note.text" :text="note.text" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
<EmMfm v-if="note.text" :text="note.text" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
<EmA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`">RN: ...</EmA>
</div>
<details v-if="note.files && note.files.length > 0">
@ -36,6 +36,7 @@ import EmMediaList from '@/components/EmMediaList.vue';
import EmPoll from '@/components/EmPoll.vue';
import { i18n } from '@/i18n.js';
import { shouldCollapsed } from '@/to-be-shared/collapsed.js';
import EmMfm from '@/components/EmMfm.js';
const props = defineProps<{
note: Misskey.entities.Note;

View File

@ -0,0 +1,90 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<component
:is="self ? 'MkA' : 'a'" ref="el" :class="$style.root" class="_link" :[attr]="self ? props.url.substring(local.length) : props.url" :rel="rel ?? 'nofollow noopener'" :target="target"
:behavior="props.navigationBehavior"
@contextmenu.stop="() => {}"
>
<template v-if="!self">
<span :class="$style.schema">{{ schema }}//</span>
<span :class="$style.hostname">{{ hostname }}</span>
<span v-if="port != ''">:{{ port }}</span>
</template>
<template v-if="pathname === '/' && self">
<span :class="$style.self">{{ hostname }}</span>
</template>
<span v-if="pathname != ''" :class="$style.pathname">{{ self ? pathname.substring(1) : pathname }}</span>
<span :class="$style.query">{{ query }}</span>
<span :class="$style.hash">{{ hash }}</span>
<i v-if="target === '_blank'" :class="$style.icon" class="ti ti-external-link"></i>
</component>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, ref } from 'vue';
import { toUnicode as decodePunycode } from 'punycode/';
import { url as local } from '@/config.js';
import { safeURIDecode } from '@/to-be-shared/safe-uri-decode.js';
const props = withDefaults(defineProps<{
url: string;
rel?: string;
showUrlPreview?: boolean;
navigationBehavior?: MkABehavior;
}>(), {
showUrlPreview: true,
});
const self = props.url.startsWith(local);
const url = new URL(props.url);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('invalid url');
const el = ref();
const schema = url.protocol;
const hostname = decodePunycode(url.hostname);
const port = url.port;
const pathname = safeURIDecode(url.pathname);
const query = safeURIDecode(url.search);
const hash = safeURIDecode(url.hash);
const attr = self ? 'to' : 'href';
const target = self ? null : '_blank';
</script>
<style lang="scss" module>
.root {
word-break: break-all;
}
.icon {
padding-left: 2px;
font-size: .9em;
}
.self {
font-weight: bold;
}
.schema {
opacity: 0.5;
}
.hostname {
font-weight: bold;
}
.pathname {
opacity: 0.8;
}
.query {
opacity: 0.5;
}
.hash {
font-style: italic;
}
</style>

View File

@ -3,11 +3,13 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { computed, reactive } from 'vue';
import * as Misskey from 'misskey-js';
import { misskeyApi } from '@/misskey-api.js';
export const instance = {
iconUrl: 'TODO',
mediaProxy: 'TODO',
};
const providedMetaEl = document.getElementById('misskey_meta');
const _instance = (providedMetaEl && providedMetaEl.textContent) ? JSON.parse(providedMetaEl.textContent) : null;
// NOTE: devモードのときしか _instance が null になることは無い
export const instance = _instance ?? await misskeyApi('meta', {
detail: true,
});

View File

@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
const enRegex1 = /(?<=n)a/gi;
const enRegex2 = /(?<=morn)ing/gi;
const enRegex3 = /(?<=every)one/gi;
const koRegex1 = /[나-낳]/g;
const koRegex2 = /(다$)|(다(?=\.))|(다(?= ))|(다(?=!))|(다(?=\?))/gm;
const koRegex3 = /(야(?=\?))|(야$)|(야(?= ))/gm;
export function nyaize(text: string): string {
return text
// ja-JP
.replaceAll('な', 'にゃ').replaceAll('ナ', 'ニャ').replaceAll('ナ', 'ニャ')
// en-US
.replace(enRegex1, x => x === 'A' ? 'YA' : 'ya')
.replace(enRegex2, x => x === 'ING' ? 'YAN' : 'yan')
.replace(enRegex3, x => x === 'ONE' ? 'NYAN' : 'nyan')
// ko-KR
.replace(koRegex1, match => String.fromCharCode(
match.charCodeAt(0)! + '냐'.charCodeAt(0) - '나'.charCodeAt(0),
))
.replace(koRegex2, '다냥')
.replace(koRegex3, '냥');
}

View File

@ -0,0 +1,11 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function safeParseFloat(str: unknown): number | null {
if (typeof str !== 'string' || str === '') return null;
const num = parseFloat(str);
if (isNaN(num)) return null;
return num;
}

View File

@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function safeURIDecode(str: string): string {
try {
return decodeURIComponent(str);
} catch {
return str;
}
}