Files
vue-journal-layer/app/journal-pages/PostPage.vue
Jakub Zych 839db5156c fix: post pages keep og:type article and their article times
- journalArticleMeta(post) answers og:type article and the publish and
  update times; PostPage always sets them, the CMS-owned keys keep the
  no-answer fallback (L4 WR-02)
- expect-acme.txt: one og:type article and the article times with a CMS
  answer (heading-on) and without one (uncategorised-note)
2026-11-04 22:51:31 +01:00

109 lines
4.9 KiB
Vue

<script setup lang="ts">
// journal-post: one published post by slug. An unknown, draft or future post
// is a real 404. The transform sanitizes the body before it reaches the payload.
// Related posts fall back to the latest posts when the API has none (it
// returns none for a post without a category).
//
// One H1 per page in both Journal H1 modes (UI-SPEC L6): while the Journal
// API reports content_h1_to_h2 on (or leaves it out), the title is the h1 and
// the body's h1 headings become h2; while it is off, the title is a
// p.journal-post__title (same class, same look) and the body's own h1 is the
// page's H1. journal.postTitleTag 'h1' or 'p' forces the title's element.
//
// The head comes from the CMS through vue-seo-layer; the post's own title,
// excerpt and featured image apply only while there is no CMS answer.
// og:type article and the article times, which the CMS never sets, are
// always the post's (L4 WR-02).
import { cmsHeadPresent, journalArticleMeta, journalPostTitleTag, journalSiteOrigin, postSeoMeta } from '../utils/seo.ts'
import { demotesH1, postNeighbours, toPostView } from '../utils/postView.ts'
import { journalBreadcrumbsOn, toListPost } from '../utils/view.ts'
import type { JournalModuleOptions } from '../utils/types.ts'
const route = useRoute()
const api = useJournalApi()
const links = useJournalLinks()
const cfg = useRuntimeConfig().public.journal as JournalModuleOptions
const slug = computed(() => String(route.params.slug ?? ''))
const { data: post, error } = await useAsyncData(
() => `journal-post-${slug.value}`,
async () => {
const found = await api.getPost(slug.value)
if (!found) return null
const count = Math.max(0, cfg.relatedCount)
let related = found.related_posts ?? []
if (related.length === 0 && count > 0) {
const latest = await api.listPosts({ perPage: count + 1, sort: cfg.sort })
related = latest.data.filter((p) => p.slug !== found.slug)
}
return { ...found, related_posts: related.slice(0, count).map(toListPost) }
},
{ transform: (p) => (p ? toPostView(p) : null) },
)
if (error.value) {
throw createError({ statusCode: (error.value as { statusCode?: number }).statusCode ?? 500, fatal: true })
}
if (!post.value) {
throw createError({ statusCode: 404, statusMessage: 'Post not found', fatal: true })
}
const answer = useSeoAnswer()
const requestOrigin = useRequestURL().origin
const fallback = computed(() =>
post.value && !cmsHeadPresent(answer.value) ? postSeoMeta(post.value, journalSiteOrigin(answer.value?.url, requestOrigin)) : null,
)
const article = computed(() => (post.value ? journalArticleMeta(post.value) : null))
useSeoMeta({
// CMS-owned keys: the post's values only while the CMS has no answer.
title: () => fallback.value?.title,
ogTitle: () => fallback.value?.ogTitle,
description: () => fallback.value?.description,
ogDescription: () => fallback.value?.ogDescription,
ogImage: () => fallback.value?.ogImage,
// Keys the CMS never sets: always the post's.
ogType: () => article.value?.ogType,
articlePublishedTime: () => article.value?.articlePublishedTime,
articleModifiedTime: () => article.value?.articleModifiedTime,
})
const titleTag = computed(() => journalPostTitleTag(cfg.postTitleTag, post.value?.content_h1_to_h2))
const demoteH1 = computed(() => demotesH1(post.value))
const crumbs = journalBreadcrumbsOn(cfg.breadcrumbs)
// previous_in_category / next_in_category (the Journal's opt-in scored
// mode) win over the site-wide neighbours when the Journal answers one.
const siblings = computed(() => (post.value ? postNeighbours(post.value) : { previous: null, next: null, inCategory: false }))
</script>
<template>
<article v-if="post" class="journal-post">
<SeoBreadcrumbs v-if="crumbs" />
<header class="journal-post__header">
<component :is="titleTag" class="journal-post__title">{{ post.title }}</component>
<JournalPostMeta :post="post" />
</header>
<JournalFeaturedImage :src="post.featured_image_url" :image="post.featured_images?.[0]" :alt="post.title" sizes="(min-width: 46rem) 46rem, 100vw" eager />
<JournalPostBody :html="post.content_html" :demote-h1="demoteH1" />
<nav v-if="siblings.previous || siblings.next" class="journal-post__siblings">
<NuxtLink
v-if="siblings.previous && links.postLink(siblings.previous.slug)"
:to="links.postLink(siblings.previous.slug)!"
rel="prev"
class="journal-post__previous"
>
<span>{{ $t('journal.post.previous') }}</span> {{ siblings.previous.title }}
</NuxtLink>
<NuxtLink
v-if="siblings.next && links.postLink(siblings.next.slug)"
:to="links.postLink(siblings.next.slug)!"
rel="next"
class="journal-post__next"
>
<span>{{ $t('journal.post.next') }}</span> {{ siblings.next.title }}
</NuxtLink>
</nav>
<JournalRelatedPosts :posts="post.related_posts" />
<JournalRssLink />
</article>
</template>