Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 11 additions & 72 deletions app/composables/useBlogImages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,80 +2,19 @@ import type { BlogArticle } from '~/types'

export const useBlogImages = () => {
/**
* Get the OG image URL for a blog post
* Falls back to frontmatter image if OG image fails
* Merge articles with caller-supplied OG-image URLs (frontmatter `image:`
* wins). Callers obtain the OG paths from `defineOgImage()` in their own
* page setup — that's the only way to get a URL the prerender will actually
* write. Indexes must align with the `articles` array.
*/
const getBlogPostImage = (article: BlogArticle): string => {
// If the article has a custom image in frontmatter, use it
if (article.image) {
return article.image
}

// Otherwise, use the auto-generated OG image
return getOgImageUrl(article)
}

/**
* Get the generated OG image URL for a blog post.
*
* v6 encodes the render options into the static file path, so this must use
* the same component + props that blog/[slug].vue passes to defineOgImage()
* ('Blog' with minimal { title, category }). getOgImagePath() is auto-imported
* by nuxt-og-image and joins the app baseURL for us.
*/
const getOgImageUrl = (article: BlogArticle): string => {
const { path } = getOgImagePath(article.path, {
component: 'Blog',
props: { blog: { title: article.title, category: article.category } },
})

// getOgImagePath() yields the runtime /_og/d/ prefix when not prerendering
// (e.g. this composable re-running during client-side navigation), which
// 404s on the static deploy. Force the prerendered /_og/s/ path.
return toStaticOgPath(path)
}

/**
* Get blog post image with fallback handling
* Returns OG image URL, or frontmatter image, or placeholder
*/
const getBlogPostImageWithFallback = (article: BlogArticle): string => {
// Priority order:
// 1. Custom image from frontmatter
// 2. Generated OG image
// 3. Placeholder image

if (article.image && article.image !== '') {
return article.image
}

// Use generated OG image as primary choice
return getOgImageUrl(article)
}

/**
* Process articles to ensure they have image URLs
*/
const enrichArticlesWithImages = (articles: BlogArticle[]): BlogArticle[] => {
return articles.map((article) => ({
const enrichArticlesWithImages = (
articles: BlogArticle[],
ogPaths: readonly string[],
): BlogArticle[] =>
articles.map((article, i) => ({
...article,
// Use OG image if no image is specified
image: getBlogPostImageWithFallback(article),
image: article.image || ogPaths[i],
}))
}

/**
* Check if an article should use OG image
*/
const shouldUseOgImage = (article: BlogArticle): boolean => {
return !article.image || article.image === ''
}

return {
getBlogPostImage,
getOgImageUrl,
getBlogPostImageWithFallback,
enrichArticlesWithImages,
shouldUseOgImage,
}
return { enrichArticlesWithImages }
}
24 changes: 14 additions & 10 deletions app/pages/blog/[slug].vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,25 +41,29 @@ useSeoMeta({
twitterDescription: description,
})

// Capture the OG path used for this page's social card and reuse it for the
// JSON-LD image — same defineOgImage call drives both, so the URL is
// guaranteed to point at a file the prerender actually wrote.
let articleOgPath: string | undefined
if (article.value.image) {
useSeoMeta({ ogImage: article.value.image })
} else {
// Minimal props: the Blog template only renders title + category. Keeping
// the options small keeps the generated URL under v6's 200-char path limit
// so it stays deterministic and matches the thumbnail built in useBlogImages.
defineOgImage('Blog', {
// Minimal props: the Blog template only renders title + category.
const paths = defineOgImage('Blog', {
blog: { title: article.value.title, category: article.value.category },
})
articleOgPath = paths[0]
}

// Article image. Schema-org resolves a root-relative path by prepending the app
// baseURL exactly once (verified with the logo). The generated og-image path
// already includes the baseURL, so strip it first — otherwise the JSON-LD image
// doubles to /litestar.dev-v2/litestar.dev-v2/… under the GitHub Pages subpath.
const { getOgImageUrl } = useBlogImages()
// Schema-org resolves a root-relative path by prepending the app baseURL
// exactly once (verified with the logo). The og-image path already includes
// the baseURL, so strip it first — otherwise the JSON-LD image doubles to
// /litestar.dev-v2/litestar.dev-v2/… under the GitHub Pages subpath.
const articleImage = article.value.image
? article.value.image
: withoutBase(getOgImageUrl(article.value), config.app.baseURL)
: articleOgPath
? withoutBase(articleOgPath, config.app.baseURL)
: undefined

// BlogPosting JSON-LD derived from the post frontmatter. Authors map to Person nodes.
useSchemaOrg([
Expand Down
32 changes: 26 additions & 6 deletions app/pages/blog/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,39 @@ useSeoMeta({
twitterDescription: page.description,
})

// Listing's own social card. Called first so it claims the default 'og' key
// and stays the primary <meta property="og:image"> on this page.
defineOgImage('Page', {
title: page.title,
description: page.description,
})

// Enrich articles with OG images when no image is specified
const articlesWithImages = computed(() => {
return enrichArticlesWithImages(articles.value || [])
})

await fetchList()

// console.log('ARTICLES:', articlesWithImages.value)
// Per-article OG paths via the same public API the post page uses — so the
// URLs match the prerendered files (the hash drops _path before computing:
// node_modules/nuxt-og-image/dist/runtime/shared/urlEncoding.js:97). Distinct
// keys keep each call as its own payload entry instead of overwriting the
// 'Page' card or earlier per-article entries (utils.js:67-77). useState
// rehydrates the SSR result so the computed below produces stable values on
// the client (defineOgImage returns [] during hydration; _defineOgImageRaw.js:19-20).
const blogOgPaths = useState<string[]>('blog-og-paths', () =>
import.meta.server
? defineOgImage(
'Blog',
(articles.value || []).map((article, i) => ({
props: {
blog: { title: article.title, category: article.category },
},
key: `blog-${i}`,
})),
)
: [],
)

const articlesWithImages = computed(() =>
enrichArticlesWithImages(articles.value || [], blogOgPaths.value),
)
</script>

<template>
Expand Down
17 changes: 0 additions & 17 deletions app/utils/ogImage.ts

This file was deleted.

70 changes: 68 additions & 2 deletions test/build/meta.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { readdir, readFile } from 'node:fs/promises'
import { join, relative } from 'node:path'
import { describe, expect, it } from 'vitest'

// Resolves to the generated static deploy artifact. `nuxt generate` writes to
Expand Down Expand Up @@ -105,3 +105,69 @@ describe('Built pages have required OG/SEO meta', () => {
}
})
})

// Catches the class of bug where a consumer (listing <img>, JSON-LD image, etc.)
// predicts an /_og/s/o_<hash>.png URL that the producer never wrote — i.e. the
// hash inputs drifted between defineOgImage and the predictor. nuxt-og-image
// logs "Skipped N orphaned OG image hash URL" during prerender for these, but
// doesn't fail the build. This test does.
async function walkHtml(dir: string): Promise<string[]> {
const out: string[] = []
for (const entry of await readdir(dir, { withFileTypes: true })) {
const p = join(dir, entry.name)
if (entry.isDirectory()) out.push(...(await walkHtml(p)))
else if (entry.name.endsWith('.html')) out.push(p)
}
return out
}

function extractStaticOgRefs(html: string, base: string): string[] {
const refs = new Set<string>()
// Any quoted attribute carrying an /_og/s/o_<hash>.png path (covers <img src>,
// <link href>, <meta content>, srcset entries, etc.).
for (const m of html.matchAll(/["']([^"']*\/_og\/s\/o_[^"']+\.png)["']/g)) {
refs.add(m[1]!)
}
// JSON-LD blocks — walk every string field looking for the same prefix.
for (const m of html.matchAll(
/<script[^>]+type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/g,
)) {
let data: unknown
try {
data = JSON.parse(m[1]!)
} catch {
// Malformed JSON-LD is its own bug; not this test's job.
continue
}
JSON.stringify(data, (_, v) => {
if (typeof v === 'string' && v.includes('/_og/s/o_')) refs.add(v)
return v
})
}
// Strip the absolute prefix (host + baseURL) so we can resolve against DIST.
return [...refs].map((u) =>
u.replace(new RegExp(`^https?://[^/]+${base}`), ''),
)
}

const htmlFiles = await walkHtml(DIST)
const orphanCases: Array<{ html: string; ref: string }> = []
for (const file of htmlFiles) {
const html = await readFile(file, 'utf8')
for (const ref of extractStaticOgRefs(html, base)) {
orphanCases.push({ html: relative(DIST, file), ref })
}
}

describe('Built pages: every static OG image URL resolves to a file', () => {
// Sanity: if this is empty, the test is silently passing because no OG hash
// URLs exist anywhere in the dist (would mean an OG-image regression of its
// own). We expect at least the per-post meta to surface one.
it('finds at least one /_og/s/o_*.png reference to validate', () => {
expect(orphanCases.length).toBeGreaterThan(0)
})

it.each(orphanCases)('$html → $ref exists', ({ ref }) => {
expect(existsSync(join(DIST, ref)), `${ref} not in dist/`).toBe(true)
})
})
25 changes: 0 additions & 25 deletions test/unit/og-image-url.test.ts

This file was deleted.