Skip to content

Commit d283f9a

Browse files
authored
feat(layer): add more seo optimization (#1267)
1 parent ff8a045 commit d283f9a

6 files changed

Lines changed: 282 additions & 20 deletions

File tree

layer/app/composables/useSeo.ts

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import type { MaybeRefOrGetter } from 'vue'
2+
import type { BreadcrumbItem } from '../utils/navigation'
3+
import { joinURL, withoutTrailingSlash } from 'ufo'
4+
5+
export interface UseSeoOptions {
6+
/**
7+
* Page title
8+
*/
9+
title: MaybeRefOrGetter<string | undefined>
10+
/**
11+
* Page description
12+
*/
13+
description: MaybeRefOrGetter<string | undefined>
14+
/**
15+
* Page type for og:type (default: 'article' for docs, 'website' for landing)
16+
*/
17+
type?: MaybeRefOrGetter<'website' | 'article'>
18+
/**
19+
* Custom OG image URL (absolute)
20+
*/
21+
ogImage?: MaybeRefOrGetter<string | undefined>
22+
/**
23+
* Published date for article schema
24+
*/
25+
publishedAt?: MaybeRefOrGetter<string | undefined>
26+
/**
27+
* Modified date for article schema
28+
*/
29+
modifiedAt?: MaybeRefOrGetter<string | undefined>
30+
/**
31+
* Breadcrumb items for BreadcrumbList schema
32+
*/
33+
breadcrumbs?: MaybeRefOrGetter<BreadcrumbItem[] | undefined>
34+
}
35+
36+
/**
37+
* Composable for comprehensive SEO setup including:
38+
* - Meta tags (title, description, og:*, twitter:*)
39+
* - Canonical URLs
40+
* - Hreflang tags for i18n
41+
* - JSON-LD structured data
42+
*/
43+
export function useSeo(options: UseSeoOptions) {
44+
const route = useRoute()
45+
const site = useSiteConfig()
46+
const { locale, locales, isEnabled: isI18nEnabled, switchLocalePath } = useDocusI18n()
47+
48+
const title = computed(() => toValue(options.title))
49+
const description = computed(() => toValue(options.description))
50+
const type = computed(() => toValue(options.type) || 'article')
51+
const ogImage = computed(() => toValue(options.ogImage))
52+
const publishedAt = computed(() => toValue(options.publishedAt))
53+
const modifiedAt = computed(() => toValue(options.modifiedAt))
54+
const breadcrumbs = computed(() => toValue(options.breadcrumbs))
55+
56+
// Build canonical URL
57+
const canonicalUrl = computed(() => {
58+
if (!site.url) return undefined
59+
return joinURL(site.url, route.path)
60+
})
61+
62+
// Base URL for building other URLs
63+
const baseUrl = computed(() => site.url ? withoutTrailingSlash(site.url) : '')
64+
65+
// Set meta tags
66+
useSeoMeta({
67+
title,
68+
description,
69+
ogTitle: title,
70+
ogDescription: description,
71+
ogType: type,
72+
ogUrl: canonicalUrl,
73+
ogLocale: computed(() => isI18nEnabled.value ? locale.value : undefined),
74+
})
75+
76+
// Set canonical link
77+
useHead({
78+
link: computed(() => {
79+
const links: Array<{ rel: string, href?: string, hreflang?: string }> = []
80+
81+
// Canonical URL
82+
if (canonicalUrl.value) {
83+
links.push({
84+
rel: 'canonical',
85+
href: canonicalUrl.value,
86+
})
87+
}
88+
89+
// Hreflang tags for i18n
90+
if (isI18nEnabled.value && baseUrl.value) {
91+
for (const loc of locales) {
92+
const localePath = switchLocalePath(loc.code)
93+
if (localePath) {
94+
links.push({
95+
rel: 'alternate',
96+
hreflang: loc.code,
97+
href: joinURL(baseUrl.value, localePath),
98+
})
99+
}
100+
}
101+
102+
// x-default hreflang (points to default locale)
103+
const defaultLocalePath = switchLocalePath(locales[0]?.code || 'en')
104+
if (defaultLocalePath) {
105+
links.push({
106+
rel: 'alternate',
107+
hreflang: 'x-default',
108+
href: joinURL(baseUrl.value, defaultLocalePath),
109+
})
110+
}
111+
}
112+
113+
return links
114+
}),
115+
})
116+
117+
// Custom OG image handling
118+
if (ogImage.value) {
119+
useSeoMeta({
120+
ogImage: ogImage.value,
121+
twitterImage: ogImage.value,
122+
})
123+
}
124+
125+
// JSON-LD structured data
126+
useHead({
127+
script: computed(() => {
128+
const scripts: Array<{ type: string, innerHTML: string }> = []
129+
130+
if (!baseUrl.value || !title.value) return scripts
131+
132+
const pageUrl = joinURL(baseUrl.value, route.path)
133+
134+
// Article schema for documentation pages
135+
if (type.value === 'article') {
136+
const articleSchema: Record<string, unknown> = {
137+
'@context': 'https://schema.org',
138+
'@type': 'Article',
139+
'headline': title.value,
140+
'description': description.value,
141+
'url': pageUrl,
142+
'mainEntityOfPage': {
143+
'@type': 'WebPage',
144+
'@id': pageUrl,
145+
},
146+
}
147+
148+
if (publishedAt.value) {
149+
articleSchema.datePublished = publishedAt.value
150+
}
151+
152+
if (modifiedAt.value) {
153+
articleSchema.dateModified = modifiedAt.value
154+
}
155+
156+
if (site.name) {
157+
articleSchema.publisher = {
158+
'@type': 'Organization',
159+
'name': site.name,
160+
}
161+
}
162+
163+
scripts.push({
164+
type: 'application/ld+json',
165+
innerHTML: JSON.stringify(articleSchema),
166+
})
167+
}
168+
169+
// WebSite schema for landing pages
170+
if (type.value === 'website') {
171+
const websiteSchema: Record<string, unknown> = {
172+
'@context': 'https://schema.org',
173+
'@type': 'WebSite',
174+
'name': site.name || title.value,
175+
'description': description.value,
176+
'url': baseUrl.value,
177+
}
178+
179+
scripts.push({
180+
type: 'application/ld+json',
181+
innerHTML: JSON.stringify(websiteSchema),
182+
})
183+
}
184+
185+
// BreadcrumbList schema for navigation
186+
if (breadcrumbs.value && breadcrumbs.value.length > 0) {
187+
const breadcrumbSchema = {
188+
'@context': 'https://schema.org',
189+
'@type': 'BreadcrumbList',
190+
'itemListElement': breadcrumbs.value.map((item, index) => ({
191+
'@type': 'ListItem',
192+
'position': index + 1,
193+
'name': item.title,
194+
'item': joinURL(baseUrl.value, item.path),
195+
})),
196+
}
197+
198+
scripts.push({
199+
type: 'application/ld+json',
200+
innerHTML: JSON.stringify(breadcrumbSchema),
201+
})
202+
}
203+
204+
return scripts
205+
}),
206+
})
207+
}

layer/app/pages/[[lang]]/[...slug].vue

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,16 @@ if (!page.value) {
3131
const title = page.value.seo?.title || page.value.title
3232
const description = page.value.seo?.description || page.value.description
3333
34-
useSeoMeta({
34+
const headline = ref(findPageHeadline(navigation?.value, page.value?.path))
35+
const breadcrumbs = computed(() => findPageBreadcrumbs(navigation?.value, page.value?.path || ''))
36+
37+
useSeo({
3538
title,
36-
ogTitle: title,
3739
description,
38-
ogDescription: description,
40+
type: 'article',
41+
modifiedAt: (page.value as unknown as Record<string, unknown>).modifiedAt as string | undefined,
42+
breadcrumbs,
3943
})
40-
41-
const headline = ref(findPageHeadline(navigation?.value, page.value?.path))
4244
watch(() => navigation?.value, () => {
4345
headline.value = findPageHeadline(navigation?.value, page.value?.path) || headline.value
4446
})

layer/app/templates/landing.vue

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,14 @@ if (!page.value) {
1515
const title = page.value.seo?.title || page.value.title
1616
const description = page.value.seo?.description || page.value.description
1717
18-
useSeoMeta({
18+
useSeo({
1919
title,
2020
description,
21-
ogTitle: title,
22-
ogDescription: description,
21+
type: 'website',
22+
ogImage: page.value?.seo?.ogImage as string | undefined,
2323
})
2424
25-
if (page.value?.seo?.ogImage) {
26-
useSeoMeta({
27-
ogImage: page.value.seo.ogImage,
28-
twitterImage: page.value.seo.ogImage,
29-
})
30-
}
31-
else {
25+
if (!page.value?.seo?.ogImage) {
3226
defineOgImageComponent('Landing', {
3327
title,
3428
description,

layer/app/utils/navigation.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,34 @@ export const flattenNavigation = (items?: ContentNavigationItem[]): ContentNavig
55
? flattenNavigation(item.children)
66
: [item],
77
) || []
8+
9+
export interface BreadcrumbItem {
10+
title: string
11+
path: string
12+
}
13+
14+
/**
15+
* Find breadcrumb path to a page in the navigation tree
16+
*/
17+
export function findPageBreadcrumbs(
18+
navigation: ContentNavigationItem[] | undefined,
19+
path: string,
20+
currentPath: BreadcrumbItem[] = [],
21+
): BreadcrumbItem[] | undefined {
22+
if (!navigation) return undefined
23+
24+
for (const item of navigation) {
25+
const itemPath = [...currentPath, { title: item.title, path: item.path }]
26+
27+
if (item.path === path) {
28+
return itemPath
29+
}
30+
31+
if (item.children) {
32+
const found = findPageBreadcrumbs(item.children, path, itemPath)
33+
if (found) return found
34+
}
35+
}
36+
37+
return undefined
38+
}

layer/nuxt.config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,4 +93,13 @@ export default defineNuxtConfig({
9393
icon: {
9494
provider: 'iconify',
9595
},
96+
robots: {
97+
groups: [
98+
{
99+
userAgent: '*',
100+
allow: '/',
101+
},
102+
],
103+
sitemap: '/sitemap.xml',
104+
},
96105
})

layer/server/routes/sitemap.xml.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { queryCollection } from '@nuxt/content/server'
22
import { getAvailableLocales, getCollectionsToQuery } from '../utils/content'
33
import { inferSiteURL } from '../../utils/meta'
44

5+
interface SitemapUrl {
6+
loc: string
7+
lastmod?: string
8+
}
9+
510
export default defineEventHandler(async (event) => {
611
const config = useRuntimeConfig(event)
712
const siteUrl = inferSiteURL() || ''
@@ -18,7 +23,7 @@ export default defineEventHandler(async (event) => {
1823
collections.push('landing')
1924
}
2025

21-
const urls: Array<{ loc: string }> = []
26+
const urls: SitemapUrl[] = []
2227

2328
for (const collection of collections) {
2429
try {
@@ -34,9 +39,16 @@ export default defineEventHandler(async (event) => {
3439
// Skip .navigation files (used for navigation configuration)
3540
if (pagePath.endsWith('.navigation') || pagePath.includes('/.navigation')) continue
3641

37-
urls.push({
42+
const urlEntry: SitemapUrl = {
3843
loc: pagePath,
39-
})
44+
}
45+
46+
// Add lastmod if available (modifiedAt from content)
47+
if (meta.modifiedAt && typeof meta.modifiedAt === 'string') {
48+
urlEntry.lastmod = meta.modifiedAt.split('T')[0] // Use date part only (YYYY-MM-DD)
49+
}
50+
51+
urls.push(urlEntry)
4052
}
4153
}
4254
catch {
@@ -50,11 +62,18 @@ export default defineEventHandler(async (event) => {
5062
return sitemap
5163
})
5264

53-
function generateSitemap(urls: Array<{ loc: string }>, siteUrl: string): string {
65+
function generateSitemap(urls: SitemapUrl[], siteUrl: string): string {
5466
const urlEntries = urls
5567
.map((url) => {
5668
const loc = siteUrl ? `${siteUrl}${url.loc}` : url.loc
57-
return ` <url>\n <loc>${escapeXml(loc)}</loc>\n </url>`
69+
let entry = ` <url>\n <loc>${escapeXml(loc)}</loc>`
70+
71+
if (url.lastmod) {
72+
entry += `\n <lastmod>${escapeXml(url.lastmod)}</lastmod>`
73+
}
74+
75+
entry += `\n </url>`
76+
return entry
5877
})
5978
.join('\n')
6079

0 commit comments

Comments
 (0)