Skip to content

Commit 178bc3c

Browse files
feat: implement Roadiz meta composable (#611)
* feat: implement Roadiz meta composable * fix: replace String.fromCharCode with String.fromCodePoint for better character handling * feat: refactor page meta handling and introduce usePageTitle composable * fix: page head data destructuring Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: ensure getPageTitle returns null for empty title when internalSiteName is not set * fix: update siteName computation to return undefined instead of empty string --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 2d056b2 commit 178bc3c

16 files changed

Lines changed: 444 additions & 123 deletions
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const blocks = computed(() => (webResponse?.blocks && getBlockCollection(webResp
1111

1212
<template>
1313
<div>
14-
<h1>{{ page?.title || 'VDefaultPage' }}</h1>
14+
<h1>{{ page?.title || 'Default page' }}</h1>
1515
<VRoadizBlockFactory
1616
v-if="blocks.length"
1717
:blocks="blocks"

app/components/VPageSearch.vue

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<script lang="ts" setup>
2+
import type { PageEntityProps } from '~~/types/app'
3+
import { getBlockCollection } from '~/utils/roadiz/block'
4+
import type { NSSearchPage } from '~~/types/roadiz'
5+
6+
const { webResponse } = defineProps<PageEntityProps>()
7+
8+
const page = computed(() => webResponse?.item as NSSearchPage)
9+
const blocks = computed(() => (webResponse?.blocks && getBlockCollection(webResponse.blocks)) || [])
10+
</script>
11+
12+
<template>
13+
<div>
14+
<h1>{{ page?.title || 'Search page' }}</h1>
15+
<VRoadizBlockFactory
16+
v-if="blocks.length"
17+
:blocks="blocks"
18+
/>
19+
</div>
20+
</template>

app/composables/use-page-meta.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type { ReactiveHead } from '@unhead/vue'
2+
import type { MaybeRefOrGetter } from 'vue'
3+
import { truncate } from '~/utils/string/truncate'
4+
import { joinURL } from 'ufo'
5+
6+
export interface PageMetaAlternateLink {
7+
locale: string
8+
href: string
9+
}
10+
11+
export interface PageMetaOptions {
12+
title?: MaybeRefOrGetter<string | null | undefined>
13+
description?: MaybeRefOrGetter<string | null | undefined>
14+
image?: MaybeRefOrGetter<string | undefined>
15+
siteName?: MaybeRefOrGetter<string | undefined>
16+
noindex?: MaybeRefOrGetter<boolean | undefined>
17+
canonicalUrl?: MaybeRefOrGetter<string | null | undefined>
18+
alternateLinks?: MaybeRefOrGetter<PageMetaAlternateLink[] | undefined>
19+
}
20+
21+
/**
22+
* Generic page meta/head builder, independent from any CMS.
23+
* Backend-specific composables (e.g. useRoadizMeta) should resolve their own
24+
* title/description/image/etc. and pass them here as default options.
25+
*/
26+
export function usePageMeta(options: PageMetaOptions = {}) {
27+
const runtimeConfig = useRuntimeConfig()
28+
const { $i18n } = useNuxtApp()
29+
30+
const title = computed(() => toValue(options.title) || '')
31+
const description = computed(() => toValue(options.description) || '')
32+
const truncatedDescription = computed(() => truncate(description.value, 160))
33+
const image = computed(() => toValue(options.image) || joinURL(runtimeConfig.app.cdnUrl || runtimeConfig.public.site.url, '/images/share.jpg'))
34+
const siteName = computed(() => toValue(options.siteName) || runtimeConfig.public.site.name || '')
35+
const noindex = computed(() => !!toValue(options.noindex))
36+
const canonicalUrl = computed(() => toValue(options.canonicalUrl) || undefined)
37+
38+
const link = computed<ReactiveHead['link']>(() => {
39+
const links: NonNullable<ReactiveHead['link']> = []
40+
41+
if (canonicalUrl.value) {
42+
links.push({
43+
rel: 'canonical',
44+
href: canonicalUrl.value,
45+
})
46+
}
47+
48+
const alternateLinks = toValue(options.alternateLinks)
49+
if (alternateLinks) {
50+
links.push(...alternateLinks.map(alternateLink => ({
51+
hid: `alternate-${alternateLink.locale}`,
52+
rel: 'alternate',
53+
hreflang: alternateLink.locale,
54+
href: alternateLink.href,
55+
})))
56+
}
57+
58+
return links
59+
})
60+
61+
const head = computed(() => ({
62+
htmlAttrs: {
63+
lang: $i18n.locale.value,
64+
},
65+
link: link.value,
66+
title: title.value,
67+
meta: [
68+
// app version
69+
{
70+
name: 'version',
71+
content: runtimeConfig.public.version,
72+
},
73+
// SEO meta
74+
// Update on server AND during client side navigation.
75+
// The client side update is required for the share on iOS Safari feature
76+
// to have the correct meta data when sharing.
77+
{ name: 'description', content: truncatedDescription.value },
78+
{ property: 'og:title', content: title.value },
79+
{ property: 'og:site_name', content: siteName.value },
80+
{ property: 'og:description', content: truncatedDescription.value },
81+
{ property: 'og:image', content: image.value },
82+
{ property: 'og:url', content: canonicalUrl.value },
83+
{ name: 'twitter:card', content: 'summary_large_image' },
84+
{ name: 'twitter:title', content: title.value },
85+
{ name: 'twitter:description', content: truncatedDescription.value },
86+
{ name: 'twitter:image', content: image.value },
87+
{ name: 'robots', content: noindex.value ? 'noindex' : undefined },
88+
],
89+
}))
90+
91+
return { head, image, description, title, truncatedDescription }
92+
}

app/composables/use-page-title.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// commonContent and currentPage need to be set before calling this composable
2+
3+
interface UsePageTitleOptions {
4+
title?: MaybeRefOrGetter<string | undefined>
5+
siteName?: MaybeRefOrGetter<string | undefined>
6+
}
7+
8+
export function usePageTitle(options: UsePageTitleOptions = {}) {
9+
const runtimeConfig = useRuntimeConfig()
10+
const internalSiteName = computed(() =>
11+
toValue(options.siteName) || runtimeConfig?.public?.site?.name)
12+
13+
function getPageTitle(title: string | undefined) {
14+
if (!title) {
15+
return internalSiteName.value
16+
}
17+
18+
if (!internalSiteName.value) {
19+
return title
20+
}
21+
22+
return `${title}${internalSiteName.value}`
23+
}
24+
25+
return {
26+
getPageTitle,
27+
plainTitle: computed(() => getPageTitle(toValue(options.title))),
28+
}
29+
}

app/composables/use-roadiz-meta.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { joinURL } from 'ufo'
2+
import type { RoadizAlternateLink, RoadizDocument, RoadizNodesSources, RoadizWebResponse } from '@roadiz/types'
3+
import type { EventsApi } from '@events-api/javascript-sdk'
4+
import type { MaybeRefOrGetter } from 'vue'
5+
import { markdownToPlainText } from '~/utils/markdown/markdown-to-plain-text'
6+
7+
export async function useRoadizMeta(
8+
webResponse?: MaybeRefOrGetter<RoadizWebResponse | undefined>,
9+
alternateLinks?: MaybeRefOrGetter<RoadizAlternateLink[] | undefined>,
10+
) {
11+
const nuxtApp = useNuxtApp()
12+
const runtimeConfig = useRuntimeConfig()
13+
const { data: commonContentData } = useCommonContent()
14+
const item = computed(() => toValue(webResponse)?.item)
15+
const siteName = computed(() => commonContentData.value?.head?.siteName || undefined)
16+
const { canonicalUrl } = useCurrentPageSearchParams()
17+
18+
// ------------------- Noindex -------------------
19+
const { isActive: previewIsActive } = useRoadizPreview()
20+
const noindex = computed(() => (item.value as RoadizNodesSources)?.noIndex || previewIsActive.value)
21+
22+
// -------------------- Title -------------------
23+
const { getPageTitle } = usePageTitle({ siteName })
24+
const title = computed(() => {
25+
// The API should always return a meta title.
26+
// The meta title is set in the Roadiz back office for each page. The logic is:
27+
// - a custom meta title is set in the back office for the page,
28+
// - or the API build a meta title from the page title and the site name.
29+
const metaTitle = toValue(webResponse)?.head?.metaTitle
30+
31+
if (metaTitle) {
32+
return metaTitle
33+
}
34+
35+
// In case it doesn't returned, we fallback to the page title or name (event).
36+
const fallbackTitle = (item.value as RoadizNodesSources)?.title || (item.value as EventsApi.Event)?.name
37+
38+
if (fallbackTitle) {
39+
return getPageTitle(fallbackTitle)
40+
}
41+
42+
return ''
43+
})
44+
45+
// -------------------- Alternate links -------------------
46+
const formattedAlternateLinks = computed(() => {
47+
return toValue(alternateLinks)
48+
?.filter(alternateLink => alternateLink.url)
49+
.map((alternateLink: RoadizAlternateLink) => ({
50+
locale: alternateLink.locale!,
51+
href: joinURL(runtimeConfig.public.site.url, alternateLink.url!),
52+
}))
53+
})
54+
55+
// ------------------- Description -------------------
56+
const description = computed(() => {
57+
const metaDescription = toValue(webResponse)?.head?.metaDescription
58+
59+
// The API tries to always return a meta description.
60+
// @see https://docs.roadiz.io/developer/nodes-system/node_type_fields.html#meta-description-fallback-field
61+
if (metaDescription) {
62+
return metaDescription
63+
}
64+
65+
// If the API doesn't return a meta description, we tries to find a description in the page's content.
66+
// For pages, the description is generally stored in the "excerpt", "introduction" or "content" field.
67+
// For events, the description is generally stored in the "excerpt" or "description" field.
68+
// We check for all of them in order of priority.
69+
// Be careful because an event has the excerpt field too. Then it will be used.
70+
const nodeDescription = (item.value as { excerpt?: string } | undefined)?.excerpt
71+
|| (item.value as { introduction?: string } | undefined)?.introduction
72+
|| (item.value as { content?: string } | undefined)?.content
73+
74+
if (nodeDescription) {
75+
return markdownToPlainText(nodeDescription)
76+
}
77+
78+
// If the page is an event, we fallback to the event's content.
79+
const eventDescription = (item.value as { excerpt?: string } | undefined)?.excerpt
80+
|| (item.value as { description?: string } | undefined)?.description
81+
82+
if (eventDescription) {
83+
return markdownToPlainText(eventDescription)
84+
}
85+
86+
// Fallback to the meta description set in the common content (if any).
87+
return commonContentData.value?.head?.metaDescription
88+
})
89+
90+
// ------------------- Share image -------------------
91+
const img = useImage()
92+
const itemImageDocument = computed(() => {
93+
// For pages, the image is generally stored in the "image", "images", "media" or "medias" field.
94+
const pageImage = (item.value as { image?: RoadizDocument[] } | undefined)?.image?.[0]
95+
|| (item.value as { images?: RoadizDocument[] } | undefined)?.images?.[0]
96+
|| (item.value as { media?: RoadizDocument[] } | undefined)?.media?.[0]
97+
|| (item.value as { medias?: RoadizDocument[] } | undefined)?.medias?.[0]
98+
99+
if (pageImage) {
100+
return pageImage
101+
}
102+
103+
// For events, the image is stored in the "mainDocuments" or "medias" field.
104+
const eventImage = (item.value as { mainDocuments?: RoadizDocument[] } | undefined)?.mainDocuments?.[0]
105+
|| (item.value as { medias?: RoadizDocument[] } | undefined)?.medias?.[0]
106+
107+
if (eventImage) {
108+
return eventImage
109+
}
110+
111+
return undefined
112+
})
113+
114+
const imageDocument = computed(() => {
115+
const responseHead = toValue(webResponse)?.head
116+
117+
// The API tries to always return a meta share image.
118+
// @see https://docs.roadiz.io/developer/nodes-system/node_type_fields.html#share-image-field
119+
if (responseHead?.shareImage) {
120+
return responseHead.shareImage
121+
}
122+
123+
// If the API doesn't return a meta share image, we tries to find a share image in the page's content.
124+
if (itemImageDocument.value) {
125+
return itemImageDocument.value
126+
}
127+
128+
// If the page doesn't have a share image, we fallback to the meta share image set
129+
// in the common content (if any).
130+
return commonContentData.value?.head?.shareImage
131+
})
132+
133+
async function resolveMetaImage(document: RoadizDocument | null | undefined): Promise<string | undefined> {
134+
if (!document?.processable || !document?.relativePath) return undefined
135+
136+
// On the server, nuxtApp.runWithContext() always wraps its callback's return value in a
137+
// Promise (unctx's callAsync is declared `async`), even though this callback is synchronous.
138+
// Without awaiting it, the unresolved Promise ends up as the og:image content, which
139+
// renders as "[object Promise]" instead of the actual URL.
140+
return await nuxtApp.runWithContext(() =>
141+
img(
142+
document.relativePath!,
143+
{
144+
width: 1200,
145+
crop: '1200x630',
146+
quality: 70,
147+
},
148+
{
149+
// @ts-expect-error The `provider` option is not well typed in the `useImage()` composable.
150+
provider: 'interventionRequest',
151+
},
152+
)) as string | undefined
153+
}
154+
155+
const resolvedImageUrl = ref<string | undefined>()
156+
157+
async function resolveImage(document: RoadizDocument | null | undefined) {
158+
resolvedImageUrl.value = await resolveMetaImage(document)
159+
}
160+
161+
// Resolve upfront so the first (SSR) render already has the URL available.
162+
await resolveImage(imageDocument.value)
163+
164+
// Keep it in sync if the share image changes later without remounting this composable's caller.
165+
watch(imageDocument, resolveImage)
166+
167+
const image = computed(() => resolvedImageUrl.value)
168+
169+
return {
170+
title,
171+
description,
172+
image,
173+
siteName,
174+
noindex,
175+
canonicalUrl,
176+
alternateLinks: formattedAlternateLinks,
177+
}
178+
}

app/composables/use-roadiz-page-title.ts

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

0 commit comments

Comments
 (0)