mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-18 06:24:25 +01:00
refactor: ogp
This commit is contained in:
782
package-lock.json
generated
782
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@
|
|||||||
"checkSecrets": "node -e \"import('./scripts/checkSecrets.mjs').then((m) => m.default())\""
|
"checkSecrets": "node -e \"import('./scripts/checkSecrets.mjs').then((m) => m.default())\""
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/jsdom": "^21.1.6",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||||
"@typescript-eslint/parser": "^6.7.4",
|
"@typescript-eslint/parser": "^6.7.4",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
"eslint-plugin-solid": "^0.13.0",
|
"eslint-plugin-solid": "^0.13.0",
|
||||||
"eslint-plugin-tailwindcss": "^3.13.0",
|
"eslint-plugin-tailwindcss": "^3.13.0",
|
||||||
"husky": "^8.0.3",
|
"husky": "^8.0.3",
|
||||||
|
"jsdom": "^23.0.0",
|
||||||
"license-checker": "^25.0.1",
|
"license-checker": "^25.0.1",
|
||||||
"lint-staged": "^14.0.1",
|
"lint-staged": "^14.0.1",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.31",
|
||||||
|
|||||||
92
src/components/event/textNote/PreviewedLink.tsx
Normal file
92
src/components/event/textNote/PreviewedLink.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { Component, JSX, Switch, Match, createEffect } from 'solid-js';
|
||||||
|
|
||||||
|
import SafeLink from '@/components/utils/SafeLink';
|
||||||
|
import { useOgp } from '@/utils/ogp';
|
||||||
|
import { isTwitterUrl, parseYouTubeVideoUrl } from '@/utils/url';
|
||||||
|
|
||||||
|
type PreviewdLinkProps = {
|
||||||
|
class?: string;
|
||||||
|
href: string;
|
||||||
|
children?: JSX.Element;
|
||||||
|
};
|
||||||
|
|
||||||
|
const twitterUrl = (urlString: string): string => {
|
||||||
|
try {
|
||||||
|
const url = new URL(urlString);
|
||||||
|
url.host = 'twitter.com';
|
||||||
|
return url.href;
|
||||||
|
} catch {
|
||||||
|
return urlString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const youtubeUrl = (videoId: string): string => {
|
||||||
|
const iframeUrl = new URL(`https://www.youtube.com/embed/`);
|
||||||
|
iframeUrl.pathname += videoId;
|
||||||
|
iframeUrl.searchParams.set('origin', window.location.origin);
|
||||||
|
return iframeUrl.href;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PreviewedLink: Component<PreviewdLinkProps> = (props) => {
|
||||||
|
let twitterRef: HTMLQuoteElement | undefined;
|
||||||
|
|
||||||
|
const { ogp } = useOgp(() => ({
|
||||||
|
url: props.href,
|
||||||
|
}));
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (isTwitterUrl(props.href)) {
|
||||||
|
window.twttr?.widgets?.load(twitterRef);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch fallback={<SafeLink class={props.class} href={props.href} />}>
|
||||||
|
<Match when={isTwitterUrl(props.href)}>
|
||||||
|
<blockquote class="twitter-tweet" ref={twitterRef}>
|
||||||
|
<a
|
||||||
|
class={props.class}
|
||||||
|
href={twitterUrl(props.href)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
>
|
||||||
|
{twitterUrl(props.href)}
|
||||||
|
</a>
|
||||||
|
</blockquote>
|
||||||
|
</Match>
|
||||||
|
<Match when={parseYouTubeVideoUrl(props.href)} keyed>
|
||||||
|
{({ videoId }) => (
|
||||||
|
<div class="my-2 aspect-video w-full">
|
||||||
|
<iframe
|
||||||
|
loading="lazy"
|
||||||
|
title="YouTube"
|
||||||
|
class="my-2 h-full w-full"
|
||||||
|
src={youtubeUrl(videoId)}
|
||||||
|
allowfullscreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
|
<Match when={ogp()} keyed>
|
||||||
|
{(ogpProps) => (
|
||||||
|
<SafeLink href={props.href}>
|
||||||
|
<div class="my-2 rounded-lg border transition-colors hover:bg-slate-100">
|
||||||
|
<img
|
||||||
|
alt={ogpProps.title}
|
||||||
|
class="max-w-full rounded-t-lg object-contain shadow"
|
||||||
|
src={ogpProps.image}
|
||||||
|
/>
|
||||||
|
<div class="mb-1 p-1">
|
||||||
|
<div class="text-xs text-slate-500">{new URL(ogpProps.url).host}</div>
|
||||||
|
<div class="text-sm">{ogpProps.title}</div>
|
||||||
|
<div class="text-xs text-slate-500">{ogpProps.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SafeLink>
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PreviewedLink;
|
||||||
@@ -8,9 +8,9 @@ import EventDisplayById from '@/components/event/EventDisplayById';
|
|||||||
import ImageDisplay from '@/components/event/textNote/ImageDisplay';
|
import ImageDisplay from '@/components/event/textNote/ImageDisplay';
|
||||||
import MentionedEventDisplay from '@/components/event/textNote/MentionedEventDisplay';
|
import MentionedEventDisplay from '@/components/event/textNote/MentionedEventDisplay';
|
||||||
import MentionedUserDisplay from '@/components/event/textNote/MentionedUserDisplay';
|
import MentionedUserDisplay from '@/components/event/textNote/MentionedUserDisplay';
|
||||||
|
import PreviewedLink from '@/components/event/textNote/PreviewedLink';
|
||||||
import VideoDisplay from '@/components/event/textNote/VideoDisplay';
|
import VideoDisplay from '@/components/event/textNote/VideoDisplay';
|
||||||
import EventLink from '@/components/EventLink';
|
import EventLink from '@/components/EventLink';
|
||||||
import PreviewedLink from '@/components/utils/PreviewedLink';
|
|
||||||
import { createRelaysColumn, createSearchColumn } from '@/core/column';
|
import { createRelaysColumn, createSearchColumn } from '@/core/column';
|
||||||
import useConfig from '@/core/useConfig';
|
import useConfig from '@/core/useConfig';
|
||||||
import { useRequestCommand } from '@/hooks/useCommandBus';
|
import { useRequestCommand } from '@/hooks/useCommandBus';
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
import { Component, Show, JSX, createSignal, Switch, Match, createEffect, onMount } from 'solid-js';
|
|
||||||
|
|
||||||
import SafeLink from '@/components/utils/SafeLink';
|
|
||||||
import { isTwitterUrl, isYoutubeVideoUrl } from '@/utils/url';
|
|
||||||
|
|
||||||
type SafeLinkProps = {
|
|
||||||
class?: string;
|
|
||||||
href: string;
|
|
||||||
children?: JSX.Element;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OgpContents = {
|
|
||||||
url: URL | null;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
image: URL | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const twitterUrl = (urlString: string): string => {
|
|
||||||
try {
|
|
||||||
const url = new URL(urlString);
|
|
||||||
url.host = 'twitter.com';
|
|
||||||
return url.href;
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const youtubeUrl = (urlString: string): string => {
|
|
||||||
try {
|
|
||||||
const originalUrl = new URL(urlString);
|
|
||||||
const iframeUrl = new URL('https://www.youtube.com/embed');
|
|
||||||
if (originalUrl.host === 'youtu.be') {
|
|
||||||
iframeUrl.pathname += originalUrl.pathname;
|
|
||||||
} else {
|
|
||||||
iframeUrl.pathname += `/${originalUrl.searchParams.get('v')}`;
|
|
||||||
}
|
|
||||||
iframeUrl.searchParams.set('origin', window.location.origin);
|
|
||||||
return iframeUrl.href;
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchOgpContents = async (url: URL): Promise<OgpContents | null> => {
|
|
||||||
const whiteList = ['www3.nhk.or.jp'];
|
|
||||||
if (whiteList.includes(url.host)) {
|
|
||||||
const res = await fetch(url, { headers: { Accept: 'text/html' } });
|
|
||||||
const text = await res.text();
|
|
||||||
const el = new DOMParser().parseFromString(text, 'text/html');
|
|
||||||
const ogs: { [index: string]: string } = {};
|
|
||||||
Array.from(el.head.querySelectorAll('meta'))
|
|
||||||
.filter((v) => v.getAttribute('property') != null && v.getAttribute('content') != null)
|
|
||||||
.forEach((v) => {
|
|
||||||
const property = v.getAttribute('property');
|
|
||||||
const content = v.getAttribute('content');
|
|
||||||
if (property != null && content != null) {
|
|
||||||
ogs[property] = content;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ogs['og:image'] && ogs['og:title'] && ogs['og:description']) {
|
|
||||||
const contents: OgpContents = {
|
|
||||||
title: ogs['og:title'],
|
|
||||||
description: ogs['og:description'],
|
|
||||||
image: new URL(ogs['og:image']),
|
|
||||||
url,
|
|
||||||
};
|
|
||||||
return contents;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const PreviewedLink: Component<SafeLinkProps> = (props) => {
|
|
||||||
let twitterRef: HTMLQuoteElement | undefined;
|
|
||||||
|
|
||||||
const initialOgp: OgpContents = { title: '', description: '', image: null, url: null };
|
|
||||||
const [getOgpContents, setOgpContents] = createSignal(initialOgp);
|
|
||||||
|
|
||||||
const isSafe = () => {
|
|
||||||
try {
|
|
||||||
const url = new URL(props.href);
|
|
||||||
return url.protocol === 'https:' || url.protocol === 'http:';
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateOgpContents = async () => {
|
|
||||||
const ogp = await fetchOgpContents(new URL(props.href));
|
|
||||||
if (ogp != null) {
|
|
||||||
setOgpContents(ogp);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (isTwitterUrl(props.href)) {
|
|
||||||
window.twttr?.widgets?.load(twitterRef);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
updateOgpContents()
|
|
||||||
.then(() => {})
|
|
||||||
.catch(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show when={isSafe()} fallback={props.href}>
|
|
||||||
<Switch fallback={<SafeLink class={props.class} href={props.href} />}>
|
|
||||||
<Match when={isTwitterUrl(props.href)}>
|
|
||||||
<blockquote class="twitter-tweet" ref={twitterRef}>
|
|
||||||
<a
|
|
||||||
class={props.class}
|
|
||||||
href={twitterUrl(props.href)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
>
|
|
||||||
{twitterUrl(props.href)}
|
|
||||||
</a>
|
|
||||||
</blockquote>
|
|
||||||
</Match>
|
|
||||||
<Match when={isYoutubeVideoUrl(props.href)}>
|
|
||||||
<div class="my-2 aspect-video w-full">
|
|
||||||
<iframe title="YouTube" class="h-full w-full" src={youtubeUrl(props.href)} />
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={getOgpContents().url} keyed>
|
|
||||||
<a href={props.href} target="_blank" rel="noreferrer noopener">
|
|
||||||
<div class="rounded-lg border transition-colors hover:bg-slate-100">
|
|
||||||
<img
|
|
||||||
alt=""
|
|
||||||
class="max-w-full rounded-t-lg object-contain shadow"
|
|
||||||
src={getOgpContents().image?.href}
|
|
||||||
/>
|
|
||||||
<div class="mb-1 p-1">
|
|
||||||
<div class="text-xs text-slate-500">{getOgpContents().url?.host}</div>
|
|
||||||
<div class="text-sm">{getOgpContents().title}</div>
|
|
||||||
<div class="text-xs text-slate-500">{getOgpContents().description}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</Show>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PreviewedLink;
|
|
||||||
35
src/utils/ogp.test.ts
Normal file
35
src/utils/ogp.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import assert from 'assert';
|
||||||
|
|
||||||
|
import jsdom from 'jsdom';
|
||||||
|
import { describe, it } from 'vitest';
|
||||||
|
|
||||||
|
import { parseOgpFromDOM } from '@/utils/ogp';
|
||||||
|
|
||||||
|
describe('parseOgpFromDOM', () => {
|
||||||
|
it('should return correct ogp', () => {
|
||||||
|
const inputHtml = `
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta property="og:url" content="https://example.com/">
|
||||||
|
<meta property="og:title" content="Example">
|
||||||
|
<meta property="og:description" content="Description">
|
||||||
|
<meta property="og:image" content="http://syusui-s.github.io/rabbit/images/rabbit_app_256.png">
|
||||||
|
<title>Example</title>
|
||||||
|
</head>
|
||||||
|
<body></body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
const doc = new jsdom.JSDOM(inputHtml);
|
||||||
|
const actual = parseOgpFromDOM(doc.window.document);
|
||||||
|
const expected = {
|
||||||
|
url: 'https://example.com/',
|
||||||
|
title: 'Example',
|
||||||
|
description: 'Description',
|
||||||
|
image: 'http://syusui-s.github.io/rabbit/images/rabbit_app_256.png',
|
||||||
|
};
|
||||||
|
assert.deepStrictEqual(actual, expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
69
src/utils/ogp.ts
Normal file
69
src/utils/ogp.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { createQuery } from '@tanstack/solid-query';
|
||||||
|
|
||||||
|
export type OgpContent = {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
image: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseOgpFromDOM = (doc: HTMLDocument): OgpContent | null => {
|
||||||
|
const props: { [property: string]: string } = {};
|
||||||
|
|
||||||
|
Array.from(doc.head.querySelectorAll('meta')).forEach((m) => {
|
||||||
|
const property = m.getAttribute('property');
|
||||||
|
const content = m.getAttribute('content');
|
||||||
|
if (property != null && content != null) {
|
||||||
|
props[property] = content;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
props['og:image'] != null &&
|
||||||
|
props['og:title'] != null &&
|
||||||
|
props['og:description'] != null &&
|
||||||
|
props['og:url']
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
title: props['og:title'],
|
||||||
|
description: props['og:description'],
|
||||||
|
image: props['og:image'],
|
||||||
|
url: props['og:url'],
|
||||||
|
} satisfies OgpContent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseOgp = (text: string): OgpContent | null => {
|
||||||
|
const doc = new DOMParser().parseFromString(text, 'text/html');
|
||||||
|
return parseOgpFromDOM(doc);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchOgpContent = async (urlString: string): Promise<OgpContent | null> => {
|
||||||
|
const allowList = ['www3.nhk.or.jp'];
|
||||||
|
|
||||||
|
const url = new URL(urlString);
|
||||||
|
if (!allowList.includes(url.host)) return null;
|
||||||
|
|
||||||
|
const res = await fetch(url, { headers: { Accept: 'text/html' } });
|
||||||
|
const text = await res.text();
|
||||||
|
return parseOgp(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseOgpProps = {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useOgp = (propsProvider: () => UseOgpProps) => {
|
||||||
|
const genQueryKey = () => ['useOgp', propsProvider().url] as const;
|
||||||
|
const query = createQuery(genQueryKey, ({ queryKey: [, url] }) => fetchOgpContent(url), {
|
||||||
|
staleTime: 4 * 60 * 60 * 1000, // 4 hour
|
||||||
|
cacheTime: 4 * 60 * 60 * 1000, // 4 hour
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ogp = () => query.data;
|
||||||
|
|
||||||
|
return { query, ogp };
|
||||||
|
};
|
||||||
@@ -48,7 +48,7 @@ export const thumbnailUrl = (urlString: string): string => {
|
|||||||
if (url.host === 'i.gyazo.com') {
|
if (url.host === 'i.gyazo.com') {
|
||||||
const result = new URL(url);
|
const result = new URL(url);
|
||||||
result.host = 'thumb.gyazo.com';
|
result.host = 'thumb.gyazo.com';
|
||||||
result.pathname = `/thumb/640_w${url.pathname}`;
|
result.pathname = `/thumb/640${url.pathname}`;
|
||||||
return result.toString();
|
return result.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,17 +67,37 @@ export const isTwitterUrl = (urlString: string): boolean => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isYoutubeVideoUrl = (urlString: string): boolean => {
|
type YouTubeVideo = {
|
||||||
|
videoId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const YouTubeHosts = ['www.youtube.com', 'm.youtube.com', 'youtube.com'];
|
||||||
|
export const parseYouTubeVideoUrl = (urlString: string): YouTubeVideo | null => {
|
||||||
try {
|
try {
|
||||||
const url = new URL(urlString);
|
const url = new URL(urlString);
|
||||||
return (
|
if (url.protocol !== 'https:') return null;
|
||||||
(url.protocol === 'https:' &&
|
|
||||||
url.host === 'www.youtube.com' &&
|
if (YouTubeHosts.includes(url.host)) {
|
||||||
url.pathname === '/watch' &&
|
if (url.pathname === '/watch') {
|
||||||
url.searchParams.get('v') != null) ||
|
const videoId = url.searchParams.get('v');
|
||||||
(url.protocol === 'https:' && url.host === 'youtu.be' && url.pathname.lastIndexOf('/') === 0)
|
if (videoId != null) {
|
||||||
);
|
return { videoId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (url.pathname.startsWith('/shorts/')) {
|
||||||
|
const match = url.pathname.match(/^\/shorts\/([0-9a-zA-Z_-]*)$/);
|
||||||
|
if (match) {
|
||||||
|
return { videoId: match[1] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.host === 'youtu.be' && url.pathname.lastIndexOf('/') === 0) {
|
||||||
|
return { videoId: url.pathname };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user