Skip to content

Commit e3788d9

Browse files
committed
Refactor WebMention component to server folder pattern, add unit test
1 parent 22402e6 commit e3788d9

10 files changed

Lines changed: 256 additions & 64 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export interface WebmentionAuthor {
2+
url?: string
3+
name?: string
4+
photo?: string
5+
}
6+
7+
export interface WebmentionContent {
8+
html?: string
9+
text?: string
10+
value?: string
11+
}
12+
13+
export interface Webmention {
14+
'wm-id': string
15+
'wm-target': string
16+
'wm-property': string
17+
'wm-source'?: string
18+
published: string
19+
author?: WebmentionAuthor
20+
content?: WebmentionContent
21+
url?: string
22+
}
23+
24+
export interface WebmentionResponse {
25+
type: string
26+
name: string
27+
children: Webmention[]
28+
}

src/components/WebMentions/WebMentions.astro renamed to src/components/WebMentions/index.astro

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @see https://brid.gy/ for social media integration
88
*/
99
import { fetchWebmentions } from '@components/WebMentions/server'
10-
import WebMentionItem from '@components/WebMentions/WebMentionItem.astro'
10+
import WebMentionItem from './item.astro'
1111
1212
interface Props {
1313
/** The absolute URL of the page to fetch webmentions for */
@@ -38,19 +38,19 @@ const hasInteractions = likes.length > 0 || reposts.length > 0
3838

3939
{
4040
(hasMentions || hasInteractions) && (
41-
<section class="mt-12 pt-8 border-t border-[var(--color-border)]" id="webmentions">
41+
<section class="mt-12 pt-8 border-t border-border" id="webmentions">
4242
<header class="flex items-start justify-between mb-6 flex-wrap gap-4 flex-col sm:flex-row sm:items-center">
43-
<h3 class="text-2xl font-bold text-[var(--color-text)] m-0">Webmentions</h3>
43+
<h3 class="text-2xl font-bold text-text m-0">Webmentions</h3>
4444

4545
{hasInteractions && (
4646
<div class="flex gap-6 items-center w-full sm:w-auto justify-start">
4747
{likes.length > 0 && (
4848
<span
49-
class="inline-flex items-center gap-1.5 text-[var(--color-text-offset)] text-sm font-medium"
49+
class="inline-flex items-center gap-1.5 text-text-offset text-sm font-medium"
5050
title={`${likes.length} like${likes.length !== 1 ? 's' : ''}`}
5151
>
5252
<svg
53-
class="w-5 h-5 text-[var(--color-text-lighter)]"
53+
class="w-5 h-5 text-text-lighter"
5454
xmlns="http://www.w3.org/2000/svg"
5555
viewBox="0 0 24 24"
5656
fill="currentColor"
@@ -63,11 +63,11 @@ const hasInteractions = likes.length > 0 || reposts.length > 0
6363
)}
6464
{reposts.length > 0 && (
6565
<span
66-
class="inline-flex items-center gap-1.5 text-[var(--color-text-offset)] text-sm font-medium"
66+
class="inline-flex items-center gap-1.5 text-text-offset text-sm font-medium"
6767
title={`${reposts.length} repost${reposts.length !== 1 ? 's' : ''}`}
6868
>
6969
<svg
70-
class="w-5 h-5 text-[var(--color-text-lighter)]"
70+
class="w-5 h-5 text-text-lighter"
7171
xmlns="http://www.w3.org/2000/svg"
7272
viewBox="0 0 24 24"
7373
fill="currentColor"
@@ -85,7 +85,7 @@ const hasInteractions = likes.length > 0 || reposts.length > 0
8585
{hasMentions ? (
8686
<>
8787
{showFacepile && displayMentions.length > 0 && (
88-
<div class="flex items-center gap-2 mb-6 p-4 bg-[var(--color-bg-offset)] rounded-lg">
88+
<div class="flex items-center gap-2 mb-6 p-4 bg-bg-offset rounded-lg">
8989
{displayMentions
9090
.slice(0, facepileLimit)
9191
.reverse()
@@ -94,12 +94,12 @@ const hasInteractions = likes.length > 0 || reposts.length > 0
9494
src={mention.author?.photo || '/assets/images/webmention-avatar-default.svg'}
9595
alt={mention.author?.name || 'Anonymous'}
9696
title={mention.author?.name || 'Anonymous'}
97-
class="w-10 h-10 rounded-full border-2 border-[var(--color-bg)] object-cover transition-transform hover:scale-110 hover:z-10"
97+
class="w-10 h-10 rounded-full border-2 border-bg object-cover transition-transform hover:scale-110 hover:z-10"
9898
loading="lazy"
9999
/>
100100
))}
101101
{displayMentions.length > facepileLimit && (
102-
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-[var(--color-text-lighter)] text-[var(--color-bg)] text-xs font-semibold">
102+
<span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-text-lighter text-bg text-xs font-semibold">
103103
+{displayMentions.length - facepileLimit}
104104
</span>
105105
)}
@@ -115,7 +115,7 @@ const hasInteractions = likes.length > 0 || reposts.length > 0
115115
</ol>
116116
</>
117117
) : (
118-
<p class="text-[var(--color-text-offset)] italic my-8">No mentions yet.</p>
118+
<p class="text-text-offset italic my-8">No mentions yet.</p>
119119
)}
120120
</section>
121121
)

‎src/components/WebMentions/index.ts‎

Lines changed: 0 additions & 14 deletions
This file was deleted.

src/components/WebMentions/WebMentionItem.astro renamed to src/components/WebMentions/item.astro

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
/**
33
* Individual WebMention item display
44
*/
5-
import type { Webmention } from '@components/WebMentions/server'
5+
import type { Webmention } from '@components/WebMentions/@types'
66
77
interface Props {
88
mention: Webmention
@@ -24,7 +24,7 @@ const formatDate = (dateString: string) => {
2424
---
2525

2626
<article
27-
class="p-4 bg-[var(--color-bg-offset)] rounded-lg border border-[var(--color-border)] h-cite"
27+
class="p-4 bg-bg-offset rounded-lg border border-border h-cite"
2828
id={`webmention-${mention['wm-id']}`}
2929
>
3030
<div
@@ -33,13 +33,13 @@ const formatDate = (dateString: string) => {
3333
{
3434
mention.author ? (
3535
<a
36-
class="inline-flex items-center gap-3 no-underline text-[var(--color-text)] transition-colors hover:text-[var(--color-primary-hover)] p-author h-card u-url"
36+
class="inline-flex items-center gap-3 no-underline text-text transition-colors hover:text-primary-hover p-author h-card u-url"
3737
href={mention.author.url || mention['wm-source']}
3838
target="_blank"
3939
rel="noopener noreferrer"
4040
>
4141
<img
42-
class="w-10 h-10 rounded-full object-cover border-2 border-[var(--color-border)] u-photo"
42+
class="w-10 h-10 rounded-full object-cover border-2 border-border u-photo"
4343
src={mention.author.photo || '/assets/images/webmention-avatar-default.svg'}
4444
alt={mention.author.name || 'Anonymous'}
4545
loading="lazy"
@@ -49,7 +49,7 @@ const formatDate = (dateString: string) => {
4949
) : (
5050
<span class="inline-flex items-center gap-3">
5151
<img
52-
class="w-10 h-10 rounded-full object-cover border-2 border-[var(--color-border)]"
52+
class="w-10 h-10 rounded-full object-cover border-2 border-border"
5353
src="/assets/images/webmention-avatar-default.svg"
5454
alt="Anonymous"
5555
loading="lazy"
@@ -62,7 +62,7 @@ const formatDate = (dateString: string) => {
6262
{
6363
mention.published && (
6464
<time
65-
class="text-[var(--color-text-offset)] text-sm whitespace-nowrap dt-published"
65+
class="text-text-offset text-sm whitespace-nowrap dt-published"
6666
datetime={mention.published}
6767
>
6868
{formatDate(mention.published)}
@@ -74,7 +74,7 @@ const formatDate = (dateString: string) => {
7474
{
7575
mention.content?.value && (
7676
<div
77-
class="text-[var(--color-text)] leading-relaxed break-words [&_p]:my-2 [&_a]:text-[var(--color-primary)] [&_a]:underline [&_a:hover]:text-[var(--color-primary-hover)] p-content"
77+
class="text-text leading-relaxed wrap-break-word [&_p]:my-2 [&_a]:text-primary [&_a]:underline [&_a:hover]:text-primary-hover p-content"
7878
set:html={mention.content.value}
7979
/>
8080
)
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
{
2+
"type": "feed",
3+
"name": "webmention.io",
4+
"children": [
5+
{
6+
"wm-id": "1004",
7+
"wm-target": "https://example.com/blog/post",
8+
"wm-property": "mention-of",
9+
"wm-source": "https://blog.example.com/posts/alpha",
10+
"published": "2024-06-30T12:00:00.000Z",
11+
"author": {
12+
"name": "Carol Indie",
13+
"url": "https://carol.example.com",
14+
"photo": "https://carol.example.com/avatar.jpg"
15+
},
16+
"content": {
17+
"text": "Earlier mention text"
18+
}
19+
},
20+
{
21+
"wm-id": "1001",
22+
"wm-target": "https://example.com/blog/post",
23+
"wm-property": "mention-of",
24+
"wm-source": "https://social.example.com/@alice/123",
25+
"published": "2024-07-01T15:00:00.000Z",
26+
"author": {
27+
"name": "Alice Web",
28+
"url": "https://alice.example.com",
29+
"photo": "https://alice.example.com/avatar.jpg"
30+
},
31+
"content": {
32+
"html": "<p>Love this post<script>alert('xss')</script></p>"
33+
}
34+
},
35+
{
36+
"wm-id": "1002",
37+
"wm-target": "https://example.com/blog/post",
38+
"wm-property": "like-of",
39+
"wm-source": "https://social.example.com/@bob/456",
40+
"published": "2024-07-02T18:30:00.000Z",
41+
"author": {
42+
"name": "Bob Reader",
43+
"url": "https://bob.example.com",
44+
"photo": "https://bob.example.com/avatar.jpg"
45+
}
46+
},
47+
{
48+
"wm-id": "1003",
49+
"wm-target": "https://example.com/blog/post",
50+
"wm-property": "rsvp",
51+
"wm-source": "https://events.example.com/rsvp",
52+
"published": "2024-07-03T18:30:00.000Z",
53+
"author": {
54+
"name": "Dana RSVP",
55+
"url": "https://dana.example.com"
56+
},
57+
"content": {
58+
"text": "See you there"
59+
}
60+
},
61+
{
62+
"wm-id": "1005",
63+
"wm-target": "https://example.com/blog/post",
64+
"wm-property": "mention-of",
65+
"wm-source": "https://missing-content.example.com/post",
66+
"published": "2024-07-04T08:45:00.000Z",
67+
"author": {
68+
"name": "Evan NoContent",
69+
"url": "https://evan.example.com"
70+
}
71+
}
72+
]
73+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// @vitest-environment node
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
import type { Webmention, WebmentionResponse } from '@components/WebMentions/@types'
4+
import fixture from '../__fixtures__/index.fixture.json'
5+
import {
6+
fetchWebmentions,
7+
isOwnWebmention,
8+
webmentionsByUrl,
9+
webmentionCountByType,
10+
} from '../index'
11+
12+
vi.mock('astro:env/client', () => ({
13+
WEBMENTION_IO_TOKEN: 'test-token',
14+
}))
15+
16+
const fixtureResponse = fixture as WebmentionResponse
17+
18+
const createMockResponse = (
19+
overrides: Partial<Response> = {},
20+
payload: WebmentionResponse = fixtureResponse,
21+
): Response => ({
22+
ok: true,
23+
status: 200,
24+
statusText: 'OK',
25+
json: async () => payload,
26+
...overrides,
27+
} as Response)
28+
29+
describe('fetchWebmentions', () => {
30+
const targetUrl = 'https://example.com/blog/post'
31+
let fetchMock: ReturnType<typeof vi.fn>
32+
33+
beforeEach(() => {
34+
fetchMock = vi.fn()
35+
vi.stubGlobal('fetch', fetchMock)
36+
})
37+
38+
afterEach(() => {
39+
vi.unstubAllGlobals()
40+
})
41+
42+
it('fetches, filters, and sanitizes webmentions from the API', async () => {
43+
fetchMock.mockResolvedValue(createMockResponse())
44+
45+
const results = await fetchWebmentions(targetUrl)
46+
47+
expect(fetchMock).toHaveBeenCalledTimes(1)
48+
const [requestedUrl, init] = fetchMock.mock.calls[0]
49+
const parsedUrl = new URL(requestedUrl as string)
50+
51+
expect(parsedUrl.origin + parsedUrl.pathname).toBe('https://webmention.io/api/mentions.jf2')
52+
expect(parsedUrl.searchParams.get('target')).toBe(targetUrl)
53+
expect(parsedUrl.searchParams.get('token')).toBe('test-token')
54+
expect(parsedUrl.searchParams.get('per-page')).toBe('1000')
55+
expect(init).toEqual({ headers: { 'Cache-Control': 'max-age=300' } })
56+
57+
expect(results).toHaveLength(3)
58+
expect(results[0]['wm-property']).toBe('mention-of')
59+
expect(new Date(results[0].published).getTime()).toBeLessThan(new Date(results[2].published).getTime())
60+
61+
const sanitizedHtml = results[1].content?.value ?? ''
62+
expect(sanitizedHtml).toBe('<p>Love this post</p>')
63+
})
64+
65+
it('returns an empty array when the API responds with an error status', async () => {
66+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
67+
fetchMock.mockResolvedValue(
68+
createMockResponse({ ok: false, status: 500, statusText: 'Server Error', json: async () => ({}) }),
69+
)
70+
71+
const results = await fetchWebmentions(targetUrl)
72+
73+
expect(results).toEqual([])
74+
expect(errorSpy).toHaveBeenCalled()
75+
errorSpy.mockRestore()
76+
})
77+
78+
it('returns an empty array when the fetch call fails', async () => {
79+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
80+
fetchMock.mockRejectedValue(new Error('network down'))
81+
82+
const results = await fetchWebmentions(targetUrl)
83+
84+
expect(results).toEqual([])
85+
expect(errorSpy).toHaveBeenCalled()
86+
errorSpy.mockRestore()
87+
})
88+
})
89+
90+
describe('Webmention helpers', () => {
91+
const helperWebmentions: Webmention[] = [
92+
{
93+
'wm-id': 'helper-1',
94+
'wm-target': 'https://example.com/a',
95+
'wm-property': 'mention-of',
96+
published: '2024-01-01T00:00:00.000Z',
97+
author: { name: 'Site Author', url: 'https://webstackbuilders.com/articles/launch' },
98+
},
99+
{
100+
'wm-id': 'helper-2',
101+
'wm-target': 'https://example.com/a',
102+
'wm-property': 'like-of',
103+
published: '2024-01-02T00:00:00.000Z',
104+
author: { name: 'Another User', url: 'https://social.example.com/users/2' },
105+
},
106+
{
107+
'wm-id': 'helper-3',
108+
'wm-target': 'https://example.com/b',
109+
'wm-property': 'mention-of',
110+
published: '2024-01-03T00:00:00.000Z',
111+
author: { name: 'Third User', url: 'https://elsewhere.example.com' },
112+
},
113+
]
114+
115+
it('detects when a webmention originates from the configured domain', () => {
116+
expect(isOwnWebmention(helperWebmentions[0])).toBe(true)
117+
expect(isOwnWebmention(helperWebmentions[1])).toBe(false)
118+
expect(isOwnWebmention(helperWebmentions[2], ['https://elsewhere.example.com'])).toBe(true)
119+
})
120+
121+
it('filters webmentions by target URL', () => {
122+
const filtered = webmentionsByUrl(helperWebmentions, 'https://example.com/a')
123+
124+
expect(filtered).toHaveLength(2)
125+
expect(filtered.every((entry) => entry['wm-target'] === 'https://example.com/a')).toBe(true)
126+
})
127+
128+
it('counts webmentions that match specific interaction types', () => {
129+
const count = webmentionCountByType(helperWebmentions, 'https://example.com/a', 'mention-of', 'like-of')
130+
131+
expect(count).toBe(2)
132+
})
133+
})

0 commit comments

Comments
 (0)