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() : ''