Files
xo-stack/packages/docs/src/.vitepress/theme/components/BlogCards.vue
T
2025-10-16 06:23:59 +00:00

205 lines
5.7 KiB
Vue

<template>
<div v-if="!posts.length" class="empty">
<p>No posts yet. Add Markdown files to <code>docs/src/blog/</code>.</p>
</div>
<div v-else class="grid">
<article v-for="p in posts" :key="p.url" class="card">
<div class="body">
<!-- date -->
<time v-if="p.dateText" class="date">{{ p.dateText }}</time>
<!-- title -->
<h2 class="title">
<a :href="p.url">{{ p.title }}</a>
</h2>
<!-- description -->
<p v-if="p.description" class="desc">{{ p.description }}</p>
<!-- author -->
<div v-if="p.author" class="author">
{{ p.author }}
</div>
<div class="spacer"></div>
<!-- tags at bottom (styled globally) -->
<div v-if="p.tags && p.tags.length" class="tags">
<span v-for="t in p.tags" :key="t" class="tag-badge VPButton brand">{{ t }}</span>
</div>
</div>
</article>
</div>
</template>
<script setup>
// Use the vite custom import to get each markdown file as a module
// with additional metadata from the markdown's frontmatter (if any).
// The modules are in a lookup according to their relative path.
const allMDModules = import.meta.glob('../../../blog/**/*.md', { eager: true })
function formatDate(d) {
// instanceof checks that we have a Date object
// isNaN checks validity of the Date object
if (!(d instanceof Date) || isNaN(d)) {
throw new Error(`argument is not a valid date object: ${d}`)
}
// JS Date doesn't have a great way to do this timezone aware so this manual method is common
// Note: getMonth() alone is 0-indexed so it has a +1
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const posts = Object.entries(allMDModules)
// Ignore index.md in the md module lookup
.filter(([p]) => !/[/\\]index\.md$/.test(p))
// Convert each path + md module into a well formed post object
.map(([p, mdModule]) => {
// Extract the frontmatter, which can be in different places depending on the vite setup
const frontmatter =
(mdModule && mdModule.frontmatter) ||
(mdModule && mdModule.__pageData && mdModule.__pageData.frontmatter) ||
{}
const normalizedPath = p.replace(/\\/g, '/')
const relativePath = normalizedPath.split('/blog/')[1] || ''
const slug = relativePath.replace(/\.md$/, '')
// We assume that the posts have a 'yyyy-mm-dd-' prefix and remove it.
const namePart = slug.replace(/^\d{4}-\d{2}-\d{2}-/, '')
// We use global regex replace to replace all hyphens, and regex to capitalize the first letter
const fallbackTitle = namePart.replace(/-/g, ' ').replace(/^\w/, c => c.toUpperCase())
// Extract the post author from the frontmatter
const postAuthor = frontmatter.author || null
// Extract the post date from either the frontmatter or the filename, in that order
const postDate = frontmatter.date ? new Date(frontmatter.date) : null
if ((postDate === null) || (isNaN(postDate))) {
throw new Error(`Blog posts are required to have a date in their frontmatter but did not find one for ${slug}`);
}
return {
url: '/blog/' + slug,
title: frontmatter.title || fallbackTitle,
dateText: postDate ? formatDate(postDate) : '',
description: frontmatter.description || '',
author: postAuthor,
tags: Array.isArray(frontmatter.tags) ? frontmatter.tags : [],
sort: postDate ? postDate.getTime() : 0
}
})
// Sort the blogs with most recent first
.sort((a, b) => b.sort - a.sort)
</script>
<style scoped>
/* responsive grid */
.grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 20px;
margin-top: 12px;
}
.card { grid-column: span 4; }
@media (max-width: 1100px) { .card { grid-column: span 6; } }
@media (max-width: 700px) { .card { grid-column: span 12; } }
/* compact, uniform-height cards */
.card {
display: flex;
flex-direction: column;
height: 240px; /* all cards same height */
border-radius: 12px;
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
overflow: hidden;
transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0,0,0,.06);
border-color: var(--vp-c-brand);
}
.body {
display: flex;
flex-direction: column;
padding: 12px 14px 12px;
flex: 1;
}
/* date */
.date {
font-size: 12px;
color: var(--vp-c-text-2);
margin-bottom: 4px;
}
.author {
font-size: 12px;
color: var(--vp-c-brand-1);
margin: 4px 0px;
font-style: italic;
}
/* title (clamped to 2 lines) */
.title {
margin: 0;
font-size: 16px;
line-height: 1.3;
font-weight: 600;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.title a { color: var(--vp-c-text-1); text-decoration: none; }
.title a:hover { text-decoration: underline; }
.vp-doc h2 {
padding-top: 8px !important; /* override default 24px */
}
/* description (clamped to 3 lines) */
.desc {
margin: 0;
font-size: 13px;
color: var(--vp-c-text-2);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* spacer pushes tags to bottom */
.spacer { flex: 1; }
/* tags row (visual style comes from your global CSS) */
/* tags row (visual style comes from your global CSS) */
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
text-transform: uppercase;
}
/* individual tag badge */
.tag-badge {
background: var(--vp-button-brand-bg);
color: #fff;
padding: 3px 8px;
border-radius: 6px; /* adds rounded corners */
font-size: 11px;
line-height: 1.2;
}
</style>