Fork of Chiri for Astro for my blog
1<script>
2 import type { LinkCardMetadata } from '@/types'
3
4 let linkCardsObserver: IntersectionObserver | null = null
5 const metadataCache = new Map<string, LinkCardMetadata>()
6
7 // Fetch metadata from URL using our own proxy API with caching
8 async function fetchLinkMetadata(url: string): Promise<LinkCardMetadata | null> {
9 // Check cache first
10 if (metadataCache.has(url)) {
11 return metadataCache.get(url)!
12 }
13
14 try {
15 // Use our own proxy API
16 const proxyUrl = `/api/proxy?url=${encodeURIComponent(url)}`
17 const controller = new AbortController()
18 const timeoutId = setTimeout(() => controller.abort(), 3000) // 3 second timeout
19
20 const response = await fetch(proxyUrl, {
21 signal: controller.signal
22 })
23
24 clearTimeout(timeoutId)
25
26 if (!response.ok) {
27 throw new Error('Failed to fetch metadata')
28 }
29
30 const html = await response.text()
31
32 // Parse HTML to extract metadata
33 const parser = new DOMParser()
34 const doc = parser.parseFromString(html, 'text/html')
35
36 const title =
37 doc.querySelector('meta[property="og:title"]')?.getAttribute('content') ||
38 doc.querySelector('meta[name="twitter:title"]')?.getAttribute('content') ||
39 doc.querySelector('title')?.textContent ||
40 ''
41
42 const description =
43 doc.querySelector('meta[property="og:description"]')?.getAttribute('content') ||
44 doc.querySelector('meta[name="twitter:description"]')?.getAttribute('content') ||
45 doc.querySelector('meta[name="description"]')?.getAttribute('content') ||
46 ''
47
48 const image =
49 doc.querySelector('meta[property="og:image"]')?.getAttribute('content') ||
50 doc.querySelector('meta[name="twitter:image"]')?.getAttribute('content') ||
51 doc.querySelector('meta[name="image"]')?.getAttribute('content') ||
52 ''
53
54 const imageAlt = doc.querySelector('meta[property="og:image:alt"]')?.getAttribute('content') || title || ''
55
56 const metadata = {
57 title: title?.trim() || '',
58 description: description?.trim() || '',
59 image: image?.trim() || '',
60 imageAlt: imageAlt?.trim() || ''
61 }
62
63 // Cache the result
64 metadataCache.set(url, metadata)
65 return metadata
66 } catch {
67 return null
68 }
69 }
70
71 // Update link card with fetched metadata
72 async function updateLinkCard(el: HTMLElement) {
73 const url = el.dataset.url
74 if (!url) {
75 return
76 }
77
78 try {
79 // Fetch and update metadata
80 const metadata = await fetchLinkMetadata(url)
81 if (!metadata) {
82 return
83 }
84
85 // Update title
86 const titleElement = el.querySelector('.link-card-title') as HTMLElement
87 if (titleElement) {
88 if (metadata.title && metadata.title.trim()) {
89 titleElement.textContent = metadata.title
90 titleElement.style.display = 'block'
91 } else {
92 // Hide title element if empty
93 titleElement.style.display = 'none'
94 }
95 }
96
97 // Update description
98 const descElement = el.querySelector('.link-card-description') as HTMLElement
99 if (descElement) {
100 if (metadata.description && metadata.description.trim()) {
101 descElement.textContent = metadata.description
102 descElement.style.display = 'block'
103 } else {
104 // Hide description element if empty
105 descElement.style.display = 'none'
106 descElement.textContent = ''
107 }
108 }
109
110 // Update image
111 const imageContainer = el.querySelector('.link-card-image') as HTMLElement
112 const imageElement = el.querySelector('.link-card-image img') as HTMLImageElement
113 if (imageContainer && imageElement && metadata.image) {
114 imageElement.src = metadata.image
115 imageElement.alt = metadata.imageAlt
116 imageContainer.style.display = 'block'
117 }
118 } catch {
119 // Handle error silently
120 }
121 }
122
123 // Set up intersection observer for link cards
124 function setupLinkCards() {
125 linkCardsObserver?.disconnect()
126
127 const linkCards = document.getElementsByClassName('link-card')
128 if (linkCards.length === 0) {
129 return
130 }
131
132 // Create an intersection observer to process link cards when they enter viewport
133 linkCardsObserver = new IntersectionObserver(
134 (entries) => {
135 // Process all intersecting cards in parallel
136 const intersectingCards = entries
137 .filter((entry) => entry.isIntersecting)
138 .map((entry) => entry.target as HTMLElement)
139
140 if (intersectingCards.length > 0) {
141 // Update domain names immediately for better perceived performance
142 intersectingCards.forEach((card) => {
143 const url = card.dataset.url
144 if (url) {
145 try {
146 const domain = new URL(url).hostname.replace('www.', '')
147 const urlElement = card.querySelector('.link-card-url')
148 if (urlElement) {
149 // Keep the SVG icon and update only the text content
150 const textSpan = urlElement.querySelector('span')
151 if (textSpan) {
152 textSpan.textContent = domain
153 } else {
154 // Fallback if span doesn't exist
155 urlElement.innerHTML = `
156 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
157 <path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z"></path>
158 </svg>
159 <span>${domain}</span>
160 `
161 }
162 }
163 } catch {
164 const urlElement = card.querySelector('.link-card-url')
165 if (urlElement) {
166 // Keep the SVG icon and update only the text content
167 const textSpan = urlElement.querySelector('span')
168 if (textSpan) {
169 textSpan.textContent = 'invalid-url'
170 } else {
171 // Fallback if span doesn't exist
172 urlElement.innerHTML = `
173 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
174 <path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z"></path>
175 </svg>
176 <span>invalid-url</span>
177 `
178 }
179 }
180 }
181 }
182 })
183
184 // Fetch metadata in parallel
185 Promise.allSettled(intersectingCards.map((card) => updateLinkCard(card))).then(() => {
186 // Unobserve all processed cards
187 intersectingCards.forEach((card) => linkCardsObserver?.unobserve(card))
188 })
189 }
190 },
191 { rootMargin: '200px' }
192 )
193
194 Array.from(linkCards).forEach((card) => linkCardsObserver?.observe(card))
195 }
196
197 setupLinkCards()
198 document.addEventListener('astro:page-load', setupLinkCards)
199</script>
200
201<style is:inline>
202 .prose .link-card {
203 display: flex;
204 flex-direction: column;
205 border: 0.5px solid var(--border);
206 border-radius: 8px;
207 overflow: hidden;
208 text-decoration: none;
209 color: inherit;
210 background: var(--astro-code-background);
211 min-height: 22.75px;
212 margin: 1.25rem 0 1.75rem 0;
213 transition: background 0.2s ease-out;
214 }
215
216 .prose .link-card:hover {
217 background: color-mix(in srgb, var(--selection) 75%, transparent);
218 text-decoration: none;
219 }
220
221 .prose .link-card-content {
222 padding: 0.75rem 1.25rem 0.75rem 1.25rem;
223 order: 2;
224 display: flex;
225 flex-direction: column;
226 }
227
228 .prose .link-card-image-outer {
229 padding: 0.5rem 0.5rem 0 0.5rem;
230 order: 1;
231 }
232
233 /* Hide image outer container when no image is displayed */
234 .prose .link-card-image-outer:has(.link-card-image[style*='display: none']),
235 .prose .link-card-image-outer:not(:has(.link-card-image[style*='display: block'])) {
236 display: none;
237 }
238
239 .prose .link-card-image {
240 width: 100%;
241 aspect-ratio: 16 / 9;
242 /* Fallback for browsers that don't support aspect-ratio */
243 height: 0;
244 padding-bottom: 56.25%; /* 16:9 aspect ratio (9/16 = 0.5625) */
245 overflow: hidden;
246 background: var(--astro-code-background);
247 position: relative;
248 margin: 0;
249 padding: 0;
250 border-radius: 6px;
251 }
252
253 /* Modern browsers with aspect-ratio support */
254 @supports (aspect-ratio: 16 / 9) {
255 .prose .link-card-image {
256 height: auto;
257 padding-bottom: 0;
258 }
259 }
260
261 .prose .link-card-image img {
262 position: absolute;
263 top: 0;
264 left: 0;
265 right: 0;
266 bottom: 0;
267 width: 100%;
268 height: 100%;
269 object-fit: cover;
270 object-position: center;
271 margin: 0;
272 padding: 0;
273 }
274
275 .prose .link-card-title {
276 font-size: var(--font-size-m);
277 font-weight: var(--font-weight-bold);
278 color: var(--text-primary);
279 margin: 0.25rem 0 0 0;
280 white-space: nowrap;
281 overflow: hidden;
282 text-overflow: ellipsis;
283 padding-right: 1rem;
284 order: 1;
285 }
286
287 .prose .link-card .link-card-description {
288 font-size: var(--font-size-m);
289 color: var(--text-primary);
290 opacity: 0.6;
291 margin: 0;
292 display: -webkit-box !important;
293 -webkit-line-clamp: 2 !important;
294 -webkit-box-orient: vertical !important;
295 overflow: hidden !important;
296 word-wrap: break-word !important;
297 /* Fallback for older browsers */
298 max-height: calc(1.4em * 2) !important;
299 order: 2;
300 }
301
302 /* Hide description when display is set to none */
303 .prose .link-card .link-card-description[style*='display: none'] {
304 display: none !important;
305 margin: 0 !important;
306 padding: 0 !important;
307 height: 0 !important;
308 overflow: hidden !important;
309 }
310
311 /* Modern browsers with line-clamp support */
312 @supports (-webkit-line-clamp: 2) {
313 .prose .link-card .link-card-description {
314 max-height: none !important;
315 }
316 }
317
318 .prose .link-card-url {
319 color: var(--text-tertiary);
320 letter-spacing: 0.015em;
321 margin: 0.25rem 0;
322 display: flex;
323 align-items: center;
324 gap: 0.325rem;
325 order: 3;
326 }
327
328 .prose .link-card-url svg {
329 width: 0.875em;
330 height: 0.875em;
331 flex-shrink: 0;
332 vertical-align: middle;
333 }
334
335 .prose .link-card-url span {
336 font-size: var(--font-size-s);
337 vertical-align: baseline;
338 }
339
340 .prose .link-card:not(:has(.link-card-image[style*='display: block'])) {
341 padding: 0.5rem 1.25rem 0.5rem 1.25rem;
342 }
343 .prose .link-card:has(.link-card-image[style*='display: block']) .link-card-content {
344 padding: 0.5rem 1.25rem 0.5rem 1.25rem;
345 }
346
347 .prose .link-card:not(:has(.link-card-image[style*='display: block'])) .link-card-content {
348 padding: 0;
349 }
350</style>