Commit 28d748e

HPCesia <me@hpcesia.com>
2025-10-12 08:35:26
feat: prefetch metadata for LinkCard component
1 parent e17180a
Changed files (4)
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 }) {