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