feat: more embed support (#560)

This commit is contained in:
Birdgg 2023-05-19 22:35:04 +08:00 committed by GitHub
parent f903699842
commit add4afcabc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 326 additions and 36 deletions

View File

@ -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",

View File

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

View File

@ -0,0 +1,5 @@
import { GithubRepo as ReactGithubRepo } from "@birdgg/react-github"
export default function GithubRepo({ repo }: { repo: string }) {
return <ReactGithubRepo repo={repo} />
}

View File

@ -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",
})
},
}

View File

@ -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 `<github-repo repo="${repo}"/>`
},
}

View File

@ -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",
})
},
}

View File

@ -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 `<tweet id=${matched.params.id}/>`
},
}

View File

@ -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,
})
},
}

View File

@ -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,
]

View File

@ -0,0 +1,34 @@
import { IframeHTMLAttributes } from "react"
interface IframeProps extends IframeHTMLAttributes<HTMLIFrameElement> {
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 `<div class="xlog-post-content-${name}" style="position: relative; width: ${width}; height: ${height}; margin: 1rem auto;">
<iframe src="${src}" border="0" ${iframeStyle} ${iframeAttributes}></iframe>
</div>`
}

View File

@ -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,
})

View File

@ -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)
}
}
}
})
}

View File

@ -1,31 +0,0 @@
import { Root } from "rehype-raw"
import { Plugin } from "unified"
import { visit } from "unist-util-visit"
export const rehypeTweet: Plugin<Array<void>, 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,
}
}
}
})
}