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 =
54 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 /* Set container size/padding when image hidden */
234 .prose .link-card-image-outer:has(.link-card-image[style*='display: none']) {
235 padding: 0.25rem 0.25rem 0 0.25rem;
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;
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: 1rem 1.25rem 0.75rem 1.25rem;
341 }
342
343 .prose .link-card:not(:has(.link-card-image[style*='display: block'])) .link-card-content {
344 padding: 0;
345 }
346
347 .prose .link-card:has(.link-card-image[style*='display: block']) .link-card-content {
348 padding: 1rem 1.25rem 0.75rem 1.25rem;
349 }
350</style>