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)
This commit is contained in:
@@ -234,7 +234,8 @@ The layer ships `i18n/locales/en.json` and `pl.json` under the `journal.*` key t
|
||||
|
||||
The page head comes from the CMS: vue-seo-layer resolves every page through the SEO plugin and sets the title, description, canonical, robots, Open Graph, hreflang and the one JSON-LD `@graph` from the answer, with priority over the page's own tags. The layer no longer installs `@nuxtjs/seo` (no nuxt-site-config, schema.org, robots or sitemap module, no `site.url`/`site.name` title template). The sitemaps and robots.txt come from the SEO and sitemap plugins through vue-seo-layer's root files.
|
||||
|
||||
- **Fallback meta.** While a post page has no CMS answer (the SEO layer off with `seo: { cms: false }`, the CMS unreachable, or a page the CMS does not know), it sets the post title, the excerpt (or the first 160 characters of the body) as description, the featured image as `og:image` and the article times. Listing pages set their title (`· Page N` after page 1). An app that wants a site-name suffix there sets `app.head.titleTemplate`.
|
||||
- **Fallback meta.** While a post page has no CMS answer (the SEO layer off with `seo: { cms: false }`, the CMS unreachable, or a page the CMS does not know), it sets the post title, the excerpt (or the first 160 characters of the body) as description and the featured image as `og:image`. Listing pages set their title (`· Page N` after page 1). An app that wants a site-name suffix there sets `app.head.titleTemplate`.
|
||||
- **Article meta.** Every post page sets `og:type` `article`, `article:published_time` (`published_at`) and `article:modified_time` (`updated_at`) from the post, whether or not the CMS answers: the CMS head never carries them (`journalArticleMeta`). The CMS-owned keys (title, description, `og:title`, `og:description`, `og:image`) come from the CMS when it answers.
|
||||
- **Canonical.** From the CMS. An author page reached by login points its canonical at the slug URL when the CMS answer has none.
|
||||
- **ItemList.** The index, category, tag and author pages add an `ItemList` node (`@id` `<page URL>#itemlist`, one `ListItem` with the absolute URL of each post shown, positions continuing across pages) through vue-seo-layer's `useSchemaNode` (`useJournalItemList`), so the page keeps one JSON-LD script.
|
||||
- **Body links.** `JournalPostBody` renders the body through `renderJournalBody` in the UI-SPEC L7 order: the sanitizer (with H1 demotion), vue-seo-layer's `autolink` with the resolve answer's `autolinks` and `autolink_max` (first occurrence, whole words, never in headings, links, code, figure captions or buttons, never to the page itself), `applyOutbound` with the answer's `outbound` (the editor's `rel` kept, `nofollow` or `sponsored` from the domain lists; `target="_blank"`, `rel="noopener"` and a `<span class="seo-visually-hidden">` hint only while `new_tab` is on, the hint while vue-seo-layer's `seo.links.newTabHint` is on, its default) and `imageAttrs` (`loading="lazy"`, `decoding="async"`, stored width, height and `alt=""` kept). Internal links are untouched.
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
//
|
||||
// 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.
|
||||
import { cmsHeadPresent, journalPostTitleTag, journalSiteOrigin, postSeoMeta } from '../utils/seo.ts'
|
||||
// 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'
|
||||
@@ -51,15 +53,18 @@ 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,
|
||||
ogType: () => fallback.value?.ogType,
|
||||
ogImage: () => fallback.value?.ogImage,
|
||||
articlePublishedTime: () => fallback.value?.articlePublishedTime,
|
||||
articleModifiedTime: () => fallback.value?.articleModifiedTime,
|
||||
// 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))
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// only while there is no CMS answer for it (the SEO layer off with seo.cms
|
||||
// false, the CMS unreachable, or a page the CMS does not know), so a site
|
||||
// without the SEO plugin keeps its titles and no page gets two of them.
|
||||
// The keys the CMS never sets (og:type, article:*) come from
|
||||
// journalArticleMeta on every post page.
|
||||
|
||||
import type { JournalPost } from './types.ts'
|
||||
|
||||
@@ -67,6 +69,19 @@ export function postSeoMeta(post: JournalPost, siteUrl?: string | null): Journal
|
||||
return meta
|
||||
}
|
||||
|
||||
/**
|
||||
* The post-page head keys the CMS head never carries (vue-seo-layer writes
|
||||
* no og:type and no article:* tags): og:type article and the publish and
|
||||
* update times, set on every post page whether or not the CMS answers
|
||||
* (L4 WR-02). A missing time is left out.
|
||||
*/
|
||||
export function journalArticleMeta(post: Pick<JournalPost, 'published_at' | 'updated_at'>): Pick<JournalPostSeo, 'ogType' | 'articlePublishedTime' | 'articleModifiedTime'> {
|
||||
const meta: Pick<JournalPostSeo, 'ogType' | 'articlePublishedTime' | 'articleModifiedTime'> = { ogType: 'article' }
|
||||
if (post.published_at) meta.articlePublishedTime = post.published_at
|
||||
if (post.updated_at) meta.articleModifiedTime = post.updated_at
|
||||
return meta
|
||||
}
|
||||
|
||||
/** True when the resolve answer sets the page head (an ok answer with head values). */
|
||||
export function cmsHeadPresent(answer: { action?: string; seo?: unknown } | null | undefined): boolean {
|
||||
return answer?.action === 'ok' && !!answer.seo && typeof answer.seo === 'object'
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
200 /blog/heading-on contains=<title>Heading On Sample | Acme Blog</title> count=<title>:1
|
||||
# Without a CMS answer the post's own title and description are the fallback.
|
||||
200 /blog/uncategorised-note contains=<title>Uncategorised Note</title> contains=Uncategorised Note: a short summary.
|
||||
# og:type article and the article times on every post page, with a CMS
|
||||
# answer (heading-on) and without one (uncategorised-note): the CMS head
|
||||
# never carries them (L4 WR-02).
|
||||
200 /blog/heading-on count=property="og:type":1 contains=<meta property="og:type" content="article"> contains=<meta property="article:published_time" content="2025-12-20T09:00:00+00:00"> contains=<meta property="article:modified_time" content="2025-12-20T09:00:00+00:00">
|
||||
200 /blog/uncategorised-note count=property="og:type":1 contains=<meta property="og:type" content="article"> contains=<meta property="article:published_time" content="2026-01-02T09:00:00+00:00">
|
||||
|
||||
# Single post
|
||||
200 /blog/hello-acme contains=Hello Acme
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// list titles.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { absoluteUrl, cmsHeadPresent, htmlToText, journalItemList, journalPostTitleTag, journalSiteOrigin, listTitle, postSeoMeta, truncateText } from '../app/utils/seo.ts'
|
||||
import { absoluteUrl, cmsHeadPresent, htmlToText, journalArticleMeta, journalItemList, journalPostTitleTag, journalSiteOrigin, listTitle, postSeoMeta, truncateText } from '../app/utils/seo.ts'
|
||||
import type { JournalPost } from '../app/utils/types.ts'
|
||||
|
||||
function post(extra: Partial<JournalPost> = {}): JournalPost {
|
||||
@@ -82,6 +82,16 @@ test('article times come from published_at and updated_at', () => {
|
||||
assert.equal(bare.articleModifiedTime, undefined)
|
||||
})
|
||||
|
||||
test('journalArticleMeta: og:type article and the post times, which the CMS never sets (L4 WR-02)', () => {
|
||||
assert.deepEqual(journalArticleMeta(post()), {
|
||||
ogType: 'article',
|
||||
articlePublishedTime: '2024-06-12T06:00:00+00:00',
|
||||
articleModifiedTime: '2024-06-13T06:00:00+00:00',
|
||||
})
|
||||
assert.deepEqual(journalArticleMeta(post({ published_at: null, updated_at: null })), { ogType: 'article' })
|
||||
assert.deepEqual(journalArticleMeta(post({ updated_at: null })), { ogType: 'article', articlePublishedTime: '2024-06-12T06:00:00+00:00' })
|
||||
})
|
||||
|
||||
test('absoluteUrl passes through absolute URLs and a missing site URL', () => {
|
||||
assert.equal(absoluteUrl('a.png', 'https://x.test'), 'https://x.test/a.png')
|
||||
assert.equal(absoluteUrl('HTTPS://y.test/a.png', 'https://x.test'), 'HTTPS://y.test/a.png')
|
||||
|
||||
Reference in New Issue
Block a user