205 lines
5.7 KiB
Vue
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>
|