feat: more embed support (#560)
This commit is contained in:
parent
f903699842
commit
add4afcabc
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
import { GithubRepo as ReactGithubRepo } from "@birdgg/react-github"
|
||||
|
||||
export default function GithubRepo({ repo }: { repo: string }) {
|
||||
return <ReactGithubRepo repo={repo} />
|
||||
}
|
||||
|
|
@ -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",
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
@ -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}"/>`
|
||||
},
|
||||
}
|
||||
|
|
@ -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",
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
@ -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}/>`
|
||||
},
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
@ -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,
|
||||
]
|
||||
|
|
@ -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>`
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue