Skip to content

Commit bf4f2f8

Browse files
authored
fix: support reactive og image options & props (#361)
1 parent 9738b36 commit bf4f2f8

8 files changed

Lines changed: 112 additions & 71 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<script lang="ts" setup>
2+
import { defineOgImage, ref } from '#imports'
3+
4+
const name = ref('foo')
5+
6+
defineOgImage({
7+
url: () => `https://nitro.build/_og/guide/tasks.png?name=${name.value}&title=Tasks&description=Nitro+tasks+allow+on-off+operations+in+runtime.`,
8+
width: 1200,
9+
height: 630,
10+
alt: 'Nitro UI',
11+
})
12+
13+
name.value = 'bar'
14+
</script>
15+
16+
<template>
17+
<div>prebuilt</div>
18+
</template>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<script lang="ts" setup>
2+
import { defineOgImageComponent, ref } from '#imports'
3+
4+
const title = ref('foo')
5+
defineOgImageComponent('NuxtSeo', {
6+
title,
7+
})
8+
title.value = 'bar'
9+
</script>
10+
11+
<template>
12+
<div>
13+
<div>
14+
bad usage example
15+
</div>
16+
</div>
17+
</template>

src/runtime/app/composables/defineOgImage.ts

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import type { ActiveHeadEntry } from '@unhead/vue'
2-
import type { DefineOgImageInput, OgImageOptions } from '../../types'
3-
import { defu } from 'defu'
2+
import type { DefineOgImageInput } from '../../types'
43
import { appendHeader } from 'h3'
54
import { createError, useNuxtApp, useRequestEvent, useRoute, useState } from 'nuxt/app'
6-
import { ref } from 'vue'
5+
import { ref, toValue } from 'vue'
76
import { createNitroRouteRuleMatcher } from '../../server/util/kit'
8-
import { getOgImagePath, separateProps, useOgImageRuntimeConfig } from '../../shared'
9-
import { createOgImageMeta, normaliseOptions } from '../utils'
7+
import { getOgImagePath, useOgImageRuntimeConfig } from '../../shared'
8+
import { createOgImageMeta, setHeadOgImagePrebuilt } from '../utils'
109

1110
// In non-dev client-side environments this is treeshaken
1211
export function defineOgImage(_options: DefineOgImageInput = {}) {
@@ -47,23 +46,25 @@ export function defineOgImage(_options: DefineOgImageInput = {}) {
4746
return
4847
}
4948
const { defaults } = useOgImageRuntimeConfig()
50-
const options = normaliseOptions(defu({
51-
..._options,
52-
}, {
53-
component: defaults.component,
54-
}))
49+
const options = toValue(_options)
50+
for (const key in routeRules) {
51+
if (options[key] === undefined)
52+
options[key] = routeRules[key]
53+
}
54+
for (const key in defaults) {
55+
if (options[key] === undefined)
56+
options[key] = defaults[key]
57+
}
5558
if (route.query)
5659
options._query = route.query
57-
const resolvedOptions = normaliseOptions(defu(separateProps(_options), separateProps(routeRules), defaults) as OgImageOptions)
5860
// allow overriding using a prebuild config
59-
if (resolvedOptions.url) {
60-
createOgImageMeta(null, options, resolvedOptions, nuxtApp.ssrContext!)
61+
if (options.url) {
62+
setHeadOgImagePrebuilt(options)
63+
return
6164
}
62-
else {
63-
const path = getOgImagePath(basePath, defu(resolvedOptions, { _query: options._query }))
64-
if (import.meta.prerender) {
65-
appendHeader(useRequestEvent(nuxtApp)!, 'x-nitro-prerender', path)
66-
}
67-
createOgImageMeta(path, options, resolvedOptions, nuxtApp.ssrContext!)
65+
const path = getOgImagePath(basePath, options)
66+
if (import.meta.prerender) {
67+
appendHeader(useRequestEvent(nuxtApp)!, 'x-nitro-prerender', path)
6868
}
69+
createOgImageMeta(path, options, nuxtApp.ssrContext!)
6970
}

src/runtime/app/utils.ts

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,34 @@
11
import type { Head } from '@unhead/vue'
22
import type { NuxtSSRContext } from 'nuxt/app'
3-
import type { DefineOgImageInput, OgImageOptions, OgImagePrebuilt } from '../types'
3+
import type { OgImageOptions, OgImagePrebuilt } from '../types'
44
import { componentNames } from '#build/nuxt-og-image/components.mjs'
55
import { useHead } from '#imports'
66
import { resolveUnrefHeadInput } from '@unhead/vue'
77
import { defu } from 'defu'
88
import { stringify } from 'devalue'
99
import { withQuery } from 'ufo'
10-
import { unref } from 'vue'
11-
import { generateMeta, separateProps } from '../shared'
10+
import { generateMeta, separateProps, useOgImageRuntimeConfig } from '../shared'
1211

13-
export function createOgImageMeta(src: string | null, input: OgImageOptions | OgImagePrebuilt, resolvedOptions: OgImageOptions, ssrContext: NuxtSSRContext) {
12+
export function setHeadOgImagePrebuilt(input: OgImagePrebuilt) {
1413
if (import.meta.client) {
1514
return
1615
}
17-
const _input = separateProps(defu(input, ssrContext._ogImagePayload))
18-
let url = src || input.url || resolvedOptions.url
16+
const url = input.url
1917
if (!url)
2018
return
21-
if (input._query && Object.keys(input._query).length && url)
22-
url = withQuery(url, { _query: input._query })
23-
const meta = generateMeta(url, resolvedOptions)
19+
const meta = generateMeta(url, input)
20+
useHead({ meta }, { tagPriority: 'high' })
21+
}
22+
23+
export function createOgImageMeta(src: string, input: OgImageOptions | OgImagePrebuilt, ssrContext: NuxtSSRContext) {
24+
if (import.meta.client) {
25+
return
26+
}
27+
const { defaults } = useOgImageRuntimeConfig()
28+
const _input = separateProps(defu(input, ssrContext._ogImagePayload))
29+
if (input._query && Object.keys(input._query).length)
30+
src = withQuery(src, { _query: input._query })
31+
const meta = generateMeta(src, input)
2432
ssrContext._ogImageInstances = ssrContext._ogImageInstances || []
2533
const script: Head['script'] = []
2634
if (src) {
@@ -32,12 +40,19 @@ export function createOgImageMeta(src: string | null, input: OgImageOptions | Og
3240
const payload = resolveUnrefHeadInput(_input)
3341
if (typeof payload.props.title === 'undefined')
3442
payload.props.title = '%s'
43+
payload.component = resolveComponentName(input.component, defaults.component)
3544
delete payload.url
3645
if (payload._query && Object.keys(payload._query).length === 0) {
3746
delete payload._query
3847
}
48+
const final = {}
49+
for (const k in payload) {
50+
if (payload[k] !== defaults[k]) {
51+
final[k] = payload[k]
52+
}
53+
}
3954
// don't apply defaults
40-
return stringify(payload)
55+
return stringify(final)
4156
},
4257
// we want this to be last in our head
4358
tagPosition: 'bodyClose',
@@ -54,23 +69,16 @@ export function createOgImageMeta(src: string | null, input: OgImageOptions | Og
5469
ssrContext._ogImageInstances.push(instance)
5570
}
5671

57-
export function normaliseOptions(_options: DefineOgImageInput): OgImageOptions | OgImagePrebuilt {
58-
const options = { ...unref(_options) } as OgImageOptions
59-
if (!options)
60-
return options
72+
export function resolveComponentName(component: OgImageOptions['component'], fallback: string): OgImageOptions['component'] {
73+
component = component || fallback || componentNames?.[0]?.pascalName
6174
// try and fix component name if we're using a shorthand (i.e Banner instead of OgImageBanner)
62-
if (options.component && componentNames) {
63-
const originalName = options.component
75+
if (component && componentNames) {
76+
const originalName = component
6477
for (const component of componentNames) {
6578
if (component.pascalName.endsWith(originalName) || component.kebabName.endsWith(originalName)) {
66-
options.component = component.pascalName
67-
break
79+
return component.pascalName
6880
}
6981
}
7082
}
71-
else if (!options.component) {
72-
// just pick first component
73-
options.component = componentNames[0]?.pascalName
74-
}
75-
return options
83+
return component
7684
}

src/runtime/app/utils/plugins.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ import { parse, stringify } from 'devalue'
99
import { createRouter as createRadixRouter, toRouteMatcher } from 'radix3'
1010
import { parseURL, withoutBase } from 'ufo'
1111
import { toValue } from 'vue'
12-
import { createOgImageMeta, normaliseOptions } from '../../app/utils'
12+
import { createOgImageMeta } from '../../app/utils'
1313
import { isInternalRoute, separateProps } from '../../pure'
14-
import { getOgImagePath, useOgImageRuntimeConfig } from '../../shared'
14+
import { getOgImagePath } from '../../shared'
1515

1616
export function ogImageCanonicalUrls(nuxtApp: NuxtApp) {
1717
// specifically we're checking if a route is missing a payload but has route rules, we can inject the meta needed
@@ -106,13 +106,8 @@ export function routeRuleOgImage(nuxtApp: NuxtApp) {
106106
nuxtApp.ssrContext!._ogImageInstances = undefined
107107
return
108108
}
109-
const { defaults } = useOgImageRuntimeConfig()
110-
routeRules = normaliseOptions(defu(nuxtApp.ssrContext?.event.context._nitro?.routeRules?.ogImage, routeRules, {
111-
component: defaults.component,
112-
}))
113-
114-
const resolvedOptions = normaliseOptions(defu(routeRules, defaults) as OgImageOptions)
115-
const src = getOgImagePath(ssrContext!.url, resolvedOptions)
116-
createOgImageMeta(src, routeRules, resolvedOptions, nuxtApp.ssrContext!)
109+
routeRules = defu(nuxtApp.ssrContext?.event.context._nitro?.routeRules?.ogImage, routeRules)
110+
const src = getOgImagePath(ssrContext!.url, routeRules)
111+
createOgImageMeta(src, routeRules, nuxtApp.ssrContext!)
117112
})
118113
}

src/runtime/pure.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,5 +115,8 @@ export function withoutQuery(path: string) {
115115
export function getExtension(path: string) {
116116
path = withoutQuery(path)
117117
const lastSegment = (path.split('/').pop() || path)
118-
return lastSegment.split('.').pop() || lastSegment
118+
const extension = lastSegment.split('.').pop() || lastSegment
119+
if (extension === 'jpg')
120+
return 'jpeg'
121+
return extension
119122
}

src/runtime/shared.ts

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
1-
import type { Head } from '@unhead/vue'
2-
import type { OgImageOptions, OgImageRuntimeConfig } from './types'
1+
import type { ResolvableMeta } from '@unhead/vue'
2+
import type { OgImageOptions, OgImagePrebuilt, OgImageRuntimeConfig } from './types'
33
import { useRuntimeConfig } from '#imports'
4-
import { defu } from 'defu'
54
import { joinURL, withQuery } from 'ufo'
5+
import { toValue } from 'vue'
66
import { getExtension } from './pure'
77

88
// must work in both Nuxt an
99

1010
export * from './pure'
1111

12-
export function generateMeta(url: string, resolvedOptions: OgImageOptions): Head['meta'] {
13-
let urlExtension = getExtension(url) || resolvedOptions.extension
14-
if (urlExtension === 'jpg')
15-
urlExtension = 'jpeg'
16-
const meta: Head['meta'] = [
12+
export function generateMeta(url: OgImagePrebuilt['url'] | string, resolvedOptions: OgImageOptions | OgImagePrebuilt): ResolvableMeta[] {
13+
const meta: ResolvableMeta[] = [
1714
{ property: 'og:image', content: url },
18-
{ property: 'og:image:type', content: `image/${urlExtension}` },
15+
{ property: 'og:image:type', content: () => `image/${resolvedOptions.extension || getExtension(toValue(url))}` },
1916
{ name: 'twitter:card', content: 'summary_large_image' },
2017
// we don't need this but avoids issue when using useSeoMeta({ twitterImage })
2118
{ name: 'twitter:image', content: url },
@@ -38,10 +35,10 @@ export function generateMeta(url: string, resolvedOptions: OgImageOptions): Head
3835

3936
export function getOgImagePath(pagePath: string, _options?: Partial<OgImageOptions>) {
4037
const baseURL = useRuntimeConfig().app.baseURL
41-
const options = defu(_options, useOgImageRuntimeConfig().defaults)
42-
const path = joinURL('/', baseURL, `__og-image__/${import.meta.prerender ? 'static' : 'image'}`, pagePath, `og.${options.extension}`)
43-
if (Object.keys(options._query || {}).length) {
44-
return withQuery(path, options._query)
38+
const extension = _options?.extension || useOgImageRuntimeConfig().defaults.extension
39+
const path = joinURL('/', baseURL, `__og-image__/${import.meta.prerender ? 'static' : 'image'}`, pagePath, `og.${extension}`)
40+
if (Object.keys(_options?._query || {}).length) {
41+
return withQuery(path, _options!._query!)
4542
}
4643
return path
4744
}

src/runtime/types.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { NitroApp } from 'nitropack/types'
88
import type { SatoriOptions } from 'satori'
99
import type { html } from 'satori-html'
1010
import type { SharpOptions } from 'sharp'
11+
import type { Ref } from 'vue'
1112

1213
export interface OgImageRenderEventContext {
1314
unocss: UnoGenerator
@@ -82,7 +83,8 @@ export interface ScreenshotOptions {
8283
delay?: number
8384
}
8485

85-
export type OgImagePrebuilt = { url: string } & Pick<OgImageOptions, 'width' | 'height' | 'alt' | '_query'>
86+
export interface OgImagePrebuilt extends OgImageOptions {
87+
}
8688

8789
export type DefineOgImageInput = OgImageOptions | OgImagePrebuilt | false
8890

@@ -92,23 +94,23 @@ export interface OgImageOptions<T extends keyof OgImageComponents = 'NuxtSeo'> {
9294
*
9395
* @default 1200
9496
*/
95-
width?: number
97+
width?: number | (() => number) | Ref<number>
9698
/**
9799
* The height of the screenshot.
98100
*
99101
* @default 630
100102
*/
101-
height?: number
103+
height?: number | (() => number) | Ref<number>
102104
/**
103105
* The alt text for the image.
104106
*/
105-
alt?: string
107+
alt?: string | (() => string) | Ref<string>
106108
/**
107109
* Use a prebuilt image instead of generating one.
108110
*
109111
* Should be an absolute URL.
110112
*/
111-
url?: string
113+
url?: string | (() => string) | Ref<string>
112114
/**
113115
* The name of the component to render.
114116
*/

0 commit comments

Comments
 (0)