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'}