Commit 28d748e
Changed files (4)
src
components
user
content
posts
plugins
utils
src/components/user/LinkCard.astro
@@ -1,22 +1,72 @@
---
import { Icon } from 'astro-icon/components';
+import { getLinkPreview } from '@utils/link-preview';
+
interface Props {
- title: string;
- description: string;
url: string;
+ title?: string;
+ description?: string;
+ siteName?: string;
+ image?: string;
+ favicon?: string;
}
-let { title, description, url } = Astro.props;
+const props = Astro.props as Props;
+
+const preview = await getLinkPreview(props.url, {
+ title: props.title,
+ description: props.description,
+ siteName: props.siteName,
+ image: props.image,
+ favicon: props.favicon,
+});
---
-<a href={url} title={title} class="card border-base-content/25 my-4 overflow-hidden border">
- <div class="card-body flex flex-row items-center justify-between p-4">
- <div>
- <div class="card-title">
- {title}
+<a
+ href={preview.url}
+ title={preview.title}
+ class="card card-side border-base-content/25 my-4 overflow-hidden border"
+ data-link-card
+ data-url={preview.url}
+ data-fetched-at={preview.fetchedAt}
+ rel="noopener noreferrer"
+>
+ {
+ preview.image && (
+ <figure class="flex-shrink-0">
+ <img
+ src={preview.image}
+ alt={preview.title || 'Link preview image'}
+ class="h-24 w-32 object-cover"
+ loading="lazy"
+ />
+ </figure>
+ )
+ }
+ <div class="card-body grid grid-cols-[minmax(0,1fr)_auto] items-center p-4">
+ <div class="grid grid-rows-2 gap-2">
+ <div class="flex items-center gap-2">
+ {
+ preview.favicon && (
+ <img
+ src={preview.favicon}
+ alt={preview.siteName}
+ class="h-6 w-6 rounded object-cover"
+ loading="lazy"
+ />
+ )
+ }
+ <span class="card-title truncate">
+ {preview.title}
+ </span>
+ <span class="text-base-content/60 text-sm">
+ {preview.siteName}
+ </span>
</div>
- <div class="card-desc text-base-content/50">{description}</div>
+ <p class="text-base-content/50 truncate">
+ {preview.description}
+ </p>
</div>
<Icon name="material-symbols:arrow-right-alt-rounded" height="1.875rem" width="1.875rem" />
</div>
src/content/posts/components.mdx
@@ -142,45 +142,6 @@ Components let you easily reuse a piece of UI or styling consistently. You can u
</Fragment>
</Repl>
-### LinkCard
-
-<Repl>
- <LinkCard
- title="Astral Halo"
- description="A static blog template developed with Astro"
- url="https://github.com/HPCesia/astral-halo"
- />
- <LinkCard
- title="Astro"
- description="The all-in-one web framework designed for speed."
- url="https://astro.build/"
- />
- <Fragment slot="desc">
- <Tabs>
- <TabItem label="mdx" active>
- ```jsx
- <LinkCard
- title="Astral Halo"
- description="A static blog template developed with Astro"
- url="https://github.com/HPCesia/astral-halo"
- />
- <LinkCard
- title="Astro"
- description="The all-in-one web framework designed for speed."
- url="https://astro.build/"
- />
- ```
- </TabItem>
- <TabItem label="md">
- ```md
- ::linkcard{title="Astral Halo" description="A static blog template developed with Astro" url="https://github.com/HPCesia/astral-halo"}
- ::linkcard{title="Astro" description="The all-in-one web framework designed for speed." url="https://astro.build/"}
- ```
- </TabItem>
- </Tabs>
- </Fragment>
-</Repl>
-
## Inline Containers
### Tooltip
@@ -288,6 +249,53 @@ Components let you easily reuse a piece of UI or styling consistently. You can u
## Web Contents
+### LinkCard
+
+<Repl>
+ <LinkCard url="https://codeberg.org/HPCesia" />
+ <LinkCard
+ url="https://codeberg.org/HPCesia/AstralHalo"
+ title="Astral Halo"
+ />
+ <LinkCard
+ title="Astro"
+ description="The all-in-one web framework designed for speed."
+ favicon = "https://astro.build/favicon.svg"
+ image = "https://astro.build/og/astro.jpg"
+ url="https://astro.build/"
+ />
+ <Fragment slot="desc">
+ <Tabs>
+ <TabItem label="mdx" active>
+ ```jsx
+ <LinkCard url="https://codeberg.org/HPCesia" />
+ <LinkCard
+ url="https://codeberg.org/HPCesia/AstralHalo"
+ title="Astral Halo"
+ />
+ <LinkCard
+ title="Astro"
+ description="The all-in-one web framework designed for speed."
+ favicon = "https://astro.build/favicon.svg"
+ image = "https://astro.build/og/astro.jpg"
+ url="https://astro.build/"
+ />
+ ```
+ </TabItem>
+ <TabItem label="md">
+ ```md
+ ::linkcard{url="https://codeberg.org/HPCesia"}
+ ::linkcard{url="https://codeberg.org/HPCesia/AstralHalo" title="Astral Halo"}
+ ::linkcard{title="Astro" description="The all-in-one web framework designed for speed." favicon="https://astro.build/favicon.svg" image="https://astro.build/og/astro.jpg" url="https://astro.build/"}
+ ```
+ </TabItem>
+ </Tabs>
+ </Fragment>
+</Repl>
+
+> [!TIP]
+> `LinkCard`'s preview data is fetched on build time, the output is static.
+
### RepoCard
<Repl>
src/plugins/rehype-components-list.ts
@@ -1,8 +1,10 @@
/**
* All components in this file should sync with the components in `src/components/user`
*/
+import { getLinkPreview } from '../utils/link-preview.ts';
import type { IconifyJSON } from '@iconify/types';
import { getIconData, iconToHTML, iconToSVG, stringToIcon } from '@iconify/utils';
+import { fromHtml } from 'hast-util-from-html';
import { h } from 'hastscript';
import type { Child } from 'hastscript';
import { readFile } from 'node:fs/promises';
@@ -43,10 +45,10 @@ async function loadCollection(name: string) {
}
const collections: Record<string, IconifyJSON> = {};
-iconSets.forEach(async (set) => {
+for (const set of iconSets) {
const icons = await loadCollection(set);
if (icons) collections[set] = icons;
-});
+}
const Collapse = function (
props: {
@@ -118,42 +120,155 @@ const Icon = function (props: {
);
};
-const LinkCard = function (props: { title: string; description: string; url: string }) {
- const { title, description, url } = props;
- const wrapperClassName = 'card border-base-content/25 my-4 overflow-hidden border';
- const bodyClassName = 'card-body flex flex-row items-center justify-between p-4';
- const titleClassName = 'card-title';
- const descClassName = 'card-desc text-base-content/50';
+const LinkCard = async function (props: {
+ url: string;
+ title?: string;
+ description?: string;
+ siteName?: string;
+ image?: string;
+ favicon?: string;
+}) {
+ if (!props.url) {
+ console.error('LinkCard requires a "url" property.');
+ return h(
+ 'a',
+ { class: 'card border-base-content/25 my-4 overflow-hidden border' },
+ 'Link card error'
+ );
+ }
- const titleNode = h('div', { class: titleClassName }, title);
- const descNode = h('div', { class: descClassName }, description);
- const contentNode = h('div', null, [titleNode, descNode]);
+ const preview = await getLinkPreview(props.url, {
+ title: props.title,
+ description: props.description,
+ siteName: props.siteName,
+ image: props.image,
+ favicon: props.favicon,
+ });
+
+ const contentNodes: Child[] = [];
+
+ if (preview.image) {
+ contentNodes.push(
+ h('figure', { class: 'flex-shrink-0' }, [
+ h('img', {
+ src: preview.image,
+ alt: preview.title || 'Link preview image',
+ class: 'h-24 w-32 object-cover',
+ loading: 'lazy',
+ }),
+ ])
+ );
+ }
+
+ const metaRowChildren: Child[] = [];
+ if (preview.favicon) {
+ metaRowChildren.push(
+ h('img', {
+ src: preview.favicon,
+ alt: preview.siteName || preview.title,
+ class: 'h-6 w-6 rounded object-cover',
+ loading: 'lazy',
+ })
+ );
+ }
+
+ metaRowChildren.push(
+ h('span', { class: 'card-title truncate' }, preview.title || preview.url)
+ );
+ metaRowChildren.push(
+ h(
+ 'span',
+ { class: 'text-base-content/60 text-sm' },
+ preview.siteName || new URL(preview.url).hostname
+ )
+ );
+
+ const infoColumn = h(
+ 'div',
+ { class: 'grid grid-rows-2 gap-2' },
+ h('div', { class: 'flex items-center gap-2' }, ...metaRowChildren),
+ h('p', { class: 'text-base-content/50 truncate' }, preview.description || '')
+ );
const collection = collections['material-symbols'];
if (!collection) {
- console.error('LinkCard icon set found: material-symbols');
- return h('a', { class: wrapperClassName, href: url, title }, 'Link card error');
+ console.error('LinkCard icon set not found: material-symbols');
+ const bodyNode = h('div', { class: 'card-body p-4' }, infoColumn);
+ contentNodes.push(bodyNode);
+ return h(
+ 'a',
+ {
+ class: 'card card-side border-base-content/25 my-4 overflow-hidden border',
+ href: preview.url,
+ title: preview.title,
+ 'data-link-card': '',
+ 'data-url': preview.url,
+ 'data-fetched-at': preview.fetchedAt,
+ rel: 'noopener noreferrer',
+ },
+ ...contentNodes
+ );
}
+
const iconData = getIconData(collection, 'arrow-right-alt-rounded');
if (!iconData) {
console.error('LinkCard icon not found: material-symbols:arrow-right-alt-rounded');
- return h('a', { class: wrapperClassName, href: url, title }, 'Link card error');
+ const bodyNode = h('div', { class: 'card-body p-4' }, infoColumn);
+ contentNodes.push(bodyNode);
+ return h(
+ 'a',
+ {
+ class: 'card card-side border-base-content/25 my-4 overflow-hidden border',
+ href: preview.url,
+ title: preview.title,
+ 'data-link-card': '',
+ 'data-url': preview.url,
+ 'data-fetched-at': preview.fetchedAt,
+ rel: 'noopener noreferrer',
+ },
+ ...contentNodes
+ );
}
+
const { attributes, body } = iconToSVG(iconData);
- const iconHtml = iconToHTML(body, attributes);
+ const svgAttributes: Record<string, string> = {
+ ...attributes,
+ width: '1.875rem',
+ height: '1.875rem',
+ 'aria-hidden': 'true',
+ focusable: 'false',
+ };
+
+ const parsed = fromHtml(body, { fragment: true });
+ const svgNode = h('svg', svgAttributes, ...(parsed.children as Child[]));
+
const iconNode = h(
- 'span',
+ 'div',
+ { class: 'flex items-center justify-center text-base-content/60' },
+ svgNode
+ );
+
+ const bodyNode = h(
+ 'div',
+ { class: 'card-body grid grid-cols-[minmax(0,1fr)_auto] items-center p-4' },
+ [infoColumn, iconNode]
+ );
+
+ contentNodes.push(bodyNode);
+
+ return h(
+ 'a',
{
- class: 'text-3xl',
+ class: 'card card-side border-base-content/25 my-4 overflow-hidden border',
+ href: preview.url,
+ title: preview.title,
+ 'data-link-card': '',
+ 'data-url': preview.url,
+ 'data-fetched-at': preview.fetchedAt,
+ rel: 'noopener noreferrer',
},
- {
- type: 'raw',
- value: iconHtml,
- }
+ ...contentNodes
);
- const bodyNode = h('div', { class: bodyClassName }, [contentNode, iconNode]);
-
- return h('a', { class: wrapperClassName, href: url, title }, bodyNode);
};
const Ruby = function (props: { base: string; text: string }) {
src/utils/link-preview.ts
@@ -0,0 +1,157 @@
+import { load } from 'cheerio';
+import type { CheerioAPI } from 'cheerio';
+
+export interface LinkPreviewData {
+ title: string;
+ description: string;
+ siteName: string;
+ image?: string | null;
+ favicon?: string | null;
+ url: string;
+ fetchedAt: string;
+}
+
+interface LinkPreviewFallback {
+ title?: string;
+ description?: string;
+ siteName?: string;
+ image?: string | null;
+ favicon?: string | null;
+}
+
+const previewCache = new Map<string, Promise<LinkPreviewData>>();
+
+function makeAbsolute(resource: string | undefined | null, baseUrl: string): string | null {
+ if (!resource) return null;
+ try {
+ return new URL(resource, baseUrl).href;
+ } catch (error) {
+ console.warn(`Failed to resolve resource URL for ${resource}`, error);
+ return null;
+ }
+}
+
+type PartialPreview = {
+ title?: string | null;
+ description?: string | null;
+ siteName?: string | null;
+ image?: string | null;
+ favicon?: string | null;
+};
+
+function extractMeta($: CheerioAPI, selector: string): string | null {
+ const content = $(selector).attr('content');
+ if (!content) return null;
+ return content.trim() || null;
+}
+
+function createPreviewData(
+ url: string,
+ partial: PartialPreview,
+ fallback: LinkPreviewFallback
+): LinkPreviewData {
+ const target = new URL(url);
+
+ const title = partial.title?.trim() || fallback.title?.trim() || target.hostname;
+ const description = partial.description?.trim() || fallback.description?.trim() || '';
+ const siteName = partial.siteName?.trim() || fallback.siteName?.trim() || target.hostname;
+ const image = partial.image ?? fallback.image ?? null;
+ const favicon = partial.favicon ?? fallback.favicon ?? null;
+
+ return {
+ title,
+ description,
+ siteName,
+ image,
+ favicon,
+ url,
+ fetchedAt: new Date().toISOString(),
+ };
+}
+
+async function fetchAndParse(
+ url: string,
+ fallback: LinkPreviewFallback
+): Promise<LinkPreviewData> {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 10_000);
+
+ try {
+ const response = await fetch(url, {
+ headers: {
+ 'User-Agent': 'Astral-Halo-LinkPreview/1.0 (+https://astral-halo.netlify.app)',
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ },
+ signal: controller.signal,
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch preview for ${url}: ${response.status} ${response.statusText}`
+ );
+ }
+
+ const html = await response.text();
+ const $ = load(html);
+
+ const title =
+ extractMeta($, 'meta[property="og:title"]') ||
+ extractMeta($, 'meta[name="twitter:title"]') ||
+ $('title').first().text().trim() ||
+ null;
+
+ const description =
+ extractMeta($, 'meta[property="og:description"]') ||
+ extractMeta($, 'meta[name="description"]') ||
+ extractMeta($, 'meta[name="twitter:description"]') ||
+ null;
+
+ const image = makeAbsolute(
+ extractMeta($, 'meta[property="og:image"]') ||
+ extractMeta($, 'meta[name="twitter:image"]') ||
+ $('img[src]').first().attr('src'),
+ url
+ );
+
+ const siteName =
+ extractMeta($, 'meta[property="og:site_name"]') ||
+ $('meta[name="application-name"]').attr('content') ||
+ $('meta[name="author"]').attr('content') ||
+ null;
+
+ const faviconLink = $(
+ 'link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]'
+ )
+ .map((_, el) => $(el).attr('href')?.trim())
+ .get()
+ .find(Boolean);
+
+ const favicon = makeAbsolute(faviconLink, url);
+
+ return createPreviewData(url, { title, description, image, siteName, favicon }, fallback);
+ } catch (error) {
+ console.warn(`[LinkPreview] Failed to fetch ${url}:`, error);
+ return createPreviewData(url, {}, fallback);
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+export async function getLinkPreview(
+ url: string,
+ fallback: LinkPreviewFallback = {}
+): Promise<LinkPreviewData> {
+ if (!previewCache.has(url)) {
+ const previewPromise = fetchAndParse(url, fallback).catch((error) => {
+ previewCache.delete(url);
+ throw error;
+ });
+ previewCache.set(url, previewPromise);
+ }
+
+ try {
+ return await previewCache.get(url)!;
+ } catch {
+ return createPreviewData(url, {}, fallback);
+ }
+}