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