diff --git a/app/app.vue b/app/app.vue index 83d2c28..3bf48d6 100644 --- a/app/app.vue +++ b/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 = { + '@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 = { + '@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(() => { diff --git a/layers/composables/useSchema.ts b/layers/composables/useSchema.ts new file mode 100644 index 0000000..08e6bf2 --- /dev/null +++ b/layers/composables/useSchema.ts @@ -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) => { + 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 = { + '@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 = { + '@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 = { + '@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, + } +} diff --git a/layers/layouts/default.vue b/layers/layouts/default.vue index 3003290..f2040b7 100644 --- a/layers/layouts/default.vue +++ b/layers/layouts/default.vue @@ -1,4 +1,70 @@ - +