Fork of Chiri for Astro for my blog
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add link card for embedded content

the3ash c79a6003 9e896a53

+390 -3
+3 -3
src/components/ui/GitHubCard.astro
··· 161 161 } 162 162 163 163 .prose .gc-container:hover { 164 - background: color-mix(in srgb, var(--selection) 60%, transparent); 164 + background: color-mix(in srgb, var(--selection) 75%, transparent); 165 165 text-decoration: none; 166 166 } 167 167 ··· 206 206 .prose .gc-repo-description { 207 207 font-size: var(--font-size-m); 208 208 color: var(--text-primary); 209 - opacity: 0.75; 209 + opacity: 0.6; 210 210 margin: 0 0 0.75rem 0; 211 211 line-height: 1.4; 212 212 } ··· 215 215 display: flex; 216 216 align-items: center; 217 217 color: var(--text-primary); 218 - opacity: 0.75; 218 + opacity: 0.6; 219 219 gap: 0.35rem; 220 220 } 221 221
+311
src/components/ui/LinkCard.astro
··· 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 + // Add loading state 79 + el.classList.add('loading') 80 + 81 + try { 82 + // Fetch and update metadata 83 + const metadata = await fetchLinkMetadata(url) 84 + if (!metadata) { 85 + return 86 + } 87 + 88 + // Update title 89 + const titleElement = el.querySelector('.link-card-title') as HTMLElement 90 + if (titleElement) { 91 + if (metadata.title && metadata.title.trim()) { 92 + titleElement.textContent = metadata.title 93 + titleElement.style.display = 'block' 94 + } else { 95 + // Hide title element if empty 96 + titleElement.style.display = 'none' 97 + } 98 + } 99 + 100 + // Update description 101 + const descElement = el.querySelector('.link-card-description') as HTMLElement 102 + if (descElement) { 103 + if (metadata.description && metadata.description.trim()) { 104 + descElement.textContent = metadata.description 105 + descElement.style.display = 'block' 106 + } else { 107 + // Hide description element if empty 108 + descElement.style.display = 'none' 109 + descElement.textContent = '' 110 + } 111 + } 112 + 113 + // Update image 114 + const imageContainer = el.querySelector('.link-card-image') as HTMLElement 115 + const imageElement = el.querySelector('.link-card-image img') as HTMLImageElement 116 + if (imageContainer && imageElement && metadata.image) { 117 + imageElement.src = metadata.image 118 + imageElement.alt = metadata.imageAlt 119 + imageContainer.style.display = 'block' 120 + } 121 + } finally { 122 + // Remove loading state 123 + el.classList.remove('loading') 124 + } 125 + } 126 + 127 + // Set up intersection observer for link cards 128 + function setupLinkCards() { 129 + linkCardsObserver?.disconnect() 130 + 131 + const linkCards = document.getElementsByClassName('link-card') 132 + if (linkCards.length === 0) { 133 + return 134 + } 135 + 136 + // Create an intersection observer to process link cards when they enter viewport 137 + linkCardsObserver = new IntersectionObserver( 138 + (entries) => { 139 + // Process all intersecting cards in parallel 140 + const intersectingCards = entries 141 + .filter((entry) => entry.isIntersecting) 142 + .map((entry) => entry.target as HTMLElement) 143 + 144 + if (intersectingCards.length > 0) { 145 + // Update domain names immediately for better perceived performance 146 + intersectingCards.forEach((card) => { 147 + const url = card.dataset.url 148 + if (url) { 149 + try { 150 + const domain = new URL(url).hostname.replace('www.', '') 151 + const urlElement = card.querySelector('.link-card-url') 152 + if (urlElement) { 153 + urlElement.textContent = domain 154 + } 155 + } catch { 156 + const urlElement = card.querySelector('.link-card-url') 157 + if (urlElement) { 158 + urlElement.textContent = 'invalid-url' 159 + } 160 + } 161 + } 162 + }) 163 + 164 + // Fetch metadata in parallel 165 + Promise.allSettled(intersectingCards.map((card) => updateLinkCard(card))).then(() => { 166 + // Unobserve all processed cards 167 + intersectingCards.forEach((card) => linkCardsObserver?.unobserve(card)) 168 + }) 169 + } 170 + }, 171 + { rootMargin: '200px' } 172 + ) 173 + 174 + Array.from(linkCards).forEach((card) => linkCardsObserver?.observe(card)) 175 + } 176 + 177 + setupLinkCards() 178 + document.addEventListener('astro:page-load', setupLinkCards) 179 + </script> 180 + 181 + <style is:inline> 182 + .prose .link-card { 183 + display: block; 184 + border: 0.5px solid var(--border); 185 + border-radius: 10px; 186 + overflow: hidden; 187 + text-decoration: none; 188 + color: inherit; 189 + background: var(--astro-code-background); 190 + margin: 1.25rem 0 1.75rem 0; 191 + transition: background 0.2s ease-out; 192 + } 193 + 194 + .prose .link-card:hover { 195 + background: color-mix(in srgb, var(--selection) 75%, transparent); 196 + text-decoration: none; 197 + } 198 + 199 + .prose .link-card.loading { 200 + pointer-events: none; 201 + } 202 + 203 + .prose .link-card-content { 204 + padding: 1rem 1.25rem 0.75rem 1.25rem; 205 + } 206 + 207 + .prose .link-card-image-outer { 208 + padding: 0 0.5rem 0.5rem 0.5rem; 209 + } 210 + 211 + /* Set container size/padding when image hidden */ 212 + .prose .link-card-image-outer:has(.link-card-image[style*='display: none']) { 213 + padding: 0 0.25rem 0.25rem 0.25rem; 214 + } 215 + 216 + .prose .link-card-image { 217 + width: 100%; 218 + aspect-ratio: 16 / 9; 219 + /* Fallback for browsers that don't support aspect-ratio */ 220 + height: 0; 221 + padding-bottom: 56.25%; /* 16:9 aspect ratio (9/16 = 0.5625) */ 222 + overflow: hidden; 223 + background: var(--border); 224 + position: relative; 225 + margin: 0; 226 + padding: 0; 227 + border-radius: 8px; 228 + } 229 + 230 + /* Modern browsers with aspect-ratio support */ 231 + @supports (aspect-ratio: 16 / 9) { 232 + .prose .link-card-image { 233 + height: auto; 234 + padding-bottom: 0; 235 + } 236 + } 237 + 238 + .prose .link-card-image img { 239 + position: absolute; 240 + top: 0; 241 + left: 0; 242 + right: 0; 243 + bottom: 0; 244 + width: 100%; 245 + height: 100%; 246 + object-fit: cover; 247 + object-position: center; 248 + margin: 0; 249 + padding: 0; 250 + } 251 + 252 + .prose .link-card-title { 253 + font-size: var(--font-size-m); 254 + font-weight: var(--font-weight-bold); 255 + color: var(--text-primary); 256 + margin: 0 0 0.375rem 0; 257 + white-space: nowrap; 258 + overflow: hidden; 259 + text-overflow: ellipsis; 260 + padding-right: 1rem; 261 + } 262 + 263 + .prose .link-card .link-card-description { 264 + font-size: var(--font-size-m); 265 + color: var(--text-primary); 266 + opacity: 0.6; 267 + margin: 0 0 0.1875rem 0; 268 + display: -webkit-box !important; 269 + -webkit-line-clamp: 2 !important; 270 + -webkit-box-orient: vertical !important; 271 + overflow: hidden !important; 272 + word-wrap: break-word !important; 273 + /* Fallback for older browsers */ 274 + max-height: calc(1.4em * 2) !important; 275 + } 276 + 277 + /* Hide description when display is set to none */ 278 + .prose .link-card .link-card-description[style*='display: none'] { 279 + display: none !important; 280 + margin: 0 !important; 281 + padding: 0 !important; 282 + height: 0 !important; 283 + overflow: hidden !important; 284 + } 285 + 286 + /* Modern browsers with line-clamp support */ 287 + @supports (-webkit-line-clamp: 2) { 288 + .prose .link-card .link-card-description { 289 + max-height: none !important; 290 + } 291 + } 292 + 293 + .prose .link-card-url { 294 + font-size: var(--font-size-s); 295 + color: var(--text-secondary); 296 + letter-spacing: 0.015em; 297 + margin: 0; 298 + } 299 + 300 + .prose .link-card:not(:has(.link-card-image[style*='display: block'])) { 301 + padding: 1rem 1.25rem 0.75rem 1.25rem; 302 + } 303 + 304 + .prose .link-card:not(:has(.link-card-image[style*='display: block'])) .link-card-content { 305 + padding: 0; 306 + } 307 + 308 + .prose .link-card:has(.link-card-image[style*='display: block']) .link-card-content { 309 + padding: 1rem 1.25rem 0.75rem 1.25rem; 310 + } 311 + </style>
+6
src/content/posts/embedded-content.md
··· 6 6 Use these directives to embed media: 7 7 8 8 ``` 9 + ::link{url="https://xxxxx"} 10 + 9 11 ::spotify{url="https://open.spotify.com/type/xxxxxx"} 10 12 11 13 ::youtube{url="https://www.youtube.com/watch?v=xxxxxx"} ··· 20 22 ``` 21 23 🟡 When embedded content is still loading, the table of contents positioning may be inaccurate. 22 24 ``` 25 + 26 + ## Link Card 27 + 28 + ::link{url="https://pitchfork.com/reviews/albums/ichiko-aoba-luminescent-creatures/"} 23 29 24 30 ## Spotify 25 31
+2
src/layouts/PostLayout.astro
··· 10 10 import GradientMask from '@/components/ui/GradientMask.astro' 11 11 import ImageViewer from '@/components/ui/ImageViewer.astro' 12 12 import GitHubCard from '@/components/ui/GitHubCard.astro' 13 + import LinkCard from '@/components/ui/LinkCard.astro' 13 14 import ImageOptimizer from '@/components/ui/ImageOptimizer.astro' 14 15 import XPOST from '@/components/ui/XPOST.astro' 15 16 import CopyCode from '@/components/ui/CopyCode.astro' ··· 60 61 <FootnoteScroll /> 61 62 <CopyCode /> 62 63 <GitHubCard /> 64 + <LinkCard /> 63 65 <XPOST /> 64 66 <ImageOptimizer /> 65 67 {themeConfig.post.imageViewer && <ImageViewer />}
+33
src/pages/api/proxy.ts
··· 1 + export const prerender = false 2 + 3 + import type { APIContext } from 'astro' 4 + 5 + export async function GET(context: APIContext) { 6 + const host = context.request.headers.get('host') || 'localhost:4321' 7 + const url = new URL(context.request.url, `http://${host}`) 8 + const target = url.searchParams.get('url') 9 + 10 + if (!target) { 11 + return new Response('Missing url param', { status: 400 }) 12 + } 13 + 14 + try { 15 + const res = await fetch(target, { 16 + headers: { 17 + 'User-Agent': 'Mozilla/5.0' 18 + } 19 + }) 20 + const contentType = res.headers.get('content-type') || 'text/html' 21 + const data = await res.text() 22 + return new Response(data, { 23 + status: 200, 24 + headers: { 25 + 'Content-Type': contentType, 26 + 'Cache-Control': 'no-store', 27 + 'Access-Control-Allow-Origin': '*' 28 + } 29 + }) 30 + } catch { 31 + return new Response('Proxy error', { status: 500 }) 32 + } 33 + }
+27
src/plugins/remark-embedded-media.mjs
··· 2 2 3 3 /** 4 4 * A remark plugin that converts custom directives to embedded media HTML elements 5 + * Supports: link cards, Spotify, YouTube, Bilibili, X posts, and GitHub repository cards 5 6 */ 6 7 const embedHandlers = { 8 + // Link Card 9 + link: (node) => { 10 + const url = node.attributes?.url 11 + if (!url) { 12 + return false 13 + } 14 + 15 + // Create the LinkCard HTML structure - all metadata will be fetched by JavaScript 16 + return ` 17 + <div class="link-card-wrapper"> 18 + <a href="${url}" class="link-card" target="_blank" rel="noopener noreferrer" data-url="${url}"> 19 + <div class="link-card-content"> 20 + <p class="link-card-title" style="display: none;"></p> 21 + <p class="link-card-description" style="display: none;"></p> 22 + <div class="link-card-url">Loading...</div> 23 + </div> 24 + <div class="link-card-image-outer"> 25 + <div class="link-card-image" style="display: none;"> 26 + <img src="" alt="" loading="lazy" /> 27 + </div> 28 + </div> 29 + </a> 30 + </div> 31 + ` 32 + }, 33 + 7 34 // Spotify 8 35 spotify: (node) => { 9 36 const url = node.attributes?.url ?? ''
+8
src/types/component.types.ts
··· 82 82 forks: HTMLElement | null 83 83 license: HTMLElement | null 84 84 } 85 + 86 + // LinkCard metadata interface (fetched from URL) 87 + export interface LinkCardMetadata { 88 + title: string 89 + description: string 90 + image: string 91 + imageAlt: string 92 + }