Skip to content

Commit a3ed4e2

Browse files
authored
fix: blog images
2 parents 9cc4c00 + 77ae1c3 commit a3ed4e2

6 files changed

Lines changed: 119 additions & 132 deletions

File tree

app/composables/useBlogImages.ts

Lines changed: 11 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -2,80 +2,19 @@ import type { BlogArticle } from '~/types'
22

33
export const useBlogImages = () => {
44
/**
5-
* Get the OG image URL for a blog post
6-
* Falls back to frontmatter image if OG image fails
5+
* Merge articles with caller-supplied OG-image URLs (frontmatter `image:`
6+
* wins). Callers obtain the OG paths from `defineOgImage()` in their own
7+
* page setup — that's the only way to get a URL the prerender will actually
8+
* write. Indexes must align with the `articles` array.
79
*/
8-
const getBlogPostImage = (article: BlogArticle): string => {
9-
// If the article has a custom image in frontmatter, use it
10-
if (article.image) {
11-
return article.image
12-
}
13-
14-
// Otherwise, use the auto-generated OG image
15-
return getOgImageUrl(article)
16-
}
17-
18-
/**
19-
* Get the generated OG image URL for a blog post.
20-
*
21-
* v6 encodes the render options into the static file path, so this must use
22-
* the same component + props that blog/[slug].vue passes to defineOgImage()
23-
* ('Blog' with minimal { title, category }). getOgImagePath() is auto-imported
24-
* by nuxt-og-image and joins the app baseURL for us.
25-
*/
26-
const getOgImageUrl = (article: BlogArticle): string => {
27-
const { path } = getOgImagePath(article.path, {
28-
component: 'Blog',
29-
props: { blog: { title: article.title, category: article.category } },
30-
})
31-
32-
// getOgImagePath() yields the runtime /_og/d/ prefix when not prerendering
33-
// (e.g. this composable re-running during client-side navigation), which
34-
// 404s on the static deploy. Force the prerendered /_og/s/ path.
35-
return toStaticOgPath(path)
36-
}
37-
38-
/**
39-
* Get blog post image with fallback handling
40-
* Returns OG image URL, or frontmatter image, or placeholder
41-
*/
42-
const getBlogPostImageWithFallback = (article: BlogArticle): string => {
43-
// Priority order:
44-
// 1. Custom image from frontmatter
45-
// 2. Generated OG image
46-
// 3. Placeholder image
47-
48-
if (article.image && article.image !== '') {
49-
return article.image
50-
}
51-
52-
// Use generated OG image as primary choice
53-
return getOgImageUrl(article)
54-
}
55-
56-
/**
57-
* Process articles to ensure they have image URLs
58-
*/
59-
const enrichArticlesWithImages = (articles: BlogArticle[]): BlogArticle[] => {
60-
return articles.map((article) => ({
10+
const enrichArticlesWithImages = (
11+
articles: BlogArticle[],
12+
ogPaths: readonly string[],
13+
): BlogArticle[] =>
14+
articles.map((article, i) => ({
6115
...article,
62-
// Use OG image if no image is specified
63-
image: getBlogPostImageWithFallback(article),
16+
image: article.image || ogPaths[i],
6417
}))
65-
}
66-
67-
/**
68-
* Check if an article should use OG image
69-
*/
70-
const shouldUseOgImage = (article: BlogArticle): boolean => {
71-
return !article.image || article.image === ''
72-
}
7318

74-
return {
75-
getBlogPostImage,
76-
getOgImageUrl,
77-
getBlogPostImageWithFallback,
78-
enrichArticlesWithImages,
79-
shouldUseOgImage,
80-
}
19+
return { enrichArticlesWithImages }
8120
}

app/pages/blog/[slug].vue

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,25 +41,29 @@ useSeoMeta({
4141
twitterDescription: description,
4242
})
4343
44+
// Capture the OG path used for this page's social card and reuse it for the
45+
// JSON-LD image — same defineOgImage call drives both, so the URL is
46+
// guaranteed to point at a file the prerender actually wrote.
47+
let articleOgPath: string | undefined
4448
if (article.value.image) {
4549
useSeoMeta({ ogImage: article.value.image })
4650
} else {
47-
// Minimal props: the Blog template only renders title + category. Keeping
48-
// the options small keeps the generated URL under v6's 200-char path limit
49-
// so it stays deterministic and matches the thumbnail built in useBlogImages.
50-
defineOgImage('Blog', {
51+
// Minimal props: the Blog template only renders title + category.
52+
const paths = defineOgImage('Blog', {
5153
blog: { title: article.value.title, category: article.value.category },
5254
})
55+
articleOgPath = paths[0]
5356
}
5457
55-
// Article image. Schema-org resolves a root-relative path by prepending the app
56-
// baseURL exactly once (verified with the logo). The generated og-image path
57-
// already includes the baseURL, so strip it first — otherwise the JSON-LD image
58-
// doubles to /litestar.dev-v2/litestar.dev-v2/… under the GitHub Pages subpath.
59-
const { getOgImageUrl } = useBlogImages()
58+
// Schema-org resolves a root-relative path by prepending the app baseURL
59+
// exactly once (verified with the logo). The og-image path already includes
60+
// the baseURL, so strip it first — otherwise the JSON-LD image doubles to
61+
// /litestar.dev-v2/litestar.dev-v2/… under the GitHub Pages subpath.
6062
const articleImage = article.value.image
6163
? article.value.image
62-
: withoutBase(getOgImageUrl(article.value), config.app.baseURL)
64+
: articleOgPath
65+
? withoutBase(articleOgPath, config.app.baseURL)
66+
: undefined
6367
6468
// BlogPosting JSON-LD derived from the post frontmatter. Authors map to Person nodes.
6569
useSchemaOrg([

app/pages/blog/index.vue

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,39 @@ useSeoMeta({
2020
twitterDescription: page.description,
2121
})
2222
23+
// Listing's own social card. Called first so it claims the default 'og' key
24+
// and stays the primary <meta property="og:image"> on this page.
2325
defineOgImage('Page', {
2426
title: page.title,
2527
description: page.description,
2628
})
2729
28-
// Enrich articles with OG images when no image is specified
29-
const articlesWithImages = computed(() => {
30-
return enrichArticlesWithImages(articles.value || [])
31-
})
32-
3330
await fetchList()
3431
35-
// console.log('ARTICLES:', articlesWithImages.value)
32+
// Per-article OG paths via the same public API the post page uses — so the
33+
// URLs match the prerendered files (the hash drops _path before computing:
34+
// node_modules/nuxt-og-image/dist/runtime/shared/urlEncoding.js:97). Distinct
35+
// keys keep each call as its own payload entry instead of overwriting the
36+
// 'Page' card or earlier per-article entries (utils.js:67-77). useState
37+
// rehydrates the SSR result so the computed below produces stable values on
38+
// the client (defineOgImage returns [] during hydration; _defineOgImageRaw.js:19-20).
39+
const blogOgPaths = useState<string[]>('blog-og-paths', () =>
40+
import.meta.server
41+
? defineOgImage(
42+
'Blog',
43+
(articles.value || []).map((article, i) => ({
44+
props: {
45+
blog: { title: article.title, category: article.category },
46+
},
47+
key: `blog-${i}`,
48+
})),
49+
)
50+
: [],
51+
)
52+
53+
const articlesWithImages = computed(() =>
54+
enrichArticlesWithImages(articles.value || [], blogOgPaths.value),
55+
)
3656
</script>
3757

3858
<template>

app/utils/ogImage.ts

Lines changed: 0 additions & 17 deletions
This file was deleted.

test/build/meta.test.ts

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { existsSync } from 'node:fs'
2-
import { readFile } from 'node:fs/promises'
3-
import { join } from 'node:path'
2+
import { readdir, readFile } from 'node:fs/promises'
3+
import { join, relative } from 'node:path'
44
import { describe, expect, it } from 'vitest'
55

66
// Resolves to the generated static deploy artifact. `nuxt generate` writes to
@@ -105,3 +105,69 @@ describe('Built pages have required OG/SEO meta', () => {
105105
}
106106
})
107107
})
108+
109+
// Catches the class of bug where a consumer (listing <img>, JSON-LD image, etc.)
110+
// predicts an /_og/s/o_<hash>.png URL that the producer never wrote — i.e. the
111+
// hash inputs drifted between defineOgImage and the predictor. nuxt-og-image
112+
// logs "Skipped N orphaned OG image hash URL" during prerender for these, but
113+
// doesn't fail the build. This test does.
114+
async function walkHtml(dir: string): Promise<string[]> {
115+
const out: string[] = []
116+
for (const entry of await readdir(dir, { withFileTypes: true })) {
117+
const p = join(dir, entry.name)
118+
if (entry.isDirectory()) out.push(...(await walkHtml(p)))
119+
else if (entry.name.endsWith('.html')) out.push(p)
120+
}
121+
return out
122+
}
123+
124+
function extractStaticOgRefs(html: string, base: string): string[] {
125+
const refs = new Set<string>()
126+
// Any quoted attribute carrying an /_og/s/o_<hash>.png path (covers <img src>,
127+
// <link href>, <meta content>, srcset entries, etc.).
128+
for (const m of html.matchAll(/["']([^"']*\/_og\/s\/o_[^"']+\.png)["']/g)) {
129+
refs.add(m[1]!)
130+
}
131+
// JSON-LD blocks — walk every string field looking for the same prefix.
132+
for (const m of html.matchAll(
133+
/<script[^>]+type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/g,
134+
)) {
135+
let data: unknown
136+
try {
137+
data = JSON.parse(m[1]!)
138+
} catch {
139+
// Malformed JSON-LD is its own bug; not this test's job.
140+
continue
141+
}
142+
JSON.stringify(data, (_, v) => {
143+
if (typeof v === 'string' && v.includes('/_og/s/o_')) refs.add(v)
144+
return v
145+
})
146+
}
147+
// Strip the absolute prefix (host + baseURL) so we can resolve against DIST.
148+
return [...refs].map((u) =>
149+
u.replace(new RegExp(`^https?://[^/]+${base}`), ''),
150+
)
151+
}
152+
153+
const htmlFiles = await walkHtml(DIST)
154+
const orphanCases: Array<{ html: string; ref: string }> = []
155+
for (const file of htmlFiles) {
156+
const html = await readFile(file, 'utf8')
157+
for (const ref of extractStaticOgRefs(html, base)) {
158+
orphanCases.push({ html: relative(DIST, file), ref })
159+
}
160+
}
161+
162+
describe('Built pages: every static OG image URL resolves to a file', () => {
163+
// Sanity: if this is empty, the test is silently passing because no OG hash
164+
// URLs exist anywhere in the dist (would mean an OG-image regression of its
165+
// own). We expect at least the per-post meta to surface one.
166+
it('finds at least one /_og/s/o_*.png reference to validate', () => {
167+
expect(orphanCases.length).toBeGreaterThan(0)
168+
})
169+
170+
it.each(orphanCases)('$html → $ref exists', ({ ref }) => {
171+
expect(existsSync(join(DIST, ref)), `${ref} not in dist/`).toBe(true)
172+
})
173+
})

test/unit/og-image-url.test.ts

Lines changed: 0 additions & 25 deletions
This file was deleted.

0 commit comments

Comments
 (0)