From add4afcabca19deaef4bf2ae39f2a81824b90672 Mon Sep 17 00:00:00 2001 From: Birdgg Date: Fri, 19 May 2023 22:35:04 +0800 Subject: [PATCH] feat: more embed support (#560) --- package.json | 3 + pnpm-lock.yaml | 42 ++++++++++++- src/components/ui/GithubRepo.tsx | 5 ++ .../embed-transformers/CodeSandBox.ts | 23 +++++++ src/markdown/embed-transformers/Github.ts | 21 +++++++ .../embed-transformers/NetEaseMusic.ts | 57 +++++++++++++++++ src/markdown/embed-transformers/Tweet.ts | 19 ++++++ src/markdown/embed-transformers/Youtube.ts | 62 +++++++++++++++++++ src/markdown/embed-transformers/index.ts | 14 +++++ src/markdown/embed-transformers/utils.ts | 34 ++++++++++ src/markdown/index.ts | 10 ++- src/markdown/rehype-embed.ts | 41 ++++++++++++ src/markdown/rehype-tweet.ts | 31 ---------- 13 files changed, 326 insertions(+), 36 deletions(-) create mode 100644 src/components/ui/GithubRepo.tsx create mode 100644 src/markdown/embed-transformers/CodeSandBox.ts create mode 100644 src/markdown/embed-transformers/Github.ts create mode 100644 src/markdown/embed-transformers/NetEaseMusic.ts create mode 100644 src/markdown/embed-transformers/Tweet.ts create mode 100644 src/markdown/embed-transformers/Youtube.ts create mode 100644 src/markdown/embed-transformers/index.ts create mode 100644 src/markdown/embed-transformers/utils.ts create mode 100644 src/markdown/rehype-embed.ts delete mode 100644 src/markdown/rehype-tweet.ts diff --git a/package.json b/package.json index 31dd9b8d..a17d7096 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ ] }, "dependencies": { + "@birdgg/react-github": "0.2.2", "@codemirror/commands": "6.2.4", "@codemirror/lang-markdown": "6.1.1", "@codemirror/language": "6.6.0", @@ -73,6 +74,7 @@ "emoji-mart": "5.5.2", "fast-average-color": "^9.3.0", "fast-deep-equal": "3.1.3", + "hast-util-from-html": "^1.0.2", "hast-util-to-html": "^8.0.4", "hast-util-to-text": "^3.1.2", "i18next": "^22.4.15", @@ -96,6 +98,7 @@ "next": "13.4.2", "node-id3": "^0.2.6", "pangu": "^4.0.7", + "path-to-regexp": "^6.2.1", "pinyin-pro": "^3.14.0", "prismjs": "1.29.0", "qier-progress": "1.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc3edf9a..ae5811e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,6 +1,9 @@ lockfileVersion: '6.0' dependencies: + '@birdgg/react-github': + specifier: 0.2.2 + version: 0.2.2(react-dom@18.2.0)(react@18.2.0) '@codemirror/commands': specifier: 6.2.4 version: 6.2.4 @@ -136,6 +139,9 @@ dependencies: fast-deep-equal: specifier: 3.1.3 version: 3.1.3 + hast-util-from-html: + specifier: ^1.0.2 + version: 1.0.2 hast-util-to-html: specifier: ^8.0.4 version: 8.0.4 @@ -205,6 +211,9 @@ dependencies: pangu: specifier: ^4.0.7 version: 4.0.7 + path-to-regexp: + specifier: ^6.2.1 + version: 6.2.1 pinyin-pro: specifier: ^3.14.0 version: 3.14.0 @@ -1704,6 +1713,19 @@ packages: '@babel/helper-validator-identifier': 7.19.1 to-fast-properties: 2.0.0 + /@birdgg/react-github@0.2.2(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-mBiLK5str0duFrBCCzGaNAkBP9yuxIq04cnGUmNHpfuYLrPyt18o8d13Gt9zAFjKkLOTg4J4yWoKaQwCCI8DCQ==} + peerDependencies: + react: '>= 18.0.0' + react-dom: '>= 18.0.0' + dependencies: + '@primer/octicons-react': 19.1.0(react@18.2.0) + clsx: 1.2.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + swr: 2.1.5(react@18.2.0) + dev: false + /@braintree/sanitize-url@6.0.2: resolution: {integrity: sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==} dev: false @@ -3869,6 +3891,15 @@ packages: resolution: {integrity: sha512-Cr4OjIkipTtcXKjAsm8agyleBuDHvxzeBoa1v543lbv1YaIwQjESsVcmjiWiPEbC1FIeHOG/Op9kdCmAmiS3Kw==} dev: false + /@primer/octicons-react@19.1.0(react@18.2.0): + resolution: {integrity: sha512-owWS3jHcsMOQMRzXURSnbqkkQCgmNOZWmm/vejzwnPU21m8Wz1Xng5i0pu1B/VuW7cmsNh5+r5XsVM8r1igY6A==} + engines: {node: '>=8'} + peerDependencies: + react: '>=16.3' + dependencies: + react: 18.2.0 + dev: false + /@prisma/client@4.14.0(prisma@4.14.0): resolution: {integrity: sha512-MK/XaA2sFdfaOa7I9MjNKz6dxeIEdeZlnpNRoF2w3JuRLlFJLkpp6cD3yaqw2nUUhbrn3Iqe3ZpVV+VuGGil7Q==} engines: {node: '>=14.17'} @@ -8417,17 +8448,18 @@ packages: dependencies: '@types/hast': 2.3.4 hast-util-from-dom: 4.2.0 - hast-util-from-html: 1.0.1 + hast-util-from-html: 1.0.2 unist-util-remove-position: 4.0.2 dev: false - /hast-util-from-html@1.0.1: - resolution: {integrity: sha512-ehTy+4Lz1YAVF6enEuL9QFUHqJKRxAc8a7KACyhawY+YqTG5pLkrBHfykXELEy75N601fHDr36HIqCGSNxmgZw==} + /hast-util-from-html@1.0.2: + resolution: {integrity: sha512-LhrTA2gfCbLOGJq2u/asp4kwuG0y6NhWTXiPKP+n0qNukKy7hc10whqqCFfyvIA1Q5U5d0sp9HhNim9gglEH4A==} dependencies: '@types/hast': 2.3.4 hast-util-from-parse5: 7.1.2 parse5: 7.1.2 vfile: 5.3.7 + vfile-message: 3.1.4 dev: false /hast-util-from-parse5@7.1.2: @@ -10839,6 +10871,10 @@ packages: /path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + /path-to-regexp@6.2.1: + resolution: {integrity: sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==} + dev: false + /path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} diff --git a/src/components/ui/GithubRepo.tsx b/src/components/ui/GithubRepo.tsx new file mode 100644 index 00000000..399d72fc --- /dev/null +++ b/src/components/ui/GithubRepo.tsx @@ -0,0 +1,5 @@ +import { GithubRepo as ReactGithubRepo } from "@birdgg/react-github" + +export default function GithubRepo({ repo }: { repo: string }) { + return +} diff --git a/src/markdown/embed-transformers/CodeSandBox.ts b/src/markdown/embed-transformers/CodeSandBox.ts new file mode 100644 index 00000000..e01d1551 --- /dev/null +++ b/src/markdown/embed-transformers/CodeSandBox.ts @@ -0,0 +1,23 @@ +import type { Transformer } from "../rehype-embed" +import { generateIframeHTML, isHostIncludes } from "./utils" + +export const CodeSandboxTransformer: Transformer = { + name: "CodeSandbox", + shouldTransform(url) { + const { host, pathname } = url + + return isHostIncludes("codesandbox.io", host) && pathname.includes("/s/") + }, + getHTML(url) { + return generateIframeHTML({ + name: "codesandbox", + src: url.toString().replace("/s/", "/embed/"), + height: "500px", + width: "100%", + allow: + "accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking", + sandbox: + "allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts", + }) + }, +} diff --git a/src/markdown/embed-transformers/Github.ts b/src/markdown/embed-transformers/Github.ts new file mode 100644 index 00000000..5136c2ec --- /dev/null +++ b/src/markdown/embed-transformers/Github.ts @@ -0,0 +1,21 @@ +import { match } from "path-to-regexp" + +import type { Transformer } from "../rehype-embed" +import { isHostIncludes } from "./utils" + +export const GithubRepoTransformer: Transformer = { + name: "GithubRepo", + shouldTransform(url) { + const { host } = url + + return isHostIncludes("github.com", host) + }, + getHTML(url) { + const matched = match<{ user: string; repo: string }>("/:user/:repo")( + url.pathname, + ) + if (!matched) return + const repo = `${matched.params.user}/${matched.params.repo}` + return `` + }, +} diff --git a/src/markdown/embed-transformers/NetEaseMusic.ts b/src/markdown/embed-transformers/NetEaseMusic.ts new file mode 100644 index 00000000..ecd920f2 --- /dev/null +++ b/src/markdown/embed-transformers/NetEaseMusic.ts @@ -0,0 +1,57 @@ +import type { Transformer } from "../rehype-embed" +import { generateIframeHTML, includesSomeOfArray } from "./utils" + +const parseLink = (url: string) => { + const rules = [ + ["music.163.com.*playlist", "0"], + ["music.163.com.*album", "1"], + ["music.163.com.*song", "2"], + ] + for (let rule of rules) { + let patt = new RegExp(rule[0]) + let res = patt.exec(url) + if (res !== null) { + return { + type: rule[1], + } + } + } + return {} +} + +const getIFrame = (url: URL) => { + const { pathname, searchParams } = url + const isSong = pathname.includes("/song") + const id = searchParams.get("id") + const { type } = parseLink(url.toString()) + + const height = isSong ? 66 : 250 + + return { + src: `https://music.163.com/outchain/player?type=${type}&id=${id}&auto=0&height=${height}`, + height: isSong ? 86 : 250, + } +} + +export const NetEaseMusicTransformer: Transformer = { + name: "NetEaseMusic", + shouldTransform(url) { + const { host, pathname } = url + + return ( + host === "music.163.com" && + includesSomeOfArray(pathname, ["/song", "/playlist", "/album"]) + ) + }, + getHTML(url) { + const iframe = getIFrame(url) + return generateIframeHTML({ + name: "netease", + src: iframe.src, + height: iframe.height + "px", + width: "330px", + allow: + "accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture", + }) + }, +} diff --git a/src/markdown/embed-transformers/Tweet.ts b/src/markdown/embed-transformers/Tweet.ts new file mode 100644 index 00000000..28977066 --- /dev/null +++ b/src/markdown/embed-transformers/Tweet.ts @@ -0,0 +1,19 @@ +import { match } from "path-to-regexp" + +import type { Transformer } from "../rehype-embed" +import { isHostIncludes } from "./utils" + +export const TweetTransformer: Transformer = { + name: "Tweet", + shouldTransform(url) { + const { host, pathname } = url + + return isHostIncludes("twitter.com", host) && pathname.includes("/status/") + }, + getHTML(url) { + const { pathname } = url + const matched = match<{ id: string }>("/:user/status/:id")(pathname) + if (!matched) return + return `` + }, +} diff --git a/src/markdown/embed-transformers/Youtube.ts b/src/markdown/embed-transformers/Youtube.ts new file mode 100644 index 00000000..7827ea83 --- /dev/null +++ b/src/markdown/embed-transformers/Youtube.ts @@ -0,0 +1,62 @@ +import type { Transformer } from "../rehype-embed" +import { generateIframeHTML, isHostIncludes } from "./utils" + +const getTimeValueInSeconds = (timeValue: string) => { + if (Number(timeValue).toString() === timeValue) { + return timeValue + } + + const { + 2: hours = "0", + 4: minutes = "0", + 6: seconds = "0", + } = timeValue.match(/((\d*)h)?((\d*)m)?((\d*)s)?/)! + + return String((Number(hours) * 60 + Number(minutes)) * 60 + Number(seconds)) +} + +const getYouTubeIFrameSrc = (url: URL) => { + const id = + url.host === "youtu.be" ? url.pathname.slice(1) : url.searchParams.get("v") + + const embedUrl = new URL(`https://www.youtube-nocookie.com/embed/${id}?rel=0`) + + url.searchParams.forEach((value, name) => { + if (name === "v") { + return + } + + if (name === "t") { + embedUrl.searchParams.append("start", getTimeValueInSeconds(value)) + } else { + embedUrl.searchParams.append(name, value) + } + }) + + return embedUrl.toString() +} + +export const YoutubeTransformer: Transformer = { + name: "Youtube", + shouldTransform(url) { + const { host, pathname, searchParams } = url + + return ( + host === "youtu.be" || + (isHostIncludes("youtube.com", host) && + pathname.includes("/watch") && + Boolean(searchParams.get("v"))) + ) + }, + getHTML(url) { + return generateIframeHTML({ + name: "youtube", + src: getYouTubeIFrameSrc(url), + width: "100%", + height: "410px", + allow: + "accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture", + allowFullScreen: true, + }) + }, +} diff --git a/src/markdown/embed-transformers/index.ts b/src/markdown/embed-transformers/index.ts new file mode 100644 index 00000000..9ab47b84 --- /dev/null +++ b/src/markdown/embed-transformers/index.ts @@ -0,0 +1,14 @@ +import type { Transformer } from "../rehype-embed" +import { CodeSandboxTransformer } from "./CodeSandBox" +import { GithubRepoTransformer } from "./Github" +import { NetEaseMusicTransformer } from "./NetEaseMusic" +import { TweetTransformer } from "./Tweet" +import { YoutubeTransformer } from "./Youtube" + +export const transformers: Transformer[] = [ + CodeSandboxTransformer, + TweetTransformer, + GithubRepoTransformer, + YoutubeTransformer, + NetEaseMusicTransformer, +] diff --git a/src/markdown/embed-transformers/utils.ts b/src/markdown/embed-transformers/utils.ts new file mode 100644 index 00000000..b9ee5dc4 --- /dev/null +++ b/src/markdown/embed-transformers/utils.ts @@ -0,0 +1,34 @@ +import { IframeHTMLAttributes } from "react" + +interface IframeProps extends IframeHTMLAttributes { + name: string +} + +export const isHostIncludes = (domain: string, host: string) => { + return [domain, `www.${domain}`].includes(host) +} + +export const includesSomeOfArray = (string: string, array: string[]) => + array.some((item) => string.includes(item)) + +export const generateIframeHTML = ({ + name, + src, + height, + width, + style, + ...attributes +}: IframeProps) => { + const iframeStyle = + 'style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"' + const iframeAttributes = Object.entries(attributes) + .map(([key, value]) => { + if (key === "allowFullScreen") return key.toLowerCase() + return `${key.toLowerCase()}="${value}"` + }) + .join(" ") + + return `
+ +
` +} diff --git a/src/markdown/index.ts b/src/markdown/index.ts index 9b0efe97..d60284a7 100644 --- a/src/markdown/index.ts +++ b/src/markdown/index.ts @@ -31,15 +31,16 @@ import { unified } from "unified" import { ZoomedImage } from "~/components/ui/Image" +import { transformers } from "./embed-transformers" import { rehypeAudio } from "./rehype-audio" import { allowedCustomWrappers, defaultRules, rehypeCustomWrapper, } from "./rehype-custom-wrapper" +import { rehypeEmbed } from "./rehype-embed" import { rehypeImage } from "./rehype-image" import { rehypeTable } from "./rehype-table" -import { rehypeTweet } from "./rehype-tweet" import { rehypeVideo } from "./rehype-video" import { rehypeWrapCode } from "./rehype-wrap-code" import { rehypeExternalLink } from "./rehyper-external-link" @@ -53,7 +54,9 @@ const Style = dynamic(() => import("~/components/common/Style")) const Mention = dynamic(() => import("~/components/ui/Mention")) const Mermaid = dynamic(() => import("~/components/ui/Mermaid")) const Tweet = dynamic(() => import("~/components/ui/Tweet")) +const GithubRepo = dynamic(() => import("~/components/ui/GithubRepo")) const APlayer = dynamic(() => import("~/components/ui/APlayer")) + const DPlayer = dynamic(() => import("~/components/ui/DPlayer"), { ssr: false, }) @@ -176,7 +179,9 @@ export const renderPageContent = ( .use(rehypeExternalLink) .use(rehypeWrapCode) .use(rehypeInferDescriptionMeta) - .use(rehypeTweet) + .use(rehypeEmbed, { + transformers, + }) .use(rehypeRewrite, { selector: "p, li", rewrite: (node: any) => { @@ -227,6 +232,7 @@ export const renderPageContent = ( audio: APlayer, video: DPlayer, tweet: Tweet, + "github-repo": GithubRepo, style: Style, } as any, }) diff --git a/src/markdown/rehype-embed.ts b/src/markdown/rehype-embed.ts new file mode 100644 index 00000000..e525cacd --- /dev/null +++ b/src/markdown/rehype-embed.ts @@ -0,0 +1,41 @@ +import { fromHtml } from "hast-util-from-html" +import { Root } from "rehype-raw" +import { Plugin } from "unified" +import { visit } from "unist-util-visit" + +export interface Transformer { + name: string + shouldTransform: (url: URL) => boolean + getHTML: (url: URL) => string | undefined +} + +export const rehypeEmbed: Plugin< + Array<{ transformers: Transformer[] }>, + Root +> = + ({ transformers }) => + (tree) => { + visit(tree, { tagName: "a" }, (node, i, parent) => { + if ( + !parent || + !("tagName" in parent) || + parent.tagName !== "p" || + parent.children.length > 1 + ) + return + + const href = node.properties?.href + + if (typeof href === "string") { + const url = new URL(href) + for (const transformer of transformers) { + if (transformer.shouldTransform(url)) { + const html = transformer.getHTML(url)?.trim() + if (!html) return + const hast = fromHtml(html, { fragment: true }).children[0] + Object.assign(parent, hast) + } + } + } + }) + } diff --git a/src/markdown/rehype-tweet.ts b/src/markdown/rehype-tweet.ts deleted file mode 100644 index a668342f..00000000 --- a/src/markdown/rehype-tweet.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Root } from "rehype-raw" -import { Plugin } from "unified" -import { visit } from "unist-util-visit" - -export const rehypeTweet: Plugin, Root> = () => (tree) => { - visit(tree, { tagName: "a" }, (node, i, parent) => { - if ( - !parent || - !("tagName" in parent) || - parent.tagName !== "p" || - parent.children.length > 1 - ) - return - - const tweetRegex = - /^https?:\/\/(?:www\.)?twitter\.com\/\w+\/status\/(\d+)(?:\?.*)?(?:#.*)?$/ - - const href = node.properties?.href - - if (typeof href === "string") { - const match = href.match(tweetRegex) - if (match && match[0] === href) { - const tweetId = match[1] - parent.tagName = "tweet" - parent.properties = { - id: tweetId, - } - } - } - }) -}