diff --git a/gulpfile.js b/gulpfile.js
index a04ab4c1ad..a7620db421 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -36,7 +36,11 @@ gulp.task('copy:frontend:locales', cb => {
});
gulp.task('build:backend:script', () => {
- return gulp.src(['./packages/backend/src/server/web/boot.js', './packages/backend/src/server/web/bios.js', './packages/backend/src/server/web/cli.js'])
+ return gulp.src([
+ './packages/backend/src/server/web/boot.js',
+ './packages/backend/src/server/web/bios.js',
+ './packages/backend/src/server/web/cli.js',
+ ])
.pipe(replace('LANGS', JSON.stringify(Object.keys(locales))))
.pipe(terser({
toplevel: true
@@ -45,7 +49,12 @@ gulp.task('build:backend:script', () => {
});
gulp.task('build:backend:style', () => {
- return gulp.src(['./packages/backend/src/server/web/style.css', './packages/backend/src/server/web/bios.css', './packages/backend/src/server/web/cli.css'])
+ return gulp.src([
+ './packages/backend/src/server/web/style.css',
+ './packages/backend/src/server/web/bios.css',
+ './packages/backend/src/server/web/cli.css',
+ './packages/backend/src/server/web/embed.css'
+ ])
.pipe(cssnano({
zindex: false
}))
diff --git a/package.json b/package.json
index d1c081c86d..595eb72861 100644
--- a/package.json
+++ b/package.json
@@ -4,13 +4,14 @@
"codename": "nasubi",
"repository": {
"type": "git",
- "url": "https://github.com/misskey-dev/misskey.git"
+ "url": "https://github.com/kakkokari-gtyih/misskey.git"
},
"packageManager": "pnpm@8.1.1",
"workspaces": [
"packages/frontend",
"packages/backend",
- "packages/sw"
+ "packages/sw",
+ "packages/misskey-js"
],
"private": true,
"scripts": {
@@ -66,4 +67,4 @@
"optionalDependencies": {
"@tensorflow/tfjs-core": "4.2.0"
}
-}
+}
\ No newline at end of file
diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts
index fd2b83cf2a..707a37cad1 100644
--- a/packages/backend/src/config.ts
+++ b/packages/backend/src/config.ts
@@ -103,6 +103,7 @@ export type Mixin = {
driveUrl: string;
userAgent: string;
clientEntry: string;
+ clientEmbedEntry: string;
clientManifestExists: boolean;
mediaProxy: string;
externalMediaProxyEnabled: boolean;
@@ -133,7 +134,10 @@ export function loadConfig() {
const clientManifestExists = fs.existsSync(_dirname + '/../../../built/_vite_/manifest.json');
const clientManifest = clientManifestExists ?
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_vite_/manifest.json`, 'utf-8'))
- : { 'src/init.ts': { file: 'src/init.ts' } };
+ : {
+ 'src/init.ts': { file: 'src/init.ts' },
+ 'src/embed/init.ts': { file: 'src/embed/init.ts' },
+ };
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
const mixin = {} as Mixin;
@@ -155,6 +159,7 @@ export function loadConfig() {
mixin.driveUrl = `${mixin.scheme}://${mixin.host}/files`;
mixin.userAgent = `Misskey/${meta.version} (${config.url})`;
mixin.clientEntry = clientManifest['src/init.ts'];
+ mixin.clientEmbedEntry = clientManifest['src/embed/init.ts'];
mixin.clientManifestExists = clientManifestExists;
const externalMediaProxy = config.mediaProxy ?
diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts
index 99ae1b7af6..ed92b16ebe 100644
--- a/packages/backend/src/server/web/ClientServerService.ts
+++ b/packages/backend/src/server/web/ClientServerService.ts
@@ -344,6 +344,17 @@ export class ClientServerService {
});
};
+ const renderEmbed404 = async (reply: FastifyReply) => {
+ reply.status(404);
+ const meta = await this.metaService.fetch();
+
+ return await reply.view('embed/404', {
+ instanceName: meta.name ?? 'Misskey',
+ icon: meta.iconUrl,
+ url: this.config.url,
+ });
+ };
+
// URL preview endpoint
fastify.get<{ Querystring: { url: string; lang: string; } }>('/url', (request, reply) => this.urlPreviewService.handle(request, reply));
@@ -469,13 +480,42 @@ export class ClientServerService {
summary: getNoteSummary(_note),
instanceName: meta.name ?? 'Misskey',
icon: meta.iconUrl,
- themeColor: meta.themeColor,
+ themeColor: meta.themeColor
});
} else {
return await renderBase(reply);
}
});
+ // Note Embed
+ fastify.get<{ Params: { note: string; } }>('/notes/:note/embed', async (request, reply) => {
+ vary(reply.raw, 'Accept');
+
+ const note = await this.notesRepository.findOneBy({
+ id: request.params.note,
+ visibility: In(['public', 'home']),
+ });
+
+ if (note) {
+ const _note = await this.noteEntityService.pack(note);
+ const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId });
+ const meta = await this.metaService.fetch();
+ reply.header('Cache-Control', 'public, max-age=15');
+ return await reply.view('embed/note', {
+ note: _note,
+ profile,
+ avatarUrl: _note.user.avatarUrl,
+ // TODO: Let locale changeable by instance setting
+ summary: getNoteSummary(_note),
+ instanceName: meta.name ?? 'Misskey',
+ icon: meta.iconUrl,
+ themeColor: meta.themeColor,
+ });
+ } else {
+ return await renderEmbed404(reply);
+ }
+ });
+
// Page
fastify.get<{ Params: { user: string; page: string; } }>('/@:user/pages/:page', async (request, reply) => {
const { username, host } = Acct.parse(request.params.user);
diff --git a/packages/backend/src/server/web/embed.css b/packages/backend/src/server/web/embed.css
new file mode 100644
index 0000000000..21e5b9fcbf
--- /dev/null
+++ b/packages/backend/src/server/web/embed.css
@@ -0,0 +1,21 @@
+html,body {
+ max-width: 650px;
+}
+
+#splash {
+ max-width: 650px;
+ width: 100%;
+ height: 100%;
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border-width: 0;
+}
\ No newline at end of file
diff --git a/packages/backend/src/server/web/views/embed/404.pug b/packages/backend/src/server/web/views/embed/404.pug
new file mode 100644
index 0000000000..44ed0a3276
--- /dev/null
+++ b/packages/backend/src/server/web/views/embed/404.pug
@@ -0,0 +1,16 @@
+extends ./base
+
+block style
+ style.
+
+block content
+ div#error
+ div
+ div#instance-info
+ a.click-anime(href=url target='_blank')
+ img(src= icon || '/static-assets/splash.png')
+ span.sr-only(data-mi-i18n='aboutX' data-mi-i18n-ctx=`{"x": "${instanceName}"}`)
+
+ img.main(src='https://xn--931a.moe/assets/not-found.jpg')
+ h2(data-mi-i18n='notFound')
+ p(data-mi-i18n='notFoundDescription')
\ No newline at end of file
diff --git a/packages/backend/src/server/web/views/embed/base.pug b/packages/backend/src/server/web/views/embed/base.pug
new file mode 100644
index 0000000000..7bec25d83c
--- /dev/null
+++ b/packages/backend/src/server/web/views/embed/base.pug
@@ -0,0 +1,98 @@
+block vars
+
+block loadClientEntry
+ - const clientEntry = config.clientEmbedEntry;
+
+doctype html
+
+//
+ -
+ _____ _ _
+ | |_|___ ___| |_ ___ _ _
+ | | | | |_ -|_ -| '_| -_| | |
+ |_|_|_|_|___|___|_,_|___|_ |
+ |___|
+ Thank you for using Misskey!
+ If you are reading this message... how about joining the development?
+ https://github.com/misskey-dev/misskey
+
+
+html
+
+ head
+ meta(charset='utf-8')
+ meta(name='application-name' content='Misskey')
+ meta(name='referrer' content='origin')
+ meta(name='theme-color' content= themeColor || '#86b300')
+ meta(name='theme-color-orig' content= themeColor || '#86b300')
+ meta(property='twitter:card' content='summary')
+ meta(property='og:site_name' content= instanceName || 'Misskey')
+ meta(name='viewport' content='width=device-width, initial-scale=1')
+ link(rel='icon' href= icon || '/favicon.ico')
+ link(rel='apple-touch-icon' href= icon || '/apple-touch-icon.png')
+ link(rel='manifest' href='/manifest.json')
+ link(rel='search' type='application/opensearchdescription+xml' title=(title || "Misskey") href=`${url}/opensearch.xml`)
+ link(rel='prefetch' href='https://xn--931a.moe/assets/info.jpg')
+ link(rel='prefetch' href='https://xn--931a.moe/assets/not-found.jpg')
+ link(rel='prefetch' href='https://xn--931a.moe/assets/error.jpg')
+ //- https://github.com/misskey-dev/misskey/issues/9842
+ link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.12.0')
+ link(rel='modulepreload' href=`/vite/${clientEntry.file}`)
+
+ if !config.clientManifestExists
+ script(type="module" src="/vite/@vite/client")
+
+ if Array.isArray(clientEntry.css)
+ each href in clientEntry.css
+ link(rel='stylesheet' href=`/vite/${href}`)
+
+ title
+ block title
+ = title || 'Misskey'
+
+ block desc
+ meta(name='description' content= desc || '✨🌎✨ A interplanetary communication platform ✨🚀✨')
+
+ block meta
+
+ block og
+ meta(property='og:title' content= title || 'Misskey')
+ meta(property='og:description' content= desc || '✨🌎✨ A interplanetary communication platform ✨🚀✨')
+ meta(property='og:image' content= img)
+
+ style
+ include ../../style.css
+ include ../../embed.css
+
+ block style
+
+ script.
+ var VERSION = "#{version}";
+ var CLIENT_ENTRY = "#{clientEntry.file}";
+ var EMBED = true;
+
+ script
+ include ../../boot.js
+
+ body
+ noscript: p
+ | JavaScriptを有効にしてください
+ br
+ | Please turn on your JavaScript
+
+ div#splash
+ img#splashIcon(src= icon || '/static-assets/splash.png')
+ div#splashSpinner
+
+
+
+ div#container
+ block content
\ No newline at end of file
diff --git a/packages/backend/src/server/web/views/embed/note.pug b/packages/backend/src/server/web/views/embed/note.pug
new file mode 100644
index 0000000000..cb951f55cb
--- /dev/null
+++ b/packages/backend/src/server/web/views/embed/note.pug
@@ -0,0 +1,53 @@
+extends ./base
+
+block vars
+ - const user = note.user;
+ - const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`;
+ - const url = `${config.url}/notes/${note.id}`;
+ - const isRenote = note.renote && note.text == null && note.fileIds.length == 0 && note.poll == null;
+ - const displayUser = isRenote ? note.renote.user: note.user;
+
+block meta
+ if !user.host
+ link(rel='alternate' href=url type='application/activity+json')
+ if note.uri
+ link(rel='alternate' href=note.uri type='application/activity+json')
+
+ script(type='application/json') !{JSON.stringify(note)}
+
+block content
+ div#note
+
+ header
+ div.wrapper
+ if isRenote
+ div#renote
+ a.avatar(href=`${config.url}/@${note.user.username}` target="_blank" rel="noopener noreferrer")
+ img(src=note.user.avatarUrl)
+
+ i.ti.ti-repeat
+
+ span(data-mi-i18n='renotedBy' data-mi-i18n-ctx=`{"user": "${note.user.name || note.user.username}"}`)
+ a(href=`${config.url}/@${note.user.username}` target="_blank" rel="noopener noreferrer")
+ b(data-mi-i18n-target='user')
+
+ div.author
+ a.avatar(href=`${config.url}/@${displayUser.username}` target="_blank" rel="noopener noreferrer")
+ img(src=displayUser.avatarUrl)
+
+ div.user-info
+ a.user-name(href=`${config.url}/@${displayUser.username}` target="_blank" rel="noopener noreferrer") #{displayUser.name || displayUser.username}
+ div.user-id @#{displayUser.username}
+
+ div#instance-info
+ a.click-anime(href=config.url target='_blank')
+ img(src= icon || '/static-assets/splash.png')
+ span.sr-only(data-mi-i18n='aboutX' data-mi-i18n-ctx=`{"x": "${instanceName}"}`)
+
+ main
+ div.mfm !{isRenote ? note.renote.text : note.text}
+ if (!isRenote && note.renote)
+ div#quote.mfm !{note.renote.text}
+
+ hr
+ pre(style='white-space: pre-wrap;') !{JSON.stringify(note, null, 2)}
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index 79fb626a9a..1d74a35dda 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -39,6 +39,7 @@
"eventemitter3": "5.0.0",
"gsap": "3.11.5",
"idb-keyval": "6.2.0",
+ "iframe-resizer": "^4.3.6",
"insert-text-at-cursor": "0.3.0",
"is-file-animated": "1.0.2",
"json5": "2.2.3",
@@ -97,6 +98,7 @@
"@types/estree": "1.0.0",
"@types/gulp": "4.0.10",
"@types/gulp-rename": "2.0.1",
+ "@types/iframe-resizer": "^3.5.9",
"@types/matter-js": "0.18.2",
"@types/micromatch": "3.1.1",
"@types/node": "18.15.11",
diff --git a/packages/frontend/src/embed/embed.scss b/packages/frontend/src/embed/embed.scss
new file mode 100644
index 0000000000..bd1da0e817
--- /dev/null
+++ b/packages/frontend/src/embed/embed.scss
@@ -0,0 +1,125 @@
+*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}:where([hidden]:not([hidden='until-found'])){display:none!important}
+
+html,body {
+ background-color: transparent;
+}
+
+#container {
+ padding: 1.5rem 2rem;
+ border-radius: var(--radius);
+ border: 1px solid var(--divider);
+ background: var(--bg);
+}
+
+#instance-info img {
+ height: 2.5rem;
+ width: auto;
+}
+
+.custom-emoji {
+ height: 2em;
+ width: auto;
+ display: inline-block;
+ vertical-align: center;
+ transition: transform .2s ease;
+
+ &:hover {
+ transform: scale(1.2);
+ }
+}
+
+#error {
+ padding: 0 0 3.5rem;
+ text-align: center;
+
+ #instance-info {
+ text-align: end;
+ }
+
+ img.main {
+ max-width: 128px;
+ width: 100%;
+ height: auto;
+ border-radius: 16px;
+ margin-bottom: .5rem;
+ }
+
+ h2 {
+ font-size: 1.25rem;
+ margin-bottom: .5rem;
+ }
+
+ p {
+
+ }
+}
+
+#note {
+ display: block;
+ position: relative;
+
+ header {
+ display: flex;
+ margin-bottom: 1rem;
+
+ .wrapper {
+ #renote {
+ display: flex;
+ align-items: center;
+ margin-bottom: .5rem;
+ color: var(--renote);
+
+ >.avatar {
+ display: block;
+ margin-right: 1rem;
+
+ >img {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ }
+ }
+ }
+
+ .author {
+ display: flex;
+ align-items: center;
+
+ >.avatar {
+ display: block;
+ margin-right: 1rem;
+
+ >img {
+ width: 54px;
+ height: 54px;
+ border-radius: 50%;
+ }
+ }
+
+ >.user-info {
+ >.user-name {
+ font-weight: 700;
+ font-size: 1.1rem;
+ }
+ }
+ }
+ }
+
+ #instance-info {
+ flex-shrink: 0;
+ margin-left: auto;
+ }
+ }
+
+ main {
+ font-size: 1.05rem;
+
+ #quote {
+ font-size: .95rem;
+ padding: .75rem;
+ margin: 1rem 0;
+ border-radius: var(--radius);
+ border: dashed 1px var(--renote);
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/frontend/src/embed/init.ts b/packages/frontend/src/embed/init.ts
new file mode 100644
index 0000000000..e583bf38e5
--- /dev/null
+++ b/packages/frontend/src/embed/init.ts
@@ -0,0 +1,82 @@
+import { miLocalStorage } from '@/local-storage';
+import { version, lang, updateLocale } from '@/config';
+import { updateI18n } from '@/i18n';
+import { embedInitI18n } from './scripts/embed-i18n';
+import '@/style.scss';
+import './embed.scss';
+import 'iframe-resizer/js/iframeResizer.contentWindow';
+import { embedInitLinkAnime } from './scripts/link-anime';
+import { parseMfm } from './scripts/parse-mfm';
+
+console.info(`Misskey (Embed Sandbox) v${version}`);
+
+if (_DEV_) {
+ console.warn('Development mode!!!');
+
+ window.addEventListener('error', event => {
+ console.error(event);
+ /*
+ alert({
+ type: 'error',
+ title: 'DEV: Unhandled error',
+ text: event.message
+ });
+ */
+ });
+
+ window.addEventListener('unhandledrejection', event => {
+ console.error(event);
+ /*
+ alert({
+ type: 'error',
+ title: 'DEV: Unhandled promise rejection',
+ text: event.reason
+ });
+ */
+ });
+}
+
+//#region Detect language & fetch translations
+const localeVersion = miLocalStorage.getItem('localeVersion');
+const localeOutdated = (localeVersion == null || localeVersion !== version);
+if (localeOutdated) {
+ const res = await window.fetch(`/assets/locales/${lang}.${version}.json`);
+ if (res.status === 200) {
+ const newLocale = await res.text();
+ const parsedNewLocale = JSON.parse(newLocale);
+ miLocalStorage.setItem('locale', newLocale);
+ miLocalStorage.setItem('localeVersion', version);
+ updateLocale(parsedNewLocale);
+ updateI18n(parsedNewLocale);
+ }
+}
+//#endregion
+
+// タッチデバイスでCSSの:hoverを機能させる
+document.addEventListener('touchend', () => {}, { passive: true });
+
+//#region Set lang attr
+const html = document.documentElement;
+html.setAttribute('lang', lang);
+//#endregion
+
+embedInitI18n();
+
+document.querySelectorAll(".mfm").forEach((e) => {
+ e.innerHTML = parseMfm(e.innerHTML).outerHTML;
+});
+
+//#region ロード画面解除
+const splash = document.getElementById('splash');
+// 念のためnullチェック(HTMLが古い場合があるため(そのうち消す))
+if (splash) splash.addEventListener('transitionend', () => {
+ splash.remove();
+});
+
+if (splash) {
+ splash.style.opacity = '0';
+ splash.style.pointerEvents = 'none';
+}
+//#endregion
+
+embedInitLinkAnime();
diff --git a/packages/frontend/src/embed/scripts/embed-i18n.ts b/packages/frontend/src/embed/scripts/embed-i18n.ts
new file mode 100644
index 0000000000..72104e081f
--- /dev/null
+++ b/packages/frontend/src/embed/scripts/embed-i18n.ts
@@ -0,0 +1,46 @@
+import { i18n } from "@/i18n";
+
+/**
+ * 非vueページ向け翻訳適用関数
+ *
+ * キー指定例:
+ * ```html
+ *
+ * ```
+ */
+export function embedInitI18n() {
+ const els: NodeListOf = document.querySelectorAll("[data-mi-i18n]");
+ els.forEach((tag: HTMLElement) => {
+ const key: string[] | null = tag.dataset.miI18n?.split('.') || null;
+ const translationContext: Record | null = JSON.parse(tag.dataset.miI18nCtx ?? 'null');
+ if (!key) {
+ console.warn("[i18n] Key doesn't exist!", tag);
+ } else if (translationContext) {
+ let hasTranslationTarget: boolean = false;
+ let output: string = key.reduce((o, i) => o[i], i18n.ts);
+ Object.keys(translationContext).forEach((item) => {
+ const templateTag: NodeListOf = tag.querySelectorAll(`[data-mi-i18n-target="${item}"]`);
+ if (templateTag.length > 0) {
+ hasTranslationTarget = true;
+ templateTag.forEach((target: HTMLElement) => {
+ target.innerText = translationContext[item].toString();
+ let parent: HTMLElement = target;
+ while (parent.parentElement != null && parent.parentElement !== tag) {
+ if (parent.parentElement != null) {
+ parent = parent.parentElement;
+ }
+ }
+ output = output.replace(new RegExp(`{\s*${item}\s*}`), parent.outerHTML);
+ });
+ }
+ });
+ if (!hasTranslationTarget) {
+ tag.innerText = i18n.t(key.join('.'), translationContext);
+ } else {
+ tag.innerHTML = output;
+ }
+ } else {
+ tag.innerText = key.reduce((o, i) => o[i], i18n.ts);
+ }
+ });
+}
\ No newline at end of file
diff --git a/packages/frontend/src/embed/scripts/link-anime.ts b/packages/frontend/src/embed/scripts/link-anime.ts
new file mode 100644
index 0000000000..f1afd56ea6
--- /dev/null
+++ b/packages/frontend/src/embed/scripts/link-anime.ts
@@ -0,0 +1,33 @@
+export function embedInitLinkAnime() {
+ const animeEl: NodeListOf = document.querySelectorAll("a.click-anime,button.click-anime");
+ if (animeEl.length > 0) {
+ animeEl.forEach((el: HTMLElement) => {
+ const target = el.children[0];
+
+ if (target == null) return;
+
+ target.classList.add('_anime_bounce_standBy');
+
+ el.addEventListener('mousedown', () => {
+ target.classList.remove('_anime_bounce');
+
+ target.classList.add('_anime_bounce_standBy');
+ target.classList.add('_anime_bounce_ready');
+
+ target.addEventListener('mouseleave', () => {
+ target.classList.remove('_anime_bounce_ready');
+ });
+ });
+
+ el.addEventListener('click', () => {
+ target.classList.add('_anime_bounce');
+ target.classList.remove('_anime_bounce_ready');
+ });
+
+ el.addEventListener('animationend', () => {
+ target.classList.remove('_anime_bounce');
+ target.classList.add('_anime_bounce_standBy');
+ });
+ });
+ }
+}
\ No newline at end of file
diff --git a/packages/frontend/src/embed/scripts/parse-mfm.ts b/packages/frontend/src/embed/scripts/parse-mfm.ts
new file mode 100644
index 0000000000..0020b9787f
--- /dev/null
+++ b/packages/frontend/src/embed/scripts/parse-mfm.ts
@@ -0,0 +1,329 @@
+import * as mfm from 'mfm-js';
+import { toUnicode } from 'punycode';
+import { host as localHost } from '@/config';
+
+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(' ');
+
+interface MfmFn extends mfm.MfmFn {
+ props: {
+ name: string;
+ args: Record;
+ }
+};
+
+export function parseMfm(text: string): HTMLDivElement {
+ const ast = mfm.parse(text);
+
+ const el = document.createElement("div");
+
+ function genEl(ast: (MfmFn | mfm.MfmNode)[]) {
+ return ast.map((token: (MfmFn | mfm.MfmNode)) => {
+ switch (token.type) {
+ case 'text': {
+ const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
+
+ const res: HTMLElement[] = [];
+ for (const t of text.split('\n')) {
+ res.push(document.createElement('br'));
+ const el = document.createElement('span');
+ el.innerText = t;
+ res.push(el);
+ }
+ res.shift();
+ return res;
+ }
+
+ case 'bold': {
+ const el = document.createElement("b");
+ genEl(token.children).forEach((e) => {
+ el.appendChild(e as HTMLElement);
+ });
+ return [el];
+ }
+
+ case 'strike': {
+ const el = document.createElement("del");
+ genEl(token.children).forEach((e) => {
+ el.appendChild(e as HTMLElement);
+ });
+ return [el];
+ }
+
+ case 'italic': {
+ const el = document.createElement("i");
+ el.style.fontStyle = 'oblique';
+ genEl(token.children).forEach((e) => {
+ el.appendChild(e as HTMLElement);
+ });
+ return [el];
+ }
+
+ case 'fn': {
+ // TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる
+ let style;
+ switch (token.props.name) {
+ 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': {
+ const el = document.createElement("span");
+ el.classList.add('mfm-x2');
+ genEl(token.children).forEach((e) => {
+ el.appendChild(e as HTMLElement);
+ })
+ return [el];
+ }
+ case 'x3': {
+ const el = document.createElement("span");
+ el.classList.add('mfm-x3');
+ genEl(token.children).forEach((e) => {
+ el.appendChild(e as HTMLElement);
+ })
+ return [el];
+ }
+ case 'x4': {
+ const el = document.createElement("span");
+ el.classList.add('mfm-x4');
+ genEl(token.children).forEach((e) => {
+ el.appendChild(e as HTMLElement);
+ })
+ return [el];
+ }
+ 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': {
+ const el = document.createElement("span");
+ el.classList.add('_mfm_blur_');
+ genEl(token.children).forEach((e) => {
+ el.appendChild(e as HTMLElement);
+ })
+ return [el];
+ }
+ case 'rotate': {
+ const degrees = parseFloat(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 '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;
+ }
+ }
+ if (style == null) {
+ const el = document.createElement("span");
+ genEl(token.children).forEach((e) => {
+ el.appendChild(e as HTMLElement);
+ });
+ el.innerHTML = `$[${token.props.name} ${el.innerHTML}]`;
+ return [el];
+ } else {
+ const el = document.createElement("span");
+ genEl(token.children).forEach((e) => {
+ el.appendChild(e as HTMLElement);
+ });
+ el.setAttribute('style', `display: inline-block; ${style}`);
+ return [el];
+ }
+ }
+
+ case 'small': {
+ const el = document.createElement("small");
+ el.style.opacity = '.7';
+ genEl(token.children).forEach((e) => {
+ el.appendChild(e as HTMLElement);
+ });
+ return [el];
+ }
+
+ case 'center': {
+ const el = document.createElement("div");
+ el.style.textAlign = "center";
+ genEl(token.children).forEach((e) => {
+ el.appendChild(e as HTMLElement);
+ });
+ return [el];
+ }
+
+ case 'url': {
+ const el = document.createElement("a");
+ el.href = token.props.url;
+ el.target = '_blank';
+ el.rel = 'nofollow noopener';
+
+ el.innerText = token.props.url;
+ return [el];
+ }
+
+ case 'link': {
+ const el = document.createElement("a");
+ el.href = token.props.url;
+ el.target = '_blank';
+ el.rel = 'nofollow noopener';
+ genEl(token.children).forEach((e) => {
+ el.appendChild(e as HTMLElement);
+ });
+ return [el];
+ }
+
+ case 'mention': {
+ const el = document.createElement("a");
+ const canonical = token.props.host === localHost ? `@${token.props.username}` : `@${token.props.username}@${toUnicode(token.props.host ?? '')}`;
+ el.href = `/${canonical}`;
+ el.target = '_blank';
+ el.rel = 'nofollow noopener';
+ el.style.display = 'inline-block';
+ el.style.padding = '4px 8px 4px 4px';
+ el.style.borderRadius = '999px';
+ el.style.color = 'var(--mention)';
+ el.style.fontWeight = '700';
+
+ el.innerText = `@${canonical}`;
+ return [el];
+ }
+
+ case 'hashtag': {
+ const el = document.createElement("a");
+ el.href = `/tags/${encodeURIComponent(token.props.hashtag)}`;
+ el.target = '_blank';
+ el.rel = 'nofollow noopener';
+ el.style.color = 'var(--hashtag)';
+
+ el.innerText = `#${token.props.hashtag}`;
+ return [el];
+ }
+
+ case 'blockCode': {
+ const el = document.createElement('pre');
+ el.style.overflowX = 'scroll';
+ el.style.width = '100%';
+ const elc = document.createElement('code');
+ elc.innerText = token.props.code;
+ el.appendChild(elc);
+ return [el];
+ }
+
+ case 'inlineCode': {
+ const el = document.createElement('code');
+ el.innerText = token.props.code;
+ return [el];
+ }
+
+ case 'quote': {
+ const el = document.createElement('div');
+ el.setAttribute('style', QUOTE_STYLE);
+ genEl(token.children).forEach((e) => {
+ el.appendChild(e as HTMLElement);
+ });
+ return [el];
+ }
+
+ case 'emojiCode': {
+ const el = document.createElement('span');
+ el.classList.add('custom-emoji', 'needs-replacing');
+ el.innerText = `:${token.props.name}:`;
+ return [el];
+ }
+
+ case 'unicodeEmoji': {
+ const el = document.createElement('span');
+ el.classList.add('emoji');
+ el.innerText = token.props.emoji;
+ return [el];
+ }
+
+ case 'mathInline': {
+ const el = document.createElement('code');
+ el.innerText = token.props.formula;
+ return [el];
+ }
+
+ case 'mathBlock': {
+ const el = document.createElement('code');
+ el.innerText = token.props.formula;
+ return [el];
+ }
+ case 'search': {
+ const el = document.createElement('form');
+ el.action = 'https://www.google.com/search';
+ el.method = 'GET';
+
+ const text = document.createElement('input');
+ text.type = 'search';
+ text.value = token.props.query;
+ el.appendChild(text);
+
+ const submit = document.createElement('button');
+ submit.type = 'submit';
+ submit.innerHTML = ' {
+ el.appendChild(e as HTMLElement);
+ });
+ return [el];
+ }
+
+ default: {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ console.error('unrecognized ast type:', (token as any).type);
+
+ return [];
+ }
+ }
+ }).flat(Infinity);
+ }
+
+ genEl(ast).forEach((element) => {
+ el.appendChild(element as HTMLElement);
+ });
+
+ return el;
+}
\ No newline at end of file
diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json
index 4d582daa3c..ba494a1951 100644
--- a/packages/frontend/tsconfig.json
+++ b/packages/frontend/tsconfig.json
@@ -43,7 +43,7 @@
".eslintrc.js",
"./**/*.ts",
"./**/*.vue"
- ],
+, "src/embed/scripts/parse-mfm.ts" ],
"exclude": [
".storybook/**/*",
]
diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts
index 425f3aa45d..56ce4eea05 100644
--- a/packages/frontend/vite.config.ts
+++ b/packages/frontend/vite.config.ts
@@ -100,6 +100,7 @@ export function getConfig(): UserConfig {
rollupOptions: {
input: {
app: './src/init.ts',
+ embed: './src/embed/init.ts'
},
output: {
manualChunks: {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ff9765b5c4..521f34d7d9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -663,6 +663,9 @@ importers:
idb-keyval:
specifier: 6.2.0
version: 6.2.0
+ iframe-resizer:
+ specifier: ^4.3.6
+ version: 4.3.6
insert-text-at-cursor:
specifier: 0.3.0
version: 0.3.0
@@ -832,6 +835,9 @@ importers:
'@types/gulp-rename':
specifier: 2.0.1
version: 2.0.1
+ '@types/iframe-resizer':
+ specifier: ^3.5.9
+ version: 3.5.9
'@types/matter-js':
specifier: 0.18.2
version: 0.18.2
@@ -6593,6 +6599,10 @@ packages:
/@types/http-cache-semantics@4.0.1:
resolution: {integrity: sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==}
+ /@types/iframe-resizer@3.5.9:
+ resolution: {integrity: sha512-RQUBI75F+uXruB95BFpC/8V8lPgJg4MQ6HxOCtAZYBB/h0FNCfrFfb4I+u2pZJIV7sKeszZbFqy1UnGeBMrvsA==}
+ dev: true
+
/@types/ioredis@4.28.10:
resolution: {integrity: sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==}
dependencies:
@@ -12526,6 +12536,11 @@ packages:
/ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
+ /iframe-resizer@4.3.6:
+ resolution: {integrity: sha512-wz0WodRIF6eP0oGQa5NIP1yrITAZ59ZJvVaVJqJRjaeCtfm461vy2C3us6CKx0e7pooqpIGLpVMSTzrfAjX9Sg==}
+ engines: {node: '>=0.8.0'}
+ dev: false
+
/ignore@5.2.4:
resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==}
engines: {node: '>= 4'}