Skip to content

Commit 9d5f45e

Browse files
author
Burak Bayır
authored
feat(client): support post URLs in Tweet (#2710)
1 parent 72cafb7 commit 9d5f45e

5 files changed

Lines changed: 92 additions & 4 deletions

File tree

docs/builtin/components.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,15 +270,19 @@ Embed a tweet.
270270

271271
```md
272272
<Tweet id="20" />
273+
<Tweet url="https://x.com/antfu7/status/1389604687502995457" />
273274
```
274275

275276
Props:
276277

277-
- `id` (`number | string`, required): id of the tweet
278+
- `id` (`number | string`): id of the tweet
279+
- `url` (`string`): full `x.com` or `twitter.com` post URL
278280
- `scale` (`number | string`, default `1`): transform scale value
279281
- `conversation` (`string`, default `'none'`): [tweet embed parameter](https://developer.twitter.com/en/docs/twitter-for-websites/embedded-tweets/guides/embedded-tweet-parameter-reference)
280282
- `cards` (`'hidden' | 'visible'`, default `'visible'`): [tweet embed parameter](https://developer.twitter.com/en/docs/twitter-for-websites/embedded-tweets/guides/embedded-tweet-parameter-reference)
281283

284+
Provide either `id` or `url`. When both are present, `id` takes precedence.
285+
282286
## `BlueSky`
283287

284288
Embed a Bluesky post.

packages/client/builtin/Tweet.vue

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@ A simple wrapper for embedded Tweet
44
Usage:
55
66
<Tweet id="20" />
7+
<Tweet url="https://x.com/jack/status/20" />
78
-->
89

910
<script setup lang="ts">
1011
import { onMounted, ref } from 'vue'
1112
import { isDark } from '../logic/dark'
13+
import { resolveTweetId } from './tweet'
1214
1315
const props = defineProps<{
14-
id: string | number
16+
id?: string | number
17+
url?: string
1518
scale?: string | number
1619
conversation?: string
1720
cards?: 'hidden' | 'visible'
@@ -23,6 +26,14 @@ const loaded = ref(false)
2326
const tweetNotFound = ref(false)
2427
2528
async function create(retries = 10) {
29+
const tweetId = resolveTweetId(props.id, props.url)
30+
if (!tweetId) {
31+
loaded.value = true
32+
tweetNotFound.value = true
33+
console.error('Tweet requires a valid id or X post URL.')
34+
return
35+
}
36+
2637
// @ts-expect-error global
2738
if (!window.twttr?.widgets?.createTweet) {
2839
if (retries <= 0)
@@ -32,7 +43,7 @@ async function create(retries = 10) {
3243
}
3344
// @ts-expect-error global
3445
const element = await window.twttr.widgets.createTweet(
35-
props.id.toString(),
46+
tweetId,
3647
tweet.value,
3748
{
3849
theme: isDark.value ? 'dark' : 'light',
@@ -56,7 +67,7 @@ onMounted(() => {
5667
<div v-if="!loaded || tweetNotFound" class="w-30 h-30 my-10px bg-gray-400 bg-opacity-10 rounded-lg flex opacity-50">
5768
<div class="m-auto animate-pulse text-4xl">
5869
<div class="i-carbon:logo-twitter" />
59-
<span v-if="tweetNotFound">Could not load tweet with id="{{ props.id }}"</span>
70+
<span v-if="tweetNotFound">Could not load tweet</span>
6071
</div>
6172
</div>
6273
</div>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { resolveTweetId } from './tweet'
3+
4+
describe('resolveTweetId', () => {
5+
it('keeps the existing id input', () => {
6+
expect(resolveTweetId(123, undefined)).toBe('123')
7+
expect(resolveTweetId(' 456 ', undefined)).toBe('456')
8+
})
9+
10+
it('prefers id when both inputs are present', () => {
11+
expect(resolveTweetId('123', 'https://x.com/slidevjs/status/456')).toBe('123')
12+
})
13+
14+
it.each([
15+
'https://x.com/slidevjs/status/123',
16+
'https://www.x.com/slidevjs/status/123?s=20',
17+
'https://twitter.com/slidevjs/status/123',
18+
'https://mobile.twitter.com/slidevjs/status/123/photo/1',
19+
'https://x.com/i/web/status/123',
20+
])('extracts an id from %s', (url) => {
21+
expect(resolveTweetId(undefined, url)).toBe('123')
22+
})
23+
24+
it.each([
25+
'https://example.com/slidevjs/status/123',
26+
'https://x.com.example.com/slidevjs/status/123',
27+
'https://x.com/slidevjs/status/not-a-number',
28+
'javascript:alert(1)',
29+
'not a URL',
30+
])('rejects an unsupported source URL: %s', (url) => {
31+
expect(resolveTweetId(undefined, url)).toBeUndefined()
32+
})
33+
})

packages/client/builtin/tweet.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
const TWEET_HOSTS = new Set([
2+
'mobile.twitter.com',
3+
'mobile.x.com',
4+
'twitter.com',
5+
'www.twitter.com',
6+
'www.x.com',
7+
'x.com',
8+
])
9+
10+
export function resolveTweetId(
11+
id: string | number | undefined,
12+
sourceUrl: string | undefined,
13+
): string | undefined {
14+
if (typeof id === 'number')
15+
return id.toString()
16+
17+
if (id?.trim())
18+
return id.trim()
19+
20+
if (!sourceUrl)
21+
return
22+
23+
try {
24+
const url = new URL(sourceUrl)
25+
if (!['http:', 'https:'].includes(url.protocol) || !TWEET_HOSTS.has(url.hostname))
26+
return
27+
28+
const segments = url.pathname.split('/').filter(Boolean)
29+
const statusIndex = segments.lastIndexOf('status')
30+
const tweetId = segments[statusIndex + 1]
31+
if (statusIndex < 1 || !tweetId || !/^\d+$/.test(tweetId))
32+
return
33+
34+
return tweetId
35+
}
36+
catch {}
37+
}

skills/slidev/references/core-components.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,11 @@ Props: `controls`, `autoplay`, `autoreset`, `poster`, `timestamp`
136136
```md
137137
<Tweet id="1423789844234231808" />
138138
<Tweet id="1423789844234231808" :scale="0.8" />
139+
<Tweet url="https://x.com/antfu7/status/1389604687502995457" />
139140
```
140141

142+
Use either `id` or a full `x.com` or `twitter.com` post URL. When both are present, `id` takes precedence.
143+
141144
## Conditional
142145

143146
### LightOrDark

0 commit comments

Comments
 (0)