From 31dbf56235f0dd5e58736195de45b12bef7393df Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 25 Aug 2026 15:30:57 +0200 Subject: [PATCH] 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. --- .../article/content-types/article/schema.json | 3 ++- frontend/app/pages/blog/[slug].vue | 15 ++++++++--- frontend/i18n/locales/en.json | 3 ++- frontend/i18n/locales/es.json | 3 ++- frontend/i18n/locales/fr.json | 3 ++- frontend/i18n/locales/it.json | 3 ++- frontend/server/api/articles/[slug].get.ts | 25 ++++++++++++++++--- frontend/server/utils/queries.ts | 3 +++ frontend/shared/types/blog.ts | 2 ++ frontend/shared/utils/format.ts | 7 ++++++ 10 files changed, 56 insertions(+), 11 deletions(-) diff --git a/cms/src/api/article/content-types/article/schema.json b/cms/src/api/article/content-types/article/schema.json index 247355f..488a18e 100644 --- a/cms/src/api/article/content-types/article/schema.json +++ b/cms/src/api/article/content-types/article/schema.json @@ -8,7 +8,8 @@ "description": "Blog article" }, "options": { - "draftAndPublish": true + "draftAndPublish": true, + "populateCreatorFields": true }, "attributes": { "title": { diff --git a/frontend/app/pages/blog/[slug].vue b/frontend/app/pages/blog/[slug].vue index 9e4638c..07380e0 100644 --- a/frontend/app/pages/blog/[slug].vue +++ b/frontend/app/pages/blog/[slug].vue @@ -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({

+

@@ -90,7 +97,9 @@ useHead({ diff --git a/frontend/i18n/locales/en.json b/frontend/i18n/locales/en.json index 39dea65..536d359 100644 --- a/frontend/i18n/locales/en.json +++ b/frontend/i18n/locales/en.json @@ -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", diff --git a/frontend/i18n/locales/es.json b/frontend/i18n/locales/es.json index 7968097..5ce291b 100644 --- a/frontend/i18n/locales/es.json +++ b/frontend/i18n/locales/es.json @@ -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", diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 36dd80f..c81150a 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -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", diff --git a/frontend/i18n/locales/it.json b/frontend/i18n/locales/it.json index 435ad74..0b46717 100644 --- a/frontend/i18n/locales/it.json +++ b/frontend/i18n/locales/it.json @@ -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", diff --git a/frontend/server/api/articles/[slug].get.ts b/frontend/server/api/articles/[slug].get.ts index 7e5ad32..e8a4cb3 100644 --- a/frontend/server/api/articles/[slug].get.ts +++ b/frontend/server/api/articles/[slug].get.ts @@ -1,6 +1,20 @@ import type { Article } from '#shared/types/blog' -type RawArticle = Omit & { content: string | null } +interface AdminUser { + firstname: string | null + lastname: string | null +} + +type RawArticle = Omit & { + 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
=> { const slug = getRouterParam(event, 'slug') @@ -19,6 +33,11 @@ export default defineEventHandler(async (event): Promise
=> { 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), + } }) diff --git a/frontend/server/utils/queries.ts b/frontend/server/utils/queries.ts index 260c796..b41cb3b 100644 --- a/frontend/server/utils/queries.ts +++ b/frontend/server/utils/queries.ts @@ -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 diff --git a/frontend/shared/types/blog.ts b/frontend/shared/types/blog.ts index 2fcb710..86a3680 100644 --- a/frontend/shared/types/blog.ts +++ b/frontend/shared/types/blog.ts @@ -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 { diff --git a/frontend/shared/utils/format.ts b/frontend/shared/utils/format.ts index 52e369a..88e2ba8 100644 --- a/frontend/shared/utils/format.ts +++ b/frontend/shared/utils/format.ts @@ -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() : ''