diff --git a/src/components/patterns/AnnouncementBar/AnnouncementBar.astro b/src/components/patterns/AnnouncementBar/AnnouncementBar.astro
index b8cbfce1c1..70453dafd1 100644
--- a/src/components/patterns/AnnouncementBar/AnnouncementBar.astro
+++ b/src/components/patterns/AnnouncementBar/AnnouncementBar.astro
@@ -12,6 +12,7 @@ import { Banner } from '@components/patterns';
import { BodyMd } from '@components/primitives';
import { getCollection } from 'astro:content';
import { getLangFromUrl, useTranslations } from '@i18n/utils';
+import { getPubDate, sortByPubDateDesc } from '@/utils/rss';
import announcement from '@/config/announcement.json';
type BannerVariant = 'info' | 'warning';
@@ -22,26 +23,15 @@ const useCustom = !!announcement.title;
function getLatestBlog() {
return getCollection('blog').then((posts) => {
- const sorted = posts
- .filter((post) => post.id !== 'write-post')
- .sort((a, b) => {
- const aFile = a.id.split('/').pop() ?? '';
- const bFile = b.id.split('/').pop() ?? '';
- return bFile.localeCompare(aFile);
- });
+ const sorted = sortByPubDateDesc(posts.filter((post) => post.id !== 'write-post'));
return sorted[0] ?? null;
});
}
const latestPost = !useCustom ? await getLatestBlog() : null;
-function getPostDate(id: string) {
- const filename = id.split('/').pop() ?? id;
- return filename.substring(0, 10);
-}
-
const blogSlug = latestPost ? latestPost.id.split('/').pop() : '';
-const blogDate = latestPost ? getPostDate(latestPost.id) : '';
+const blogDate = latestPost ? (getPubDate(latestPost)?.toISOString().substring(0, 10) ?? '') : '';
const blogTitle = latestPost?.data.title ?? '';
const blogDescription = latestPost?.data.description ?? '';
const blogVariant: BannerVariant = latestPost?.data.tags?.includes('security') ? 'warning' : 'info';
diff --git a/src/components/patterns/RelatedContent/RelatedContent.astro b/src/components/patterns/RelatedContent/RelatedContent.astro
index f8c931d41c..77b2d775cc 100644
--- a/src/components/patterns/RelatedContent/RelatedContent.astro
+++ b/src/components/patterns/RelatedContent/RelatedContent.astro
@@ -3,6 +3,7 @@ import type { CollectionEntry } from 'astro:content';
import './RelatedContent.css';
import { H2 } from '@/components/primitives';
import { PostCard } from '@/components/patterns';
+import { formatPubDate } from '@/utils/rss';
interface Props {
posts: CollectionEntry<'blog'>[];
@@ -20,11 +21,7 @@ const { posts, lang, title } = Astro.props;
{posts.map((p) => {
const pFilename = p.id.split('/').pop() ?? p.id;
- const pDate = new Date(pFilename.substring(0, 10)).toLocaleDateString('en-US', {
- month: 'long',
- day: 'numeric',
- year: 'numeric',
- });
+ const pDate = formatPubDate(p, { month: 'long', day: 'numeric', year: 'numeric' });
const pAuthors = (p.data.authors ?? []).map((a) => ({
src: a.github ? `https://github.com/${a.github}.png` : '',
name: a.name ?? '',
diff --git a/src/content.config.ts b/src/content.config.ts
index 9449b30173..09c4d6898c 100644
--- a/src/content.config.ts
+++ b/src/content.config.ts
@@ -31,6 +31,7 @@ const blogCollection = defineCollection({
schema: z.object({
title: z.string(),
description: z.string().optional(),
+ date: z.coerce.date().optional(),
tags: z.array(z.string()).optional(),
cover: z.string().optional(),
authors: z
diff --git a/src/content/blog/write-post.md b/src/content/blog/write-post.md
index df58aaf515..4470150657 100644
--- a/src/content/blog/write-post.md
+++ b/src/content/blog/write-post.md
@@ -20,7 +20,7 @@ If you have an idea for a blog post, follow these steps to propose it and potent
1. **Create a new file**
- Create a new file in the `src/content/blog` directory named using following the format: `YYYY-MM-DD-title.md`.
+ Create a new file in the `src/content/blog` directory named using following the format: `YYYY-MM-DD-title.md`. The date prefix is used as the publication date unless you set a `date` field in the front matter (see below).
1. **Add the required front matter**
@@ -30,6 +30,7 @@ If you have an idea for a blog post, follow these steps to propose it and potent
---
title:
description:
+ date: YYYY-MM-DD
tags: ['tag1', 'tag2']
authors:
- name:
@@ -40,6 +41,8 @@ If you have an idea for a blog post, follow these steps to propose it and potent
The `github` property of an author is optional. Including your username only (not your full profile URL) will ensure that your blog post links out to it.
+ The `date` property is optional. When set, it determines the post's publication date (used for ordering, display, and feeds); otherwise the `YYYY-MM-DD` prefix in the filename is used.
+
The `cover` property is optional. If omitted, an Open Graph image will be automatically generated from the post title. If you want a custom cover image, place it in the `public` directory and reference its path (e.g. `/images/my-cover.jpg`).
1. **Add your content**
diff --git a/src/pages/[lang]/blog/[...page].astro b/src/pages/[lang]/blog/[...page].astro
index fa1545f8d9..e8b88183fc 100644
--- a/src/pages/[lang]/blog/[...page].astro
+++ b/src/pages/[lang]/blog/[...page].astro
@@ -7,19 +7,14 @@ import type { TabItem } from '@components/primitives';
import { PostCard, PageHead, Pagination } from '@components/patterns';
import { getCollection, type CollectionEntry } from 'astro:content';
import type { PaginateFunction } from 'astro';
+import { formatPubDate, sortByPubDateDesc } from '@/utils/rss';
export async function getStaticPaths({ paginate }: { paginate: PaginateFunction }) {
const PAGE_SIZE = 6;
const allPosts = await getCollection('blog');
return Object.keys(languages).flatMap((lang) => {
- const langPosts = allPosts
- .filter((post) => post.id !== 'write-post')
- .sort((a, b) => {
- const aFile = a.id.split('/').pop() ?? '';
- const bFile = b.id.split('/').pop() ?? '';
- return bFile.localeCompare(aFile);
- });
+ const langPosts = sortByPubDateDesc(allPosts.filter((post) => post.id !== 'write-post'));
const allTags = [...new Set(langPosts.flatMap((p) => p.data.tags ?? []))];
@@ -35,15 +30,6 @@ const { page, allTags, langPosts, pageSize } = Astro.props;
const { lang } = Astro.params;
const pagePosts = page.data as CollectionEntry<'blog'>[];
-function getDate(id: string) {
- const filename = id.split('/').pop() ?? id;
- return new Date(filename.substring(0, 10)).toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- });
-}
-
function formatTagLabel(tag: string) {
const spaced = tag.replace(/-/g, ' ');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
@@ -63,7 +49,7 @@ function getPostProps(post: CollectionEntry<'blog'>) {
src: a.github ? `https://github.com/${a.github}.png` : '',
name: a.name ?? '',
})),
- avatarCaption: getDate(post.id),
+ avatarCaption: formatPubDate(post, { month: 'short', day: 'numeric', year: 'numeric' }),
};
}
diff --git a/src/pages/[lang]/blog/[slug].astro b/src/pages/[lang]/blog/[slug].astro
index db0a9e75e0..22ebf07d42 100644
--- a/src/pages/[lang]/blog/[slug].astro
+++ b/src/pages/[lang]/blog/[slug].astro
@@ -12,6 +12,7 @@ import {
WriteBanner,
RelatedContent,
} from '@/components/patterns';
+import { formatPubDate, sortByPubDateDesc } from '@/utils/rss';
export async function getStaticPaths() {
const posts = await getCollection('blog');
@@ -40,20 +41,12 @@ const authorsList = (authors ?? []).map((a) => ({
src: a.github ? `https://github.com/${a.github}.png` : '',
name: a.name ?? '',
}));
-const dateSource = filename.substring(0, 10);
-const parsedDate = new Date(dateSource);
-const date = Number.isNaN(parsedDate.getTime())
- ? undefined
- : parsedDate.toLocaleDateString('en-US', {
- month: 'long',
- day: 'numeric',
- year: 'numeric',
- });
+const date = formatPubDate(post, { month: 'long', day: 'numeric', year: 'numeric' });
const labels = tags ?? [];
-const allPosts = (await getCollection('blog'))
- .filter((p) => p.id !== post.id && p.id !== 'write-post')
- .sort((a, b) => b.id.localeCompare(a.id));
+const allPosts = sortByPubDateDesc(
+ (await getCollection('blog')).filter((p) => p.id !== post.id && p.id !== 'write-post')
+);
const currentTags = new Set(tags ?? []);
const tagged = allPosts.filter((p) => p.data.tags?.some((tag) => currentTags.has(tag)));
diff --git a/src/pages/feed.xml.ts b/src/pages/feed.xml.ts
index f75b4ccd19..3902d07ef8 100644
--- a/src/pages/feed.xml.ts
+++ b/src/pages/feed.xml.ts
@@ -4,15 +4,15 @@ import type { APIRoute } from 'astro';
import {
getLinkedTitleContent,
getAuthorsCustomData,
- getPubDateFromId,
+ getPubDate,
shouldIncludeInFeed,
- sortByIdDateDesc,
+ sortByPubDateDesc,
} from '@/utils/rss';
export const GET: APIRoute = async (context) => {
const site = context.site as URL;
const blog = await getCollection('blog');
- const sortedBlog = sortByIdDateDesc(blog.filter((post) => shouldIncludeInFeed(post.id)));
+ const sortedBlog = sortByPubDateDesc(blog.filter((post) => shouldIncludeInFeed(post.id)));
return rss({
title: 'The Express.js Blog',
@@ -21,7 +21,7 @@ export const GET: APIRoute = async (context) => {
items: sortedBlog.map((post) => ({
link: `/en/blog/${post.id}/`,
title: post.data.title,
- pubDate: getPubDateFromId(post.id),
+ pubDate: getPubDate(post),
description: post.data.description,
content: getLinkedTitleContent(post.data.title, post.id, site),
categories: post.data.tags,
diff --git a/src/pages/vulnerabilities.xml.ts b/src/pages/vulnerabilities.xml.ts
index ca30a51e1c..777b05bef6 100644
--- a/src/pages/vulnerabilities.xml.ts
+++ b/src/pages/vulnerabilities.xml.ts
@@ -4,15 +4,15 @@ import type { APIRoute } from 'astro';
import {
getLinkedTitleContent,
getAuthorsCustomData,
- getPubDateFromId,
+ getPubDate,
shouldIncludeInFeed,
- sortByIdDateDesc,
+ sortByPubDateDesc,
} from '@/utils/rss';
export const GET: APIRoute = async (context) => {
const site = context.site as URL;
const blog = await getCollection('blog');
- const securityPosts = sortByIdDateDesc(
+ const securityPosts = sortByPubDateDesc(
blog.filter(
(post) => shouldIncludeInFeed(post.id) && (post.data.tags ?? []).includes('security')
)
@@ -25,7 +25,7 @@ export const GET: APIRoute = async (context) => {
items: securityPosts.map((post) => ({
link: `/en/blog/${post.id}/`,
title: post.data.title,
- pubDate: getPubDateFromId(post.id),
+ pubDate: getPubDate(post),
description: post.data.description,
content: getLinkedTitleContent(post.data.title, post.id, site),
categories: post.data.tags,
diff --git a/src/utils/rss.ts b/src/utils/rss.ts
index dc3ba1dae7..b8de613fa6 100644
--- a/src/utils/rss.ts
+++ b/src/utils/rss.ts
@@ -3,6 +3,11 @@ export interface FeedAuthor {
github?: string;
}
+export interface DatedPost {
+ id: string;
+ data?: { date?: Date | string };
+}
+
export function getPubDateFromId(id: string): Date | undefined {
const filename = id.split('/').pop() ?? id;
const match = filename.match(/^(\d{4}-\d{2}-\d{2})/);
@@ -12,6 +17,32 @@ export function getPubDateFromId(id: string): Date | undefined {
return Number.isNaN(date.getTime()) ? undefined : date;
}
+/**
+ * Resolves a post's publication date, preferring the `date` front matter field
+ * and falling back to the `YYYY-MM-DD` prefix in the filename.
+ */
+export function getPubDate(post: DatedPost): Date | undefined {
+ const frontmatter = post.data?.date;
+ if (frontmatter) {
+ const date = frontmatter instanceof Date ? frontmatter : new Date(frontmatter);
+ if (!Number.isNaN(date.getTime())) return date;
+ }
+ return getPubDateFromId(post.id);
+}
+
+/**
+ * Formats a post's publication date for display. Calendar dates are formatted in
+ * UTC so the rendered day matches the authored date regardless of the viewer's
+ * timezone. Returns undefined when no valid date can be resolved.
+ */
+export function formatPubDate(
+ post: DatedPost,
+ options: Intl.DateTimeFormatOptions
+): string | undefined {
+ const date = getPubDate(post);
+ return date?.toLocaleDateString('en-US', { timeZone: 'UTC', ...options });
+}
+
export function getAuthorsCustomData(authors?: FeedAuthor[]): string | undefined {
if (!authors?.length) return undefined;
@@ -23,10 +54,10 @@ export function getAuthorsCustomData(authors?: FeedAuthor[]): string | undefined
.join('');
}
-export function sortByIdDateDesc(items: T[]): T[] {
+export function sortByPubDateDesc(items: T[]): T[] {
return [...items].sort((a, b) => {
- const aTime = getPubDateFromId(a.id)?.getTime() ?? 0;
- const bTime = getPubDateFromId(b.id)?.getTime() ?? 0;
+ const aTime = getPubDate(a)?.getTime() ?? 0;
+ const bTime = getPubDate(b)?.getTime() ?? 0;
if (aTime === bTime) return a.id.localeCompare(b.id);
return bTime - aTime;