|
| 1 | +<script setup lang="ts"> |
| 2 | +type ProductInfo = { |
| 3 | + name: string; |
| 4 | + sku?: string; |
| 5 | + shortDescription?: string; |
| 6 | + description?: string; |
| 7 | + image?: { sourceUrl?: string }; |
| 8 | +}; |
| 9 | +
|
| 10 | +const props = defineProps({ |
| 11 | + info: { type: Object as PropType<ProductInfo>, required: true }, |
| 12 | +}); |
| 13 | +
|
| 14 | +const stripHtml = (html?: string) => |
| 15 | + (html ?? '') |
| 16 | + .replace(/<[^>]+>/g, ' ') |
| 17 | + .replace(/\s+/g, ' ') |
| 18 | + .trim(); |
| 19 | +
|
| 20 | +const { site } = useAppConfig() as any; |
| 21 | +const siteName: string = site?.name || 'NuxtCommerce'; |
| 22 | +
|
| 23 | +const route = useRoute(); |
| 24 | +const url = useRequestURL(); |
| 25 | +const { locales, locale } = useI18n(); |
| 26 | +const localePath = useLocalePath(); |
| 27 | +
|
| 28 | +const canonical = `${url.origin}${url.pathname}`; |
| 29 | +
|
| 30 | +const img = useImage(); |
| 31 | +const baseImage: string = props.info.image?.sourceUrl || '/images/placeholder.jpg'; |
| 32 | +const ogSrc = img.getSizes(baseImage, { width: 1200, height: 630 }).src; |
| 33 | +const twSrc = img.getSizes(baseImage, { width: 1600, height: 900 }).src; |
| 34 | +const absolutize = (u?: string) => (!u ? '' : u.startsWith('http') ? u : `${url.origin}${u}`); |
| 35 | +const ogImage = absolutize(ogSrc); |
| 36 | +const twitterImage = absolutize(twSrc); |
| 37 | +
|
| 38 | +const rawDescription = props.info.shortDescription && stripHtml(props.info.shortDescription) ? stripHtml(props.info.shortDescription) : stripHtml(props.info.description); |
| 39 | +const description = rawDescription?.slice(0, 160) || ''; |
| 40 | +
|
| 41 | +const alternates = computed(() => { |
| 42 | + const items: Array<{ href: string; hreflang: string }> = []; |
| 43 | + const codes = (locales.value as any[]).map(l => ({ code: l.code, iso: l.iso })); |
| 44 | + for (const l of codes) { |
| 45 | + const localizedPath = localePath(route.fullPath, l.code); |
| 46 | + items.push({ href: `${url.origin}${localizedPath}`, hreflang: l.iso || l.code }); |
| 47 | + } |
| 48 | + items.push({ href: `${url.origin}${localePath(route.fullPath, locale.value)}`, hreflang: 'x-default' }); |
| 49 | + return items; |
| 50 | +}); |
| 51 | +
|
| 52 | +const productSchema = computed(() => { |
| 53 | + const images = [ogImage].filter(Boolean); |
| 54 | + return { |
| 55 | + '@context': 'https://schema.org', |
| 56 | + '@type': 'Product', |
| 57 | + name: props.info.name, |
| 58 | + description, |
| 59 | + image: images, |
| 60 | + sku: props.info.sku || undefined, |
| 61 | + brand: { '@type': 'Brand', name: siteName }, |
| 62 | + }; |
| 63 | +}); |
| 64 | +
|
| 65 | +useSeoMeta({ |
| 66 | + title: props.info.name, |
| 67 | + description, |
| 68 | + ogTitle: props.info.name, |
| 69 | + ogDescription: description, |
| 70 | + ogType: 'article', |
| 71 | + ogImage, |
| 72 | + ogUrl: canonical, |
| 73 | + ogSiteName: siteName, |
| 74 | + twitterTitle: props.info.name, |
| 75 | + twitterDescription: description, |
| 76 | + twitterCard: 'summary_large_image', |
| 77 | + twitterImage, |
| 78 | +}); |
| 79 | +
|
| 80 | +useHead({ |
| 81 | + htmlAttrs: { lang: locale.value }, |
| 82 | + link: [{ rel: 'canonical', href: canonical }, ...alternates.value.map(a => ({ rel: 'alternate', href: a.href, hreflang: a.hreflang }))], |
| 83 | + script: [{ type: 'application/ld+json', innerHTML: JSON.stringify(productSchema.value) }], |
| 84 | +}); |
| 85 | +</script> |
| 86 | + |
| 87 | +<template> |
| 88 | + <slot /> |
| 89 | +</template> |
0 commit comments