diff --git a/next.config.js b/next.config.js
index 5a9aa8b3..83e120a4 100644
--- a/next.config.js
+++ b/next.config.js
@@ -92,7 +92,11 @@ module.exports = withBundleAnalyzer(
dangerouslyAllowSVG: true,
contentSecurityPolicy:
"default-src 'self'; script-src 'none'; sandbox; style-src 'unsafe-inline';",
- remotePatterns: [{ hostname: "**" }],
+ remotePatterns: [
+ { hostname: "**" },
+ { protocol: "https", hostname: "pbs.twimg.com" },
+ { protocol: "https", hostname: "abs.twimg.com" },
+ ],
},
async generateBuildId() {
diff --git a/package.json b/package.json
index 201dd2e8..5fdf10f1 100644
--- a/package.json
+++ b/package.json
@@ -105,6 +105,7 @@
"react-popper": "^2.3.0",
"react-sortablejs": "6.1.4",
"react-string-replace": "^1.1.0",
+ "react-tweet": "^2.0.1",
"react-virtuoso": "4.3.6",
"refractor": "4.8.1",
"rehype-autolink-headings": "6.1.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e6538063..5e609351 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -232,6 +232,9 @@ dependencies:
react-string-replace:
specifier: ^1.1.0
version: 1.1.0
+ react-tweet:
+ specifier: ^2.0.1
+ version: 2.0.1(react-dom@18.2.0)(react@18.2.0)
react-virtuoso:
specifier: 4.3.6
version: 4.3.6(react-dom@18.2.0)(react@18.2.0)
@@ -4322,6 +4325,12 @@ packages:
string.prototype.matchall: 4.0.8
dev: true
+ /@swc/helpers@0.4.14:
+ resolution: {integrity: sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw==}
+ dependencies:
+ tslib: 2.5.0
+ dev: false
+
/@swc/helpers@0.5.1:
resolution: {integrity: sha512-sJ902EfIzn1Fa+qYmjdQqh8tPsoxyBz+8yBKC2HKUxyezKJFwPGOn7pv4WY6QuQW//ySQi5lJjA/ZT9sNWWNTg==}
dependencies:
@@ -6909,6 +6918,13 @@ packages:
whatwg-url: 12.0.1
dev: false
+ /date-fns@2.30.0:
+ resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
+ engines: {node: '>=0.11'}
+ dependencies:
+ '@babel/runtime': 7.21.5
+ dev: false
+
/dayjs@1.11.7:
resolution: {integrity: sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==}
dev: false
@@ -11605,6 +11621,20 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
+ /react-tweet@2.0.1(react-dom@18.2.0)(react@18.2.0):
+ resolution: {integrity: sha512-r8Yrv+cW8ajpCcT2AJCIji9crbbVxiaYSagqU4IW4Btsm3b3Ui65pFOV9gZmnt2xorn8L98kqj8REEuGxwnaeA==}
+ peerDependencies:
+ react: '>= 18.0.0'
+ react-dom: '>= 18.0.0'
+ dependencies:
+ '@swc/helpers': 0.4.14
+ clsx: 1.2.1
+ date-fns: 2.30.0
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ swr: 2.1.5(react@18.2.0)
+ dev: false
+
/react-virtuoso@4.3.6(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-t2yzA9QKwMrcECLytVfZcBZMusuQ9Ahgt62SL8uC/sYm9Sk/h14sYTJC0MVO6CAtl3BzKiLKSC79Vp+W3KOXMg==}
engines: {node: '>=10'}
@@ -12673,6 +12703,15 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
+ /swr@2.1.5(react@18.2.0):
+ resolution: {integrity: sha512-/OhfZMcEpuz77KavXST5q6XE9nrOBOVcBLWjMT+oAE/kQHyE3PASrevXCtQDZ8aamntOfFkbVJp7Il9tNBQWrw==}
+ peerDependencies:
+ react: ^16.11.0 || ^17.0.0 || ^18.0.0
+ dependencies:
+ react: 18.2.0
+ use-sync-external-store: 1.2.0(react@18.2.0)
+ dev: false
+
/symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
dev: false
diff --git a/src/components/ui/Tweet.tsx b/src/components/ui/Tweet.tsx
new file mode 100644
index 00000000..3c7e703e
--- /dev/null
+++ b/src/components/ui/Tweet.tsx
@@ -0,0 +1,12 @@
+import Image from "next/image"
+import { Tweet as ReactTweet } from "react-tweet"
+import type { TweetComponents } from "react-tweet"
+
+const components: TweetComponents = {
+ AvatarImg: (props) => ,
+ MediaImg: (props) => ,
+}
+
+export function Tweet({ id }: { id: string }) {
+ return
+}
diff --git a/src/markdown/index.ts b/src/markdown/index.ts
index 1b898923..e4d9cb1c 100644
--- a/src/markdown/index.ts
+++ b/src/markdown/index.ts
@@ -32,6 +32,7 @@ import { APlayer } from "~/components/ui/APlayer"
import { ZoomedImage } from "~/components/ui/Image"
import { Mention } from "~/components/ui/Mention"
import { Mermaid } from "~/components/ui/Mermaid"
+import { Tweet } from "~/components/ui/Tweet"
import { rehypeAudio } from "./rehype-audio"
import {
@@ -41,6 +42,7 @@ import {
} from "./rehype-custom-wrapper"
import { rehypeImage } from "./rehype-image"
import { rehypeTable } from "./rehype-table"
+import { rehypeTweet } from "./rehype-tweet"
import { rehypeWrapCode } from "./rehype-wrap-code"
import { rehypeExternalLink } from "./rehyper-external-link"
import { remarkCallout } from "./remark-callout"
@@ -165,6 +167,7 @@ export const renderPageContent = (
.use(rehypeExternalLink)
.use(rehypeWrapCode)
.use(rehypeInferDescriptionMeta)
+ .use(rehypeTweet)
.use(rehypeRewrite, {
selector: "p, li",
rewrite: (node: any) => {
@@ -213,6 +216,7 @@ export const renderPageContent = (
mention: Mention,
mermaid: Mermaid,
audio: APlayer,
+ tweet: Tweet,
style: Style,
} as any,
})
diff --git a/src/markdown/rehype-tweet.ts b/src/markdown/rehype-tweet.ts
new file mode 100644
index 00000000..6cbe30d0
--- /dev/null
+++ b/src/markdown/rehype-tweet.ts
@@ -0,0 +1,31 @@
+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]
+ node.tagName = "tweet"
+ node.properties = {
+ id: tweetId,
+ }
+ }
+ }
+ })
+}