Show the author and publication time on articles

Strapi already records which admin user created an entry, so the byline needs
no extra field to fill in: the Article content type sets populateCreatorFields
and the API layer exposes only that user's firstname and lastname. The name
also feeds the BlogPosting structured data.

Also fixes the gap below the article header, which until now came from the
cover image's margin and therefore vanished on articles without a cover,
leaving the body text against the byline.
This commit is contained in:
2026-08-25 15:30:57 +02:00
parent c08f53ca69
commit 31dbf56235
10 changed files with 56 additions and 11 deletions
@@ -8,7 +8,8 @@
"description": "Blog article"
},
"options": {
"draftAndPublish": true
"draftAndPublish": true,
"populateCreatorFields": true
},
"attributes": {
"title": {
+12 -3
View File
@@ -39,6 +39,9 @@ useHead({
headline: article.value.title,
description: article.value.summary,
datePublished: isoDate(article.value.publishedAt),
author: article.value.author
? { '@type': 'Person', name: article.value.author }
: undefined,
image: cover.value ? [cover.value] : undefined,
mainEntityOfPage: { '@type': 'WebPage', '@id': canonical },
}),
@@ -64,8 +67,12 @@ useHead({
<p class="kicker date">
<time :datetime="isoDate(article.publishedAt)">
{{ formatDate(article.publishedAt, locale) }}
{{ formatDateTime(article.publishedAt, locale) }}
</time>
<template v-if="article.author">
<span aria-hidden="true"> · </span>
<span>{{ $t('article.by', { name: article.author }) }}</span>
</template>
</p>
</header>
@@ -90,7 +97,9 @@ useHead({
<style scoped>
.article-header {
max-width: 46rem;
margin-inline: auto;
/* The gap below the header lives here, not on the cover: an article without
a cover image would otherwise have its body butting against the byline. */
margin: 0 auto 3rem;
text-align: center;
}
@@ -139,7 +148,7 @@ h1 {
width: 100%;
aspect-ratio: 3 / 2;
object-fit: cover;
margin-block: 3rem;
margin-block: 0 3rem;
background: var(--color-surface);
}
</style>
+2 -1
View File
@@ -38,7 +38,8 @@
},
"article": {
"breadcrumb": "Breadcrumb",
"back": "Back to all articles"
"back": "Back to all articles",
"by": "By {name}"
},
"error": {
"notFoundTitle": "Page not found",
+2 -1
View File
@@ -38,7 +38,8 @@
},
"article": {
"breadcrumb": "Ruta de navegación",
"back": "Volver a todos los artículos"
"back": "Volver a todos los artículos",
"by": "Por {name}"
},
"error": {
"notFoundTitle": "Página no encontrada",
+2 -1
View File
@@ -38,7 +38,8 @@
},
"article": {
"breadcrumb": "Fil d'Ariane",
"back": "Retour à tous les articles"
"back": "Retour à tous les articles",
"by": "Par {name}"
},
"error": {
"notFoundTitle": "Page introuvable",
+2 -1
View File
@@ -38,7 +38,8 @@
},
"article": {
"breadcrumb": "Percorso di navigazione",
"back": "Torna a tutti gli articoli"
"back": "Torna a tutti gli articoli",
"by": "Di {name}"
},
"error": {
"notFoundTitle": "Pagina non trovata",
+22 -3
View File
@@ -1,6 +1,20 @@
import type { Article } from '#shared/types/blog'
type RawArticle = Omit<Article, 'html' | 'summary'> & { content: string | null }
interface AdminUser {
firstname: string | null
lastname: string | null
}
type RawArticle = Omit<Article, 'html' | 'summary' | 'author'> & {
content: string | null
createdBy?: AdminUser | null
}
/** Joins the admin user's name parts; returns null when neither is set. */
function byline(user: AdminUser | null | undefined): string | null {
const name = [user?.firstname, user?.lastname].filter(Boolean).join(' ').trim()
return name || null
}
export default defineEventHandler(async (event): Promise<Article> => {
const slug = getRouterParam(event, 'slug')
@@ -19,6 +33,11 @@ export default defineEventHandler(async (event): Promise<Article> => {
throw createError({ statusCode: 404, statusMessage: 'Article not found' })
}
const { content, ...rest } = article
return { ...rest, html: renderMarkdown(content), summary: summarise(content) }
const { content, createdBy, ...rest } = article
return {
...rest,
html: renderMarkdown(content),
summary: summarise(content),
author: byline(createdBy),
}
})
+3
View File
@@ -16,6 +16,9 @@ export const ARTICLE_DETAIL_QUERY = [
list('fields', ['title', 'slug', 'content', 'publishedAt']),
list('populate[cover][fields]', IMAGE_FIELDS),
list('populate[category][fields]', ['name', 'slug']),
// The byline is the admin user who created the entry; Strapi exposes the
// name fields only because the content type sets populateCreatorFields.
list('populate[createdBy][fields]', ['firstname', 'lastname']),
].join('&')
export const PAGE_SIZE = 12
+2
View File
@@ -26,6 +26,8 @@ export interface Article extends ArticleSummary {
html: string
/** Plain-text opening of the body, used as the meta description. */
summary: string
/** Byline: the full name of the admin user who wrote the article. */
author: string | null
}
export interface Paginated<T> {
+7
View File
@@ -4,6 +4,13 @@ export function formatDate(value: string | null | undefined, locale = 'en-GB'):
return new Intl.DateTimeFormat(locale, { dateStyle: 'long' }).format(new Date(value))
}
/** Date and time of publication, shown in the article byline. */
export function formatDateTime(value: string | null | undefined, locale = 'en-GB'): string {
if (!value) return ''
return new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeStyle: 'short' })
.format(new Date(value))
}
/** Machine-readable date for the `datetime` attribute and structured data. */
export function isoDate(value: string | null | undefined): string {
return value ? new Date(value).toISOString() : ''