Skip to content

Instantly share code, notes, and snippets.

@tak-dcxi
Last active June 9, 2024 01:56
Show Gist options
  • Save tak-dcxi/a0864b9954d21c247e7e9b96769da282 to your computer and use it in GitHub Desktop.
Save tak-dcxi/a0864b9954d21c247e7e9b96769da282 to your computer and use it in GitHub Desktop.
AstroLinkCard
---
import { load } from 'cheerio'
import Image from 'astro/components/Image.astro'
import { writeFileSync, readFileSync } from 'node:fs'
import BaseIcon from '@/components/BaseIcon.astro'
type Props = {
href: string
}
type PostOGP = {
title: string
description: string
image: string
favicon: string
}
type OGPJson = Record<string, PostOGP>
const { href } = Astro.props
const generatedFilePath = new URL('../../generated/ogp.json', import.meta.url)
const faviconSize = 256
const origin = new URL(href).origin
const fetchOGP = async (url: string): Promise<PostOGP> => {
const html = await fetch(url).then((res) => res.text())
const $ = load(html)
const title = $("meta[property='og:title']").attr('content') ?? $('title').text()
const description =
$("meta[property='og:description']").attr('content') ?? $("meta[name='description']").attr('content') ?? ''
const favicon = `https://www.google.com/s2/favicons?sz=${faviconSize}&domain=${origin}`
let image = $("meta[property='og:image']").attr('content') ?? ''
if (image && !image.startsWith('http')) {
image = `${new URL(url).origin}/${image}`
}
return { title, description, favicon, image }
}
const ogp: OGPJson = JSON.parse(readFileSync(generatedFilePath, 'utf8'))
const postOGP: PostOGP = ogp[href] ?? (await fetchOGP(href))
if (import.meta.env.PROD && !ogp[href]) {
ogp[href] = postOGP
writeFileSync(generatedFilePath, JSON.stringify(ogp, null, 2))
}
const innerLink = href.startsWith('https://www.tak-dcxi.com')
---
<div class="article-link-card" role="group" aria-label="参考リンク">
<div class="meta">
<p class="title">
<a href={href} target={innerLink ? null : '_blank'} rel={innerLink ? null : 'external'}>
{postOGP.title}
{!innerLink && <BaseIcon type="launch-link" size="1em" align="-0.15em" label="新しいウィンドウが開きます" />}
</a>
</p>
{
postOGP.description && (
<p class="description u-text-sm">
{postOGP.description.length > 150 ? `${postOGP.description?.slice(0, 150)}...` : postOGP.description}
</p>
)
}
<p class="url">
<Image
src={postOGP.favicon}
alt=""
decoding="async"
loading="lazy"
width={16}
height={16}
onerror={`this.onerror=null; this.style.display='none'`}
/>
{new URL(href).hostname}
</p>
</div>
{
postOGP.image && (
<a
href={href}
class="thumbnail"
tabindex="-1"
aria-hidden="true"
target={innerLink ? null : '_blank'}
rel={innerLink ? null : 'external'}
>
<Image
src={postOGP.image}
alt=""
decoding="async"
loading="lazy"
width={1200}
height={630}
onerror={`this.onerror=null; this.style.display='none'`}
/>
</a>
)
}
</div>
<style>
.article-link-card {
--_card-background: var(--_is-card-active-false, var(--background-strong))
var(--_is-card-active-true, var(--background-muted));
--_card-duration: var(--duration-default);
--_is-card-active-true: ;
--_is-card-active-false: initial;
container: article-link-card / inline-size;
display: block grid;
grid-template-columns: 1fr max-content;
border: 1px solid var(--border-base);
background-color: var(--_card-background);
transition: background-color var(--_card-duration);
&:has(:focus-visible),
&:has(:focus-visible) * {
--_is-card-active-true: initial;
--_is-card-active-false: ;
}
&:has(:any-link:hover),
&:has(:any-link:hover) * {
@media (any-hover: hover) {
--_is-card-active-true: initial;
--_is-card-active-false: ;
}
}
}
.meta {
display: block grid;
row-gap: var(--spacing-md);
justify-content: space-between;
align-items: center;
padding: 1.5em;
@container article-link-card (inline-size <= 30rem) {
grid-column: 1 / -1;
grid-row: 2 / 3;
}
}
.title {
text-wrap: pretty;
word-break: auto-phrase;
}
.title :where(:any-link) {
display: -webkit-box;
overflow: clip;
text-overflow: ellipsis;
color: var(--_is-card-active-true, var(--foreground-active));
text-decoration: revert;
transition: color var(--_card-duration);
-webkit-box-orient: block-axis;
-webkit-line-clamp: 3;
}
.description {
display: -webkit-box;
overflow: clip;
text-overflow: ellipsis;
color: var(--foreground-muted);
line-height: var(--leading-snug);
hanging-punctuation: last allow-end;
-webkit-box-orient: block-axis;
-webkit-line-clamp: 2;
}
.url {
display: block flex;
column-gap: 1ex;
line-height: var(--leading-tight);
text-justify: inter-character;
word-break: break-all;
}
.url :where(img) {
inline-size: auto;
block-size: 1lh;
}
.thumbnail {
contain: strict;
min-inline-size: 40cqi;
@container article-link-card (inline-size <= 30rem) {
grid-column: 1 / -1;
grid-row: 1 / 2;
aspect-ratio: var(--aspect-thumbnail);
}
&:has([style*='display: none']) {
display: none;
}
}
.thumbnail :where(img) {
inline-size: 100%;
block-size: 100%;
object-fit: cover;
scale: var(--_is-card-active-true, 1.1);
transition: scale var(--_card-duration);
}
</style>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment