diff --git a/README.md b/README.md index 82f7551..dff0373 100644 --- a/README.md +++ b/README.md @@ -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` `#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 `` 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. diff --git a/app/journal-pages/PostPage.vue b/app/journal-pages/PostPage.vue index 7a0e26b..41641a3 100644 --- a/app/journal-pages/PostPage.vue +++ b/app/journal-pages/PostPage.vue @@ -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)) diff --git a/app/utils/seo.ts b/app/utils/seo.ts index 90c5b2e..0c707f9 100644 --- a/app/utils/seo.ts +++ b/app/utils/seo.ts @@ -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): Pick { + const meta: Pick = { 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' diff --git a/testing/expect-acme.txt b/testing/expect-acme.txt index 48bf057..6c4eda9 100644 --- a/testing/expect-acme.txt +++ b/testing/expect-acme.txt @@ -14,6 +14,11 @@ 200 /blog/heading-on contains=Heading On Sample | Acme Blog count=:1 # Without a CMS answer the post's own title and description are the fallback. 200 /blog/uncategorised-note contains=<title>Uncategorised Note 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= contains= contains= +200 /blog/uncategorised-note count=property="og:type":1 contains= contains= # Single post 200 /blog/hello-acme contains=Hello Acme diff --git a/tests/seo.test.ts b/tests/seo.test.ts index b76d487..4a671de 100644 --- a/tests/seo.test.ts +++ b/tests/seo.test.ts @@ -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 { @@ -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')