feat: Schema.org 구조화된 데이터 추가 및 페이지별 BreadcrumbList 생성
This commit is contained in:
94
app/app.vue
94
app/app.vue
@@ -2,6 +2,8 @@
|
||||
import { useNuxtApp } from 'nuxt/app'
|
||||
|
||||
const nuxtApp = useNuxtApp()
|
||||
const route = useRoute()
|
||||
const requestURL = useRequestURL()
|
||||
const { locale } = useI18n()
|
||||
const gameDataStore = useGameDataStore()
|
||||
const modalStore = useModalStore()
|
||||
@@ -122,6 +124,97 @@ const setupGameHead = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Schema.org 구조화된 데이터 설정
|
||||
const setupStructuredData = () => {
|
||||
if (!gameName.value) return
|
||||
|
||||
try {
|
||||
// SEO용 URL은 설정 도메인이 아닌 실제 요청 호스트 기준 (SSR/CSR 모두 useRequestURL)
|
||||
const baseUrl = requestURL.origin
|
||||
const currentLocale = locale.value ?? defaultLangCode.value ?? 'ko'
|
||||
|
||||
// Organization Schema
|
||||
const organizationSchema: Record<string, any> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: gameName.value,
|
||||
url: baseUrl,
|
||||
}
|
||||
|
||||
// 로고 이미지 추가 (favicon 중 PNG 우선)
|
||||
if (faviconJson.value) {
|
||||
const logoUrl = faviconJson.value[2] || faviconJson.value[0]
|
||||
if (logoUrl) {
|
||||
organizationSchema.logo = formatPathHost(logoUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// SNS 링크 추가
|
||||
const snsData = gameDataStore.snsJson
|
||||
if (snsData) {
|
||||
const sameAs: string[] = []
|
||||
if (snsData.youtube?.use_yn === 1 && snsData.youtube?.url) {
|
||||
sameAs.push(snsData.youtube.url)
|
||||
}
|
||||
if (snsData.twitter?.use_yn === 1 && snsData.twitter?.url) {
|
||||
sameAs.push(snsData.twitter.url)
|
||||
}
|
||||
if (snsData.facebook?.use_yn === 1 && snsData.facebook?.url) {
|
||||
sameAs.push(snsData.facebook.url)
|
||||
}
|
||||
if (snsData.discord?.use_yn === 1 && snsData.discord?.url) {
|
||||
sameAs.push(snsData.discord.url)
|
||||
}
|
||||
if (snsData.instagram?.use_yn === 1 && snsData.instagram?.url) {
|
||||
sameAs.push(snsData.instagram.url)
|
||||
}
|
||||
if (snsData.tiktok?.use_yn === 1 && snsData.tiktok?.url) {
|
||||
sameAs.push(snsData.tiktok.url)
|
||||
}
|
||||
|
||||
if (sameAs.length > 0) {
|
||||
organizationSchema.sameAs = sameAs
|
||||
}
|
||||
}
|
||||
|
||||
// WebSite Schema - 사이트 정보
|
||||
const websiteSchema: Record<string, any> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: gameName.value,
|
||||
url: baseUrl,
|
||||
inLanguage: currentLocale,
|
||||
}
|
||||
|
||||
// 설명 추가 (있는 경우)
|
||||
if (gameMetaTag.value?.page_desc) {
|
||||
websiteSchema.description = gameMetaTag.value.page_desc
|
||||
}
|
||||
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify(organizationSchema),
|
||||
},
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify(websiteSchema),
|
||||
},
|
||||
],
|
||||
link: [
|
||||
{
|
||||
rel: 'canonical',
|
||||
href: `${baseUrl}${route.path}`,
|
||||
},
|
||||
],
|
||||
})
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[setupStructuredData] Failed to setup schema:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.server) {
|
||||
const gameData = nuxtApp.ssrContext?.event?.context?.gameData
|
||||
if (gameData) {
|
||||
@@ -130,6 +223,7 @@ if (import.meta.server) {
|
||||
}
|
||||
|
||||
setupGameHead()
|
||||
setupStructuredData()
|
||||
|
||||
let rafId: number | null = null
|
||||
onMounted(() => {
|
||||
|
||||
221
layers/composables/useSchema.ts
Normal file
221
layers/composables/useSchema.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Schema.org 구조화된 데이터 유틸리티
|
||||
*
|
||||
* Schema.org 마크업을 쉽게 추가하고 관리하기 위한 composable
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const { addSchema, addVideoSchema, addImageGallerySchema } = useSchema()
|
||||
*
|
||||
* // 커스텀 스키마 추가
|
||||
* addSchema({
|
||||
* '@context': 'https://schema.org',
|
||||
* '@type': 'Article',
|
||||
* headline: 'My Article'
|
||||
* })
|
||||
*
|
||||
* // 비디오 스키마 추가
|
||||
* addVideoSchema({
|
||||
* name: 'Video Title',
|
||||
* description: 'Video Description',
|
||||
* thumbnailUrl: 'https://example.com/thumb.jpg',
|
||||
* uploadDate: '2025-01-01',
|
||||
* contentUrl: 'https://youtube.com/watch?v=xxx'
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export const useSchema = () => {
|
||||
const gameDataStore = useGameDataStore()
|
||||
const { gameName } = storeToRefs(gameDataStore)
|
||||
|
||||
/**
|
||||
* 커스텀 Schema.org JSON-LD를 head에 추가
|
||||
*/
|
||||
const addSchema = (schema: Record<string, any>) => {
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify(schema),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* VideoObject Schema 생성 및 추가
|
||||
*/
|
||||
const addVideoSchema = (options: {
|
||||
name: string
|
||||
description?: string
|
||||
thumbnailUrl: string
|
||||
uploadDate: string
|
||||
contentUrl: string
|
||||
embedUrl?: string
|
||||
}) => {
|
||||
const schema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'VideoObject',
|
||||
name: options.name,
|
||||
description: options.description || options.name,
|
||||
thumbnailUrl: options.thumbnailUrl,
|
||||
uploadDate: options.uploadDate,
|
||||
contentUrl: options.contentUrl,
|
||||
embedUrl: options.embedUrl || options.contentUrl,
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: gameName.value,
|
||||
},
|
||||
}
|
||||
|
||||
addSchema(schema)
|
||||
}
|
||||
|
||||
/**
|
||||
* ImageGallery Schema 생성 및 추가
|
||||
*/
|
||||
const addImageGallerySchema = (
|
||||
images: Array<{
|
||||
contentUrl: string
|
||||
caption?: string
|
||||
}>
|
||||
) => {
|
||||
const schema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ImageGallery',
|
||||
image: images.map(img => ({
|
||||
'@type': 'ImageObject',
|
||||
contentUrl: img.contentUrl,
|
||||
caption: img.caption || '',
|
||||
})),
|
||||
}
|
||||
|
||||
addSchema(schema)
|
||||
}
|
||||
|
||||
/**
|
||||
* SoftwareApplication Schema 생성 및 추가
|
||||
*/
|
||||
const addSoftwareApplicationSchema = (options: {
|
||||
name: string
|
||||
operatingSystem: string
|
||||
description?: string
|
||||
price?: string
|
||||
priceCurrency?: string
|
||||
}) => {
|
||||
const schema: Record<string, any> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: options.name,
|
||||
operatingSystem: options.operatingSystem,
|
||||
applicationCategory: 'GameApplication',
|
||||
}
|
||||
|
||||
if (options.description) {
|
||||
schema.description = options.description
|
||||
}
|
||||
|
||||
if (options.price !== undefined) {
|
||||
schema.offers = {
|
||||
'@type': 'Offer',
|
||||
price: options.price,
|
||||
priceCurrency: options.priceCurrency || 'USD',
|
||||
}
|
||||
}
|
||||
|
||||
addSchema(schema)
|
||||
}
|
||||
|
||||
/**
|
||||
* Event Schema 생성 및 추가
|
||||
*/
|
||||
const addEventSchema = (options: {
|
||||
name: string
|
||||
startDate: string
|
||||
endDate?: string
|
||||
eventStatus?:
|
||||
| 'EventScheduled'
|
||||
| 'EventCancelled'
|
||||
| 'EventMovedOnline'
|
||||
| 'EventPostponed'
|
||||
| 'EventRescheduled'
|
||||
description?: string
|
||||
image?: string
|
||||
location?: string
|
||||
}) => {
|
||||
const schema: Record<string, any> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Event',
|
||||
name: options.name,
|
||||
startDate: options.startDate,
|
||||
eventStatus: `https://schema.org/${options.eventStatus || 'EventScheduled'}`,
|
||||
}
|
||||
|
||||
if (options.endDate) {
|
||||
schema.endDate = options.endDate
|
||||
}
|
||||
|
||||
if (options.description) {
|
||||
schema.description = options.description
|
||||
}
|
||||
|
||||
if (options.image) {
|
||||
schema.image = options.image
|
||||
}
|
||||
|
||||
if (options.location) {
|
||||
schema.location = {
|
||||
'@type': 'VirtualLocation',
|
||||
url: options.location,
|
||||
}
|
||||
}
|
||||
|
||||
addSchema(schema)
|
||||
}
|
||||
|
||||
/**
|
||||
* Article Schema 생성 및 추가
|
||||
*/
|
||||
const addArticleSchema = (options: {
|
||||
headline: string
|
||||
datePublished: string
|
||||
dateModified?: string
|
||||
author?: string
|
||||
description?: string
|
||||
image?: string
|
||||
}) => {
|
||||
const schema: Record<string, any> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: options.headline,
|
||||
datePublished: options.datePublished,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: options.author || gameName.value,
|
||||
},
|
||||
}
|
||||
|
||||
if (options.dateModified) {
|
||||
schema.dateModified = options.dateModified
|
||||
}
|
||||
|
||||
if (options.description) {
|
||||
schema.description = options.description
|
||||
}
|
||||
|
||||
if (options.image) {
|
||||
schema.image = options.image
|
||||
}
|
||||
|
||||
addSchema(schema)
|
||||
}
|
||||
|
||||
return {
|
||||
addSchema,
|
||||
addVideoSchema,
|
||||
addImageGallerySchema,
|
||||
addSoftwareApplicationSchema,
|
||||
addEventSchema,
|
||||
addArticleSchema,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,70 @@
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const { locale } = useI18n()
|
||||
const gameDataStore = useGameDataStore()
|
||||
const { gameName } = storeToRefs(gameDataStore)
|
||||
const requestURL = useRequestURL()
|
||||
|
||||
// BreadcrumbList Schema 생성
|
||||
const breadcrumbSchema = computed(() => {
|
||||
if (!gameName.value) return null
|
||||
|
||||
const baseUrl = requestURL.origin
|
||||
console.log("🚀 ~ baseUrl:", baseUrl)
|
||||
const pathSegments = route.path.split('/').filter(Boolean)
|
||||
const currentLocale = pathSegments[0] || locale.value
|
||||
|
||||
const itemListElement: Array<{
|
||||
'@type': string
|
||||
position: number
|
||||
name: string
|
||||
item: string
|
||||
}> = [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: gameName.value,
|
||||
item: `${baseUrl}/${currentLocale}`,
|
||||
},
|
||||
]
|
||||
|
||||
// 로케일 이후의 경로 세그먼트들을 breadcrumb에 추가
|
||||
pathSegments.slice(1).forEach((segment, index) => {
|
||||
const path = `/${pathSegments.slice(0, index + 2).join('/')}`
|
||||
const displayName = segment
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/\b\w/g, l => l.toUpperCase())
|
||||
|
||||
itemListElement.push({
|
||||
'@type': 'ListItem',
|
||||
position: index + 2,
|
||||
name: displayName,
|
||||
item: `${baseUrl}${path}`,
|
||||
})
|
||||
})
|
||||
|
||||
console.log("🚀 ~ itemListElement:", itemListElement)
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement,
|
||||
}
|
||||
})
|
||||
|
||||
// Schema를 head에 추가
|
||||
watchEffect(() => {
|
||||
if (breadcrumbSchema.value) {
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify(breadcrumbSchema.value),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LayoutsHeader />
|
||||
|
||||
@@ -1,4 +1,68 @@
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const { locale } = useI18n()
|
||||
const gameDataStore = useGameDataStore()
|
||||
const { gameName } = storeToRefs(gameDataStore)
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
|
||||
// BreadcrumbList Schema 생성
|
||||
const breadcrumbSchema = computed(() => {
|
||||
if (!gameName.value || !runtimeConfig.public.baseDomain) return null
|
||||
|
||||
const baseUrl = `https://${runtimeConfig.public.baseDomain}`
|
||||
const pathSegments = route.path.split('/').filter(Boolean)
|
||||
const currentLocale = pathSegments[0] || locale.value
|
||||
|
||||
const itemListElement: Array<{
|
||||
'@type': string
|
||||
position: number
|
||||
name: string
|
||||
item: string
|
||||
}> = [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: gameName.value,
|
||||
item: `${baseUrl}/${currentLocale}`,
|
||||
},
|
||||
]
|
||||
|
||||
// 로케일 이후의 경로 세그먼트들을 breadcrumb에 추가
|
||||
pathSegments.slice(1).forEach((segment, index) => {
|
||||
const path = `/${pathSegments.slice(0, index + 2).join('/')}`
|
||||
const displayName = segment
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/\b\w/g, l => l.toUpperCase())
|
||||
|
||||
itemListElement.push({
|
||||
'@type': 'ListItem',
|
||||
position: index + 2,
|
||||
name: displayName,
|
||||
item: `${baseUrl}${path}`,
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement,
|
||||
}
|
||||
})
|
||||
|
||||
// Schema를 head에 추가
|
||||
watchEffect(() => {
|
||||
if (breadcrumbSchema.value) {
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify(breadcrumbSchema.value),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LayoutsHeader />
|
||||
|
||||
Reference in New Issue
Block a user