- Published on
How I Built a Bookmark Component with VitePress and Tailwind
- Authors
- Name
- Manuel Sousa
- @mlrcbsousa
In my previous article, "Why I Love Frontend Masters", I created a component to display course recommendations in an enriched card format. To make this reusable and visually appealing, I designed a Bookmark
component that fetches and displays metadata, like the title, description, and image, for each recommended course.
This article explains how this component works, from setting up a CSV data source to implementing a VitePress data loader that collects metadata during the build step.
Why Use a Static Bookmark Component?
One crucial aspect of building this in VitePress is that VitePress is a static site generator (SSG). This means that all metadata fetching happens at build time, not runtime. This approach makes the site fast, as no client-side fetching is required, but it also changes how we design the component and data pipeline. The fact that no client side fetching is required also allows us to avoid the dreaded CORS.
The traditional approach to rendering bookmark previews would be to fetch metadata on the client side whenever the user loads the page. However, in VitePress, we use a static approach where the metadata is fetched only once during the build and then embedded into the generated HTML files. This reduces loading times and eliminates the need for additional client-side requests.
Using a static setup offers several advantages:
- Performance: The site loads faster because it doesn’t need to fetch data at runtime.
- SEO: Metadata is embedded into the HTML, allowing search engines to index it.
- Simplicity: With the metadata fetched once, we avoid handling errors or API limitations at runtime.
The Components of the Static Bookmark System
This static bookmark system involves three main parts:
- The Data Source: A CSV file containing course names and URLs.
- The Loader: A script that reads the CSV, fetches metadata at build time, and makes it available to VitePress.
- The Bookmark Component: A Vue component that renders the metadata as a styled card in the final static site.
Step 1: The Data Source
The data source is a simple CSV file, bookmarks.csv
, located in src/data
. This file lists course names and URLs, making it easy to add or remove bookmarks as needed. Here’s an example of the CSV structure:
name,url
Vue Router Best Practices for Production,https://frontendmasters.com/courses/production-vue/
Comprehensive Vue 3 Fundamentals,https://frontendmasters.com/courses/vue-fundamentals/
TypeScript with Vue,https://frontendmasters.com/courses/vue-typescript/
...
The CSV is easy to maintain and gives us a simple way to add new bookmarks without touching the Vue components.
Step 2: The Loader
The loader script, bookmarks.data.ts
, is where the metadata fetching happens. Since VitePress is a static site generator, the loader only runs once during the build step, not at runtime.
The loader reads bookmarks.csv
, fetches metadata for each URL, and stores the information in a JSON-like structure. This data is then embedded into the static files generated by VitePress, allowing the site to display rich previews without client-side requests.
Here’s a simplified breakdown of what the loader does:
- Reading the CSV: It parses
bookmarks.csv
, creating an object for each bookmark withname
andurl
properties. - Fetching Metadata: For each URL, it makes an HTTP request to retrieve the page’s HTML.
- Extracting Metadata: Using Cheerio (a library for parsing HTML), it scrapes the HTML for Open Graph tags (
og:title
,og:description
, andog:image
). These tags provide the title, description, and image for the bookmark.
Here’s the code for bookmarks.data.ts
:
import fs from 'node:fs'
import { defineLoader } from 'vitepress'
import { parse } from 'csv-parse/sync'
import * as cheerio from 'cheerio'
export interface Bookmark {
name: string
url: string
title?: string
image?: string
description?: string
}
export type Bookmarks = Record<string, Bookmark>
export default defineLoader({
watch: ['../../src/data/bookmarks.csv'],
async load(watchedFiles): Promise<Bookmarks> {
const out: Bookmarks = {}
const bookmarks = parse(fs.readFileSync(watchedFiles[0], 'utf-8'), {
columns: true,
skip_empty_lines: true,
})
const texts = await fetchMetadata(bookmarks)
texts.forEach((result, i) => {
if (result.status === 'rejected' || !result.value) {
return
}
const { title, description, image } = extractMetadata(result.value)
out[bookmarks[i].name] = {
...bookmarks[i],
title,
description,
image,
}
})
return out
},
})
async function fetchMetadata(bookmarks: Bookmark[]) {
const results = await Promise.allSettled<Response>(bookmarks.map((b: Bookmark) => fetch(b.url)))
return await Promise.allSettled<string | undefined>(
results.map((result, i) => {
if (result.status === 'rejected') {
console.error(`Failed to fetch ${bookmarks[i].url}`)
return
}
return result.value.text()
}),
)
}
function extractMetadata(html: string) {
const $ = cheerio.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 image = $('meta[property="og:image"]').attr('content')
return { title, description, image }
}
Key Points:
- defineLoader: VitePress uses
defineLoader
to register a custom loader. Here, the loader watchesbookmarks.csv
and runs at build time. - Cheerio for Parsing: The
extractMetadata
function uses Cheerio to pull metadata from the HTML, allowing us to grab title, description, and image data.
Why This Works for Static Sites
Because VitePress runs this loader at build time, all metadata is already fetched, processed, and available in the generated static HTML files. This means no client-side fetching or processing is needed, which keeps the site lightweight, fast and secure.
Step 3: The Bookmark Component
Finally, we have the Bookmark.vue
component, which renders each bookmark card using the fetched metadata. It receives a name
prop, uses it to look up the bookmark’s metadata, and displays it in a styled card format.
Here’s the component code:
<script setup lang="ts">
import { computed } from 'vue'
import { data as bookmarks, type Bookmark } from './bookmarks.data.js'
const props = defineProps<{
name: string
}>()
const metadata = computed<Bookmark>(() => bookmarks[props.name])
</script>
<template>
<div v-if="metadata" class="my-2 w-full mx-auto">
<a
:href="metadata.url"
target="_blank"
rel="noopener noreferrer"
class="not-prose flex border border-gray-300 rounded-md transition duration-200 hover:bg-gray-100 h-36"
>
<div class="flex-1 p-4 flex flex-col justify-between overflow-hidden">
<h5 class="prose-heading text-md font-semibold text-gray-800 whitespace-nowrap overflow-hidden text-ellipsis">{{ metadata.title || 'Link' }}</h5>
<p class="text-sm text-gray-600 mt-1 flex-1 overflow-hidden">{{ metadata.description || 'No description available' }}</p>
<span class="link text-xs whitespace-nowrap overflow-hidden text-ellipsis">{{ metadata.url }}</span>
</div>
<img
v-if="metadata.image"
:src="metadata.image"
alt="Thumbnail"
class="h-full w-64 object-cover rounded-r-md m-0 hidden md:inline-block"
/>
</a>
</div>
</template>
Explanation:
- Props: The component takes a
name
prop, which it uses to access the corresponding bookmark data. - Computed Metadata: We use
computed
to retrieve metadata for the bookmark name provided. - Card Layout: The
<a>
tag serves as the clickable card, displaying the title, description, URL, and image if available. - Styling: The card is styled to look clean and compact, with hover effects to enhance interactivity.
Conclusion
Using a static setup for bookmarks is ideal for a VitePress site because it combines the benefits of metadata-rich links with the performance of static HTML. By pulling metadata at build time, I get the best of both worlds: enriched previews and quick load times.
If you're looking to add visually rich, static previews to your own blog, this approach provides a flexible, maintainable solution. For a live example, check out "Why I Love Frontend Masters" to see the Bookmark
component in action.