feat: CtLayout01 커스텀 콘텐츠 기능 개선
- fnCustomVideo 유튜브 팝업 함수 추가 - script/link exclude 속성 지원 - 전역 함수 등록 로직 통합 (registerGlobalFunctions) - CSS Selector injection 보안 취약점 수정 - 사용되지 않는 변수/props 제거 - DOMPurify exclude, defer 속성 허용 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,3 +15,4 @@ logs
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
CLAUDE.md
|
||||
|
||||
@@ -11,6 +11,40 @@ const DEFAULT_LOCALES: Record<string, LocaleObject> = {
|
||||
th: { code: 'th', name: 'ภาษาไทย', iso: 'th', dir: 'ltr' },
|
||||
}
|
||||
const DEFAULT_LOCALE_CODE = 'ko'
|
||||
const ISO_LANGUAGE_CODES = [
|
||||
'en', // 영어
|
||||
'zh', // 중국어(표준어/만다린)
|
||||
'zh-tw', // 중국어(번체)
|
||||
'zh-cn', // 중국어(간체)
|
||||
'es', // 스페인어
|
||||
'ar', // 아랍어
|
||||
'id', // 인도네시아어
|
||||
'pt', // 포르투갈어
|
||||
'fr', // 프랑스어
|
||||
'ja', // 일본어
|
||||
'ru', // 러시아어
|
||||
'de', // 독일어
|
||||
'hi', // 힌디어
|
||||
'vi', // 베트남어
|
||||
'tr', // 터키어
|
||||
'ko', // 한국어
|
||||
'it', // 이탈리아어
|
||||
'bn', // 벵골어
|
||||
'pl', // 폴란드어
|
||||
'th', // 태국어
|
||||
'fa', // 페르시아어
|
||||
'nl', // 네덜란드어
|
||||
'ur', // 우르두어
|
||||
'te', // 텔루구어
|
||||
'mr', // 마라티어
|
||||
'ta', // 타밀어
|
||||
'tl', // 타갈로그어(필리핀어)
|
||||
'uk', // 우크라이나어
|
||||
'sw', // 스와힐리어
|
||||
'ro', // 루마니아어
|
||||
'hu', // 헝가리어
|
||||
'ms', // 말레이어
|
||||
]
|
||||
|
||||
// getI18n 함수가 NuxtI18nOptions 타입의 값을 반환하도록 명시적으로 타입을 지정합니다.
|
||||
const getI18n = (allowedLangCodes?: string[]): NuxtI18nOptions => {
|
||||
@@ -52,4 +86,4 @@ const getI18n = (allowedLangCodes?: string[]): NuxtI18nOptions => {
|
||||
}
|
||||
}
|
||||
|
||||
export { DEFAULT_LOCALE_CODE, DEFAULT_COVERAGES, getI18n }
|
||||
export { DEFAULT_LOCALE_CODE, ISO_LANGUAGE_CODES, getI18n }
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import type { TrackingObject } from '#layers/types/api/common'
|
||||
import type { ColorObject, TrackingObject } from '#layers/types/api/common'
|
||||
|
||||
interface Props {
|
||||
color: ColorObject
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const { locale } = useI18n()
|
||||
const { y: windowY } = useWindowScroll({ behavior: 'smooth' })
|
||||
@@ -12,6 +18,9 @@ const analytics = {
|
||||
} as TrackingObject
|
||||
|
||||
const showBtn = computed(() => windowY.value > 0)
|
||||
const backgroundColor = computed(() =>
|
||||
getColorCodeFromData(props.color, 'none')
|
||||
)
|
||||
|
||||
const handleScrollToTop = () => {
|
||||
windowY.value = 0
|
||||
@@ -25,6 +34,7 @@ const handleScrollToTop = () => {
|
||||
v-show="showBtn"
|
||||
class="btn-top"
|
||||
sr-only="top"
|
||||
:style="{ backgroundColor: backgroundColor }"
|
||||
@click="handleScrollToTop"
|
||||
>
|
||||
<AtomsIconsArrowControlTopLine />
|
||||
@@ -39,4 +49,7 @@ const handleScrollToTop = () => {
|
||||
.btn-top:hover :deep(.icon) {
|
||||
@apply -translate-y-[3px];
|
||||
}
|
||||
.btn-top:hover :deep(.icon svg) {
|
||||
fill-opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -104,7 +104,10 @@ onMounted(() => {
|
||||
<ClientOnly>
|
||||
<BlocksLnb v-if="isShowLnb" />
|
||||
<div v-if="isShowTopBtn" class="utile-wrap">
|
||||
<BlocksButtonScrollTop v-if="isShowTopBtn" />
|
||||
<BlocksButtonScrollTop
|
||||
v-if="isShowTopBtn"
|
||||
:color="pageData?.top_btn_color_json"
|
||||
/>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
<BlocksSns v-if="isShowSnsBtn" />
|
||||
|
||||
@@ -1,44 +1,50 @@
|
||||
import { DEFAULT_LOCALE_CODE, ISO_LANGUAGE_CODES } from '@/i18n.config'
|
||||
import { csrGetFinalLocale } from '#layers/utils/localeUtil'
|
||||
import { parseUrl, isExternalUrl } from '#layers/utils/urlUtil'
|
||||
|
||||
export default defineNuxtRouteMiddleware(to => {
|
||||
// server에서는 실행X -----
|
||||
// 서버에서는 실행하지 않음 (서버 미들웨어에서 이미 처리)
|
||||
if (import.meta.server) return
|
||||
|
||||
// error 페이지는 실행X -----
|
||||
if (to.path.includes('/error')) return
|
||||
|
||||
// inspection 페이지는 실행X -----
|
||||
if (to.path.includes('/inspection')) return
|
||||
// error 페이지와 inspection 페이지는 미들웨어 실행 제외
|
||||
if (to.path.includes('/error') || to.path.includes('/inspection')) {
|
||||
return
|
||||
}
|
||||
|
||||
const gameDataStore = useGameDataStore()
|
||||
const { langCodes, intro } = storeToRefs(gameDataStore)
|
||||
const { langCodes, defaultLangCode, intro } = storeToRefs(gameDataStore)
|
||||
|
||||
// app.vue에서 설정한 스토어 값이 없으면 대기
|
||||
if (!langCodes.value || !intro.value) return
|
||||
// 게임 데이터 스토어가 초기화되지 않았으면 대기
|
||||
if (!langCodes.value || !defaultLangCode.value || !intro.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------
|
||||
// [Home Redirect]
|
||||
// -------------------------------------------------------------------------------
|
||||
const gamePath = getPathAfterLanguage(to.path)
|
||||
const langCode = csrGetFinalLocale(to.path, langCodes.value)
|
||||
const fullPath = to.fullPath || to.path || ''
|
||||
|
||||
const isRootPath = gamePath === '' || gamePath === '/'
|
||||
// 최종 로케일 결정
|
||||
const finalLocale =
|
||||
csrGetFinalLocale(fullPath, langCodes.value) ||
|
||||
defaultLangCode.value ||
|
||||
DEFAULT_LOCALE_CODE
|
||||
|
||||
if (isRootPath) {
|
||||
// intro.page_url이 있으면 해당 URL로 리다이렉트, 없으면 /home으로
|
||||
const introPageUrl = intro.value?.page_url
|
||||
const redirectPath = getIntroRedirectPath(
|
||||
introPageUrl,
|
||||
langCode,
|
||||
to.fullPath
|
||||
// parseUrl을 사용하여 최종 경로 계산
|
||||
const introPageUrl = intro.value?.page_url || ''
|
||||
const finalPath = parseUrl(
|
||||
fullPath,
|
||||
finalLocale,
|
||||
langCodes.value,
|
||||
ISO_LANGUAGE_CODES,
|
||||
introPageUrl
|
||||
)
|
||||
|
||||
// 무한 리다이렉트 방지: 현재 경로와 리다이렉트할 URL 비교
|
||||
const normalizedFinalUrl = redirectPath.split('?')[0] // 리다이렉트 경로 (쿼리스트링 제외)
|
||||
const isExternal = isExternalUrl(redirectPath)
|
||||
const isSamePath = !isExternal && to.path === normalizedFinalUrl
|
||||
// 현재 경로와 최종 경로가 같으면 리다이렉트 불필요
|
||||
if (fullPath.split('?')[0] === finalPath.split('?')[0]) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSamePath) {
|
||||
// 다른 경로에서 접근한 경우에만 리다이렉트
|
||||
return navigateTo(redirectPath, { external: isExternal })
|
||||
}
|
||||
}
|
||||
// 외부 URL인지 확인
|
||||
const isExternal = isExternalUrl(finalPath)
|
||||
|
||||
// 리다이렉트 수행
|
||||
return navigateTo(finalPath, { external: isExternal })
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
|
||||
const stoveApiBaseUrl = runtimeConfig.public.stoveApiUrl
|
||||
const accessToken = csrGetAccessToken()
|
||||
const gameDomain = getGameDomain()
|
||||
const gamePath = getPathAfterLanguage(to.path)
|
||||
const pathWithoutLocale = getPathAfterLanguage(to.path)
|
||||
const langCode = csrGetFinalLocale(to.path, langCodes.value) || 'ko'
|
||||
|
||||
let pageDataResponse: PageDataResponse | null = null
|
||||
@@ -41,7 +41,7 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
|
||||
let queryParams: Record<string, string>
|
||||
let apiUrl: string
|
||||
|
||||
if (gamePath === '/preview') {
|
||||
if (pathWithoutLocale === '/preview') {
|
||||
// 미리보기 쿼리스트링에서 파라미터 값 추출
|
||||
// preview?page_seq=1&page_ver=1&lang_code=ko
|
||||
const queryString = to.fullPath.includes('?')
|
||||
@@ -64,7 +64,7 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
|
||||
queryParams = {
|
||||
game_domain: gameDomain,
|
||||
lang_code: langCode,
|
||||
page_url: gamePath,
|
||||
page_url: pathWithoutLocale,
|
||||
_t: Date.now().toString(), // 캐시 무효화를 위한 타임스탬프
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
|
||||
// 91002 (Invalid LangCode): 미지원 언어로 접근
|
||||
if (pageDataResponse?.code === 91002) {
|
||||
// 이미 /home 경로에 있으면 무한 리다이렉트 방지
|
||||
if (gamePath === '/home') {
|
||||
if (pathWithoutLocale === '/home') {
|
||||
return createError({
|
||||
statusCode: 404,
|
||||
statusMessage: pageDataResponse?.message,
|
||||
|
||||
@@ -94,6 +94,9 @@ const adminConfig: DOMPurifyConfig = {
|
||||
'crossorigin',
|
||||
'referrerpolicy',
|
||||
'allowfullscreen',
|
||||
'exclude-custom',
|
||||
'exclude',
|
||||
'defer',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { LRUCache } from 'lru-cache'
|
||||
import { defineEventHandler, createError, type H3Event } from 'h3'
|
||||
import {
|
||||
getGameDomain,
|
||||
getPathLocale,
|
||||
getIntroRedirectPath,
|
||||
isExternalUrl,
|
||||
getPathAfterLanguage,
|
||||
} from '#layers/utils/urlUtil'
|
||||
import { ISO_LANGUAGE_CODES } from '@/i18n.config'
|
||||
import { getGameDomain, getPathLocale, parseUrl } from '#layers/utils/urlUtil'
|
||||
import { ssrGetFinalLocale } from '#layers/utils/localeUtil'
|
||||
import { isStaticFile } from '#layers/utils/commonUtil'
|
||||
import { getTrueClientIp } from '#layers/utils/apiUtil'
|
||||
@@ -73,42 +68,42 @@ const cache = new LRUCache<string, WebInspectionData>({
|
||||
|
||||
/**
|
||||
* Locale Middleware 역할 함수
|
||||
*
|
||||
* @param event - 이벤트 객체
|
||||
* @param finalLocale - 최종 언어
|
||||
* URL의 언어 코드를 최종 언어로 변경하거나 추가
|
||||
*/
|
||||
function fnLocaleMiddleware(event: H3Event, finalLocale: string) {
|
||||
function fnLocaleMiddleware(
|
||||
event: H3Event,
|
||||
finalLocale: string,
|
||||
langCodes: string[],
|
||||
intro: string
|
||||
): void {
|
||||
// 이미 응답이 종료되었는지 확인
|
||||
if (event.node.res.headersSent || event.node.res.writableEnded) {
|
||||
return
|
||||
}
|
||||
|
||||
const path = event?.node.req.url || ''
|
||||
const [pathPart, queryString = ''] = path.split('?')
|
||||
const arrPath = pathPart.split('/')
|
||||
const finalPath = parseUrl(
|
||||
path,
|
||||
finalLocale,
|
||||
langCodes,
|
||||
ISO_LANGUAGE_CODES,
|
||||
intro
|
||||
)
|
||||
|
||||
// 최종 언어 세팅된 경로 생성
|
||||
const pathLocale = arrPath.length > 1 ? arrPath[1] : ''
|
||||
|
||||
// URL에서 현재 언어와 최종 언어가 다르면 리다이렉트
|
||||
if (pathLocale !== finalLocale) {
|
||||
let newLocalePath = ''
|
||||
if (pathLocale === '') {
|
||||
newLocalePath = `/${finalLocale}`
|
||||
} else {
|
||||
arrPath[1] = finalLocale
|
||||
newLocalePath = arrPath.join('/')
|
||||
// finalPath가 유효하지 않으면 리다이렉트하지 않음
|
||||
if (!finalPath || typeof finalPath !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
if (queryString) {
|
||||
newLocalePath += `?${queryString}`
|
||||
// 현재 경로와 최종 경로가 같으면 리다이렉트 불필요
|
||||
if (path === finalPath) {
|
||||
return
|
||||
}
|
||||
|
||||
event.node.res.statusCode = 302
|
||||
event.node.res.setHeader('Location', newLocalePath)
|
||||
event.node.res.setHeader('Location', finalPath)
|
||||
event.node.res.end()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 경로를 건너뛰어야 하는지 확인
|
||||
@@ -144,6 +139,7 @@ export default defineEventHandler(async event => {
|
||||
let gameDataResponse: GameDataResponse | null = null
|
||||
let gameDataLangCodes: string[] | null = null
|
||||
let gameDataDefaultLocale: string | null = null
|
||||
let gameDataIntro: string | null = null
|
||||
let finalLocale: string
|
||||
|
||||
try {
|
||||
@@ -160,6 +156,7 @@ export default defineEventHandler(async event => {
|
||||
gameDataResponse = response
|
||||
gameDataLangCodes = response?.value?.lang_codes || null
|
||||
gameDataDefaultLocale = response?.value?.default_lang_code || null
|
||||
gameDataIntro = response?.value?.intro?.page_url || ''
|
||||
event.context.gameDomain = gameDomain
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -263,7 +260,12 @@ export default defineEventHandler(async event => {
|
||||
// -------------------------------------------------------------------------------
|
||||
// [Locale Middleware]
|
||||
// -------------------------------------------------------------------------------
|
||||
fnLocaleMiddleware(event, finalLocale)
|
||||
fnLocaleMiddleware(
|
||||
event,
|
||||
finalLocale,
|
||||
gameDataLangCodes,
|
||||
gameDataIntro
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (!event.node.res.headersSent && !event.node.res.writableEnded) {
|
||||
@@ -302,40 +304,7 @@ export default defineEventHandler(async event => {
|
||||
// -------------------------------------------------------------------------------
|
||||
// [Locale Middleware]
|
||||
// -------------------------------------------------------------------------------
|
||||
fnLocaleMiddleware(event, finalLocale)
|
||||
|
||||
// -------------------------------------------------------------------------------
|
||||
// [Home Redirect]
|
||||
// -------------------------------------------------------------------------------
|
||||
if (!event.node.res.headersSent && !event.node.res.writableEnded) {
|
||||
const gamePath = getPathAfterLanguage(fullPath)
|
||||
const isRootPath = gamePath === '' || gamePath === '/'
|
||||
|
||||
if (isRootPath) {
|
||||
// intro.page_url이 있으면 해당 URL로 리다이렉트, 없으면 /home으로
|
||||
const introPageUrl = gameDataValue?.intro?.page_url
|
||||
const currentFullUrl = event.node.req.url || fullPath
|
||||
const redirectPath = getIntroRedirectPath(
|
||||
introPageUrl,
|
||||
finalLocale,
|
||||
currentFullUrl
|
||||
)
|
||||
|
||||
// 무한 리다이렉트 방지: 현재 경로와 리다이렉트할 URL 비교
|
||||
const currentPathOnly = fullPath.split('?')[0] // 현재 경로 (쿼리스트링 제외)
|
||||
const normalizedFinalUrl = redirectPath.split('?')[0] // 리다이렉트 경로 (쿼리스트링 제외)
|
||||
const isExternal = isExternalUrl(redirectPath)
|
||||
const isSamePath =
|
||||
!isExternal && currentPathOnly === normalizedFinalUrl
|
||||
|
||||
if (!isSamePath) {
|
||||
// 다른 경로에서 접근한 경우에만 리다이렉트
|
||||
event.node.res.statusCode = 302
|
||||
event.node.res.setHeader('Location', redirectPath)
|
||||
event.node.res.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
fnLocaleMiddleware(event, finalLocale, gameDataLangCodes, gameDataIntro)
|
||||
}
|
||||
|
||||
// 정상 접속 허용
|
||||
|
||||
@@ -11,13 +11,12 @@
|
||||
cancelText?: string,
|
||||
) => void
|
||||
fnCustomLog: (clickSarea: string, clickItem: string) => void
|
||||
fnCustomVideo: (youtubeUrl: string) => void
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
components: PageDataTemplateComponents
|
||||
pageVerTmplSeq: number
|
||||
pageVerTmplNameEn: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -29,16 +28,18 @@
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const staticUrl = runtimeConfig.public.staticUrl
|
||||
|
||||
// CSR에서만 fnCustomAction, fnCustomLog 즉시 설정 (HTML 렌더링 전에 필요)
|
||||
// handleCustomAction, handleCustomLog가 정의되기 전이므로 나중에 업데이트됨
|
||||
// CSR에서만 fnCustomAction, fnCustomLog, fnCustomVideo 즉시 설정 (HTML 렌더링 전에 필요)
|
||||
// handleCustomAction, handleCustomLog, handleCustomVideo가 정의되기 전이므로 나중에 업데이트됨
|
||||
if (import.meta.client) {
|
||||
// onMounted에서 실제 함수로 교체됨
|
||||
;window.fnCustomAction = () => {
|
||||
// handleCustomAction이 아직 정의되지 않았을 수 있으므로 무시
|
||||
// onMounted에서 실제 함수로 교체됨
|
||||
}
|
||||
;window.fnCustomLog = () => {
|
||||
// handleCustomLog가 아직 정의되지 않았을 수 있으므로 무시
|
||||
// onMounted에서 실제 함수로 교체됨
|
||||
}
|
||||
;window.fnCustomVideo = () => {
|
||||
// handleCustomVideo가 아직 정의되지 않았을 수 있으므로 무시
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +70,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
// tm('key') 또는 tm("key") 형태의 문자열을 i18n 번역으로 변환
|
||||
// 일반 텍스트는 그대로 반환
|
||||
const resolveI18nText = (text: string): string => {
|
||||
if (!text) return ''
|
||||
|
||||
// tm('...') 또는 tm("...") 형태 감지
|
||||
const tmPattern = /^tm\(\s*(["'])([^"']+)\1\s*\)$/
|
||||
const match = text.trim().match(tmPattern)
|
||||
|
||||
if (match) {
|
||||
const key = match[2]
|
||||
return coerceToString(tm(key))
|
||||
}
|
||||
|
||||
// 일반 텍스트는 그대로 반환
|
||||
return text
|
||||
}
|
||||
|
||||
// API로 내려온 "HTML 문자열" 내부의 {{ tm('key') }} 형태를 현재 locale 기준 번역 문자열로 치환
|
||||
// (Vue 템플릿 컴파일이 일어나지 않으므로 사전 치환이 필요)
|
||||
const applyI18nToApiHtml = (html: string): string => {
|
||||
@@ -84,14 +103,12 @@
|
||||
|
||||
// fetch한 HTML을 저장할 ref
|
||||
const fetchedHtml = ref<string>('')
|
||||
const isLoadingHtml = ref(false)
|
||||
// script/link 상대 경로 해석용 베이스 URL (staticUrl + cleanedResPath)
|
||||
const assetBaseUrl = ref<string>('')
|
||||
|
||||
// URL에서 HTML 가져오기
|
||||
const fetchHtmlFromUrl = async (url: string): Promise<string> => {
|
||||
try {
|
||||
isLoadingHtml.value = true
|
||||
const response = await $fetch<string>(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -103,8 +120,6 @@
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Exception] Failed to fetch custom HTML:', error)
|
||||
return ''
|
||||
} finally {
|
||||
isLoadingHtml.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,20 +154,11 @@
|
||||
return
|
||||
}
|
||||
|
||||
// 로케일 코드 변환 맵 (소문자 하이픈 -> 대문자 하이픈)
|
||||
const localeMap: Record<string, string> = {
|
||||
'zh-tw': 'zh-TW',
|
||||
'zh-cn': 'zh-CN',
|
||||
}
|
||||
|
||||
// 현재 locale에 해당하는 번역 데이터 찾기
|
||||
const currentLocale = locale.value
|
||||
const normalizedLocale = localeMap[currentLocale] || currentLocale
|
||||
|
||||
// 대소문자 구분 없이 로케일 데이터 찾기
|
||||
const localeData =
|
||||
translations[normalizedLocale] ||
|
||||
translations[normalizedLocale.toLowerCase()] ||
|
||||
translations[currentLocale] ||
|
||||
translations[currentLocale.toLowerCase()]
|
||||
|
||||
@@ -347,9 +353,12 @@
|
||||
|
||||
const attrs = parseTagAttributes(tagHtml)
|
||||
|
||||
// exclude 속성이 있으면 건너뛰기 (boolean attribute)
|
||||
const hasExclude = /\bexclude\b/i.test(tagHtml)
|
||||
|
||||
if (tagName === 'link') {
|
||||
const href = attrs.href
|
||||
if (!href) continue
|
||||
if (!href || hasExclude) continue
|
||||
assets.push({
|
||||
kind: 'link',
|
||||
href,
|
||||
@@ -364,7 +373,7 @@
|
||||
|
||||
if (tagName === 'script') {
|
||||
const src = attrs.src
|
||||
if (!src) continue
|
||||
if (!src || hasExclude) continue
|
||||
assets.push({
|
||||
kind: 'script',
|
||||
src,
|
||||
@@ -388,8 +397,10 @@
|
||||
}
|
||||
|
||||
const href = resolveAssetUrl(asset.href)
|
||||
// 이미 로드된 스타일시트 확인
|
||||
const existingLink = document.querySelector(`link[href="${href}"]`)
|
||||
// 이미 로드된 스타일시트 확인 (selector injection 방지)
|
||||
const existingLink = Array.from(document.querySelectorAll('link')).find(
|
||||
el => el.getAttribute('href') === href
|
||||
)
|
||||
if (existingLink) {
|
||||
resolve()
|
||||
return
|
||||
@@ -417,8 +428,10 @@
|
||||
}
|
||||
|
||||
const src = resolveAssetUrl(asset.src)
|
||||
// 이미 로드된 스크립트 확인
|
||||
const existingScript = document.querySelector(`script[src="${src}"]`)
|
||||
// 이미 로드된 스크립트 확인 (selector injection 방지)
|
||||
const existingScript = Array.from(document.querySelectorAll('script')).find(
|
||||
el => el.getAttribute('src') === src
|
||||
)
|
||||
if (existingScript) {
|
||||
resolve()
|
||||
return
|
||||
@@ -450,30 +463,27 @@
|
||||
// inline <style> 주입 (작성 순서 보존 + 중복 방지)
|
||||
const loadInlineStyle = (asset: Extract<OrderedAsset, { kind: 'style' }>): void => {
|
||||
if (!import.meta.client) return
|
||||
const mountEl = getAssetMountEl()
|
||||
const cssText = asset.cssText || ''
|
||||
const key = hashString(cssText)
|
||||
|
||||
const existing = document.head.querySelector(`style[data-custom-style-hash="${key}"]`)
|
||||
// 동일한 위치에서 체크하고 삽입
|
||||
const existing = mountEl.querySelector(`style[data-custom-style-hash="${key}"]`)
|
||||
if (existing) return
|
||||
|
||||
const styleEl = document.createElement('style')
|
||||
styleEl.setAttribute('data-custom-style-hash', key)
|
||||
styleEl.textContent = cssText
|
||||
getAssetMountEl().appendChild(styleEl)
|
||||
mountEl.appendChild(styleEl)
|
||||
}
|
||||
|
||||
// 스크립트 로드 완료 여부 추적
|
||||
const scriptsLoaded = ref(false)
|
||||
|
||||
const modalStore = useModalStore()
|
||||
|
||||
// 로그 전송 함수 (fnCustomLog로 노출)
|
||||
// - 호출 방식: fnCustomLog('로그값') 또는 fnCustomLog(element)
|
||||
// - 호출 방식: fnCustomLog('clickSarea', 'clickItem')
|
||||
const handleCustomLog = (clickSarea: string, clickItem: string | null) => {
|
||||
if (!import.meta.client) return
|
||||
if (clickItem == null) return
|
||||
|
||||
|
||||
// null, undefined, 빈 문자열 모두 체크
|
||||
if (!clickSarea || !clickItem) return
|
||||
|
||||
handleSendLog(clickSarea, clickItem)
|
||||
@@ -513,29 +523,56 @@
|
||||
? cancelText || ''
|
||||
: actionOrEl.getAttribute('data-cancel-text') || ''
|
||||
|
||||
// i18n 변환 적용 (tm('key') 형태 또는 일반 텍스트)
|
||||
const resolvedContent = contentRaw ? resolveI18nText(contentRaw) : ''
|
||||
const resolvedConfirmText = confirmButtonText ? resolveI18nText(confirmButtonText) : ''
|
||||
const resolvedCancelText = cancelButtonText ? resolveI18nText(cancelButtonText) : ''
|
||||
|
||||
// HTML 내부의 {{ tm('key') }} 형태도 변환
|
||||
const contentText = resolvedContent ? applyI18nToApiHtml(resolvedContent) : ''
|
||||
|
||||
// 1) open-confirm
|
||||
if (action === 'open-confirm') {
|
||||
const contentText = contentRaw ? applyI18nToApiHtml(contentRaw) : ''
|
||||
if (contentText) {
|
||||
modalStore.handleOpenConfirm({
|
||||
contentText,
|
||||
...(confirmButtonText ? { confirmButtonText } : {}),
|
||||
...(cancelButtonText ? { cancelButtonText } : {}),
|
||||
...(resolvedConfirmText ? { confirmButtonText: resolvedConfirmText } : {}),
|
||||
...(resolvedCancelText ? { cancelButtonText: resolvedCancelText } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
if (action === 'open-alert') {
|
||||
const contentText = contentRaw ? applyI18nToApiHtml(contentRaw) : ''
|
||||
if (contentText) {
|
||||
modalStore.handleOpenAlert({
|
||||
contentText,
|
||||
...(confirmButtonText ? { confirmButtonText } : {}),
|
||||
...(cancelButtonText ? { cancelButtonText } : {}),
|
||||
...(resolvedConfirmText ? { confirmButtonText: resolvedConfirmText } : {}),
|
||||
...(resolvedCancelText ? { cancelButtonText: resolvedCancelText } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 유튜브 팝업 호출 함수 (fnCustomVideo로 노출)
|
||||
// - 호출 방식: fnCustomVideo("tm('Ci_Video_ID')") 또는 fnCustomVideo('youtube_video_id')
|
||||
const handleCustomVideo = (youtubeUrl: string) => {
|
||||
if (!import.meta.client) return
|
||||
if (!youtubeUrl) return
|
||||
|
||||
// i18n 변환 적용 (tm('key') 형태 또는 일반 텍스트)
|
||||
const resolvedUrl = resolveI18nText(youtubeUrl)
|
||||
if (!resolvedUrl) return
|
||||
|
||||
modalStore.handleOpenYoutube({ youtubeUrl: resolvedUrl })
|
||||
}
|
||||
|
||||
// 전역 함수 등록 헬퍼
|
||||
const registerGlobalFunctions = () => {
|
||||
if (!import.meta.client) return
|
||||
window.fnCustomAction = handleCustomAction
|
||||
window.fnCustomLog = handleCustomLog
|
||||
window.fnCustomVideo = handleCustomVideo
|
||||
}
|
||||
|
||||
// custom_contents 변경 시 link/script를 "작성된 순서"대로 순차 로드
|
||||
watch(
|
||||
() => customContentsRaw.value,
|
||||
@@ -543,13 +580,8 @@
|
||||
if (!import.meta.client) return
|
||||
if (!html) return
|
||||
|
||||
// HTML이 업데이트될 때마다 fnCustomAction, fnCustomLog 재설정 (CSR에서만)
|
||||
if (import.meta.client) {
|
||||
;window.fnCustomAction = handleCustomAction
|
||||
;window.fnCustomLog = handleCustomLog
|
||||
}
|
||||
|
||||
scriptsLoaded.value = false
|
||||
// HTML이 업데이트될 때마다 전역 함수 재설정
|
||||
registerGlobalFunctions()
|
||||
|
||||
let cancelled = false
|
||||
onCleanup(() => {
|
||||
@@ -623,9 +655,7 @@
|
||||
const container = customContainerRef.value
|
||||
if (!container) return
|
||||
|
||||
;window.fnCustomAction = handleCustomAction
|
||||
;window.fnCustomLog = handleCustomLog
|
||||
scriptsLoaded.value = false
|
||||
registerGlobalFunctions()
|
||||
|
||||
// DOM이 실제로 렌더링될 때까지 대기
|
||||
await waitForDomReady(container)
|
||||
@@ -640,13 +670,10 @@
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[CustomContent] Script load error:', err)
|
||||
}
|
||||
scriptsLoaded.value = true
|
||||
;window.fnCustomAction = handleCustomAction
|
||||
;window.fnCustomLog = handleCustomLog
|
||||
// 동적 로드 시점에는 DOMContentLoaded/load가 이미 발생했으므로, 스크립트에서 구독할 커스텀 이벤트 발생
|
||||
if (import.meta.client) {
|
||||
|
||||
// 스크립트 로드 완료 후 전역 함수 재등록 및 이벤트 발생
|
||||
registerGlobalFunctions()
|
||||
window.dispatchEvent(new CustomEvent('customContentReady'))
|
||||
}
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -666,9 +693,8 @@
|
||||
const container = customContainerRef.value
|
||||
if (!container) return
|
||||
|
||||
// fnCustomAction, fnCustomLog 전역 등록
|
||||
window.fnCustomAction = handleCustomAction
|
||||
window.fnCustomLog = handleCustomLog
|
||||
// 전역 함수 등록
|
||||
registerGlobalFunctions()
|
||||
|
||||
// 이벤트 위임 등록
|
||||
container.addEventListener('click', onDelegatedClick)
|
||||
@@ -687,25 +713,20 @@
|
||||
if (w.fnCustomLog === handleCustomLog) {
|
||||
delete w.fnCustomLog
|
||||
}
|
||||
if (w.fnCustomVideo === handleCustomVideo) {
|
||||
delete w.fnCustomVideo
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="section-standard">
|
||||
<div
|
||||
v-if="customContentsHtml"
|
||||
ref="customContainerRef"
|
||||
v-dompurify-admin="customContentsHtml"
|
||||
class="custom-container"
|
||||
></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
<style>
|
||||
.custom-container {
|
||||
min-height: 20rem;
|
||||
background-color: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface PageDataValue {
|
||||
is_login_required: number
|
||||
meta_tag_type: number
|
||||
use_top_btn: boolean
|
||||
top_btn_color_json?: ColorObject
|
||||
use_sns_btn: boolean
|
||||
use_lnb: boolean
|
||||
lnb_text_color_code_active?: string
|
||||
|
||||
@@ -9,6 +9,13 @@ export const isExternalUrl = (url: string): boolean => {
|
||||
return url.startsWith('http://') || url.startsWith('https://')
|
||||
}
|
||||
|
||||
/**
|
||||
* 경로에 쿼리스트링을 추가하는 함수
|
||||
*/
|
||||
export const addQueryString = (path: string, queryString: string): string => {
|
||||
return queryString ? `${path}?${queryString}` : path
|
||||
}
|
||||
|
||||
/**
|
||||
* 게임 도메인을 추출하는 함수
|
||||
* 서버와 클라이언트 환경에서 모두 동작
|
||||
@@ -95,55 +102,83 @@ export const getPathAfterLanguage = (url: string): string => {
|
||||
}
|
||||
|
||||
/**
|
||||
* intro page_url을 기반으로 리다이렉트 경로를 생성하는 공통 함수
|
||||
* @param introPageUrl - intro.page_url 값
|
||||
* @param langCode - 현재 언어 코드
|
||||
* @param currentUrl - 현재 접근한 URL (쿼리스트링 포함, 옵션)
|
||||
* @returns 최종 리다이렉트 경로 (없으면 기본값 /home)
|
||||
* URL을 파싱하여 최종 경로를 반환하는 함수
|
||||
* 경로가 '/' 또는 ''이고 인트로 URL이 있으면 인트로로 리다이렉트,
|
||||
* 그렇지 않으면 언어 코드를 추가한 URL을 반환
|
||||
* SSR과 CSR 모두에서 작동
|
||||
* @param url - URL 문자열
|
||||
* @param finalLocale - 최종 언어 코드
|
||||
* @param langCodes - 지원하는 언어 코드 배열
|
||||
* @param ISO_LANGUAGE_CODES - ISO 언어 코드 배열
|
||||
* @param introPageUrl - 인트로 페이지 URL (선택)
|
||||
* @returns 최종 URL 문자열
|
||||
*/
|
||||
export const getIntroRedirectPath = (
|
||||
introPageUrl: string | undefined | null,
|
||||
langCode: string,
|
||||
currentUrl?: string
|
||||
export const parseUrl = (
|
||||
url: string,
|
||||
finalLocale: string,
|
||||
langCodes: string[],
|
||||
ISO_LANGUAGE_CODES: string[],
|
||||
introPageUrl?: string
|
||||
): string => {
|
||||
// 기본값: /langCode/home
|
||||
let defaultPath = `/${langCode}/home`
|
||||
const [pathPart, queryString = ''] = url.split('?')
|
||||
const pathSegments = pathPart.split('/').filter(Boolean)
|
||||
const currentLocale = pathSegments[0]
|
||||
const isKnownLocale =
|
||||
langCodes.includes(currentLocale) ||
|
||||
ISO_LANGUAGE_CODES.includes(currentLocale)
|
||||
const isEmptyPath =
|
||||
pathSegments.length === 0 || (pathSegments.length === 1 && isKnownLocale)
|
||||
|
||||
if (introPageUrl && introPageUrl.trim() !== '') {
|
||||
// 경로가 '/' 또는 ''인 경우 인트로 URL 처리
|
||||
if (isEmptyPath) {
|
||||
const hasIntroUrl = introPageUrl?.trim()
|
||||
if (hasIntroUrl) {
|
||||
// 외부 URL인 경우 그대로 반환
|
||||
if (isExternalUrl(introPageUrl)) {
|
||||
// 외부 URL인 경우 그대로 사용
|
||||
defaultPath = introPageUrl
|
||||
} else {
|
||||
// 내부 경로인 경우 언어 코드 패턴 확인
|
||||
const normalizedIntroUrl = introPageUrl.split('?')[0] // 쿼리스트링 제외
|
||||
const languagePattern = /^\/[a-z]{2}(-[a-z]{2})?(\/|$)/
|
||||
const hasLanguageCode = languagePattern.test(normalizedIntroUrl)
|
||||
return introPageUrl
|
||||
}
|
||||
// 내부 경로인 경우 언어 코드 추가
|
||||
// 인트로 URL이 이미 언어 코드로 시작하는지 확인
|
||||
const introPathSegments = introPageUrl.split('/').filter(Boolean)
|
||||
const introFirstSegment = introPathSegments[0]
|
||||
const introHasLocale =
|
||||
introFirstSegment &&
|
||||
(langCodes.includes(introFirstSegment) ||
|
||||
ISO_LANGUAGE_CODES.includes(introFirstSegment))
|
||||
|
||||
if (hasLanguageCode) {
|
||||
// 이미 언어 코드가 있으면 그대로 사용
|
||||
defaultPath = introPageUrl
|
||||
let introPath: string
|
||||
if (introHasLocale) {
|
||||
// 이미 언어 코드가 있으면 언어 코드만 교체
|
||||
introPath = '/' + [finalLocale, ...introPathSegments.slice(1)].join('/')
|
||||
} else {
|
||||
// 언어 코드가 없으면 추가
|
||||
const pathWithSlash = normalizedIntroUrl.startsWith('/')
|
||||
? normalizedIntroUrl
|
||||
: `/${normalizedIntroUrl}`
|
||||
defaultPath = `/${langCode}${pathWithSlash}`
|
||||
|
||||
// 쿼리스트링이 있으면 다시 추가
|
||||
if (introPageUrl.includes('?')) {
|
||||
defaultPath += '?' + introPageUrl.split('?')[1]
|
||||
}
|
||||
introPath = `/${finalLocale}${introPageUrl}`
|
||||
}
|
||||
return addQueryString(introPath, queryString)
|
||||
}
|
||||
// 인트로 URL이 없으면 기본 홈 경로 반환
|
||||
return addQueryString(`/${finalLocale}/home`, queryString)
|
||||
}
|
||||
|
||||
// intro에 쿼리스트링이 없고, 현재 URL에 쿼리스트링이 있으면 추가
|
||||
if (!defaultPath.includes('?') && currentUrl && currentUrl.includes('?')) {
|
||||
const queryString = currentUrl.split('?')[1]
|
||||
if (queryString) {
|
||||
defaultPath += '?' + queryString
|
||||
// 리다이렉트 경로 생성
|
||||
const remainingPath = pathSegments.slice(1)
|
||||
const hasRemainingPath = remainingPath.length > 0
|
||||
let newPath: string
|
||||
|
||||
if (isKnownLocale) {
|
||||
// 현재 언어 코드와 최종 언어가 같으면 리다이렉트 불필요
|
||||
if (currentLocale === finalLocale) return url
|
||||
|
||||
// 유효한 언어 코드가 있지만 다른 언어인 경우
|
||||
if (hasRemainingPath) {
|
||||
// 경로가 있으면 언어 코드만 교체 (/vi/story -> /finalLocale/story)
|
||||
newPath = '/' + [finalLocale, ...remainingPath].join('/')
|
||||
}
|
||||
} else {
|
||||
// 언어 코드가 없거나 유효하지 않은 경우: 언어 코드를 앞에 추가
|
||||
const pathWithoutSlash = pathPart === '/' ? '' : pathPart
|
||||
newPath = `/${finalLocale}${pathWithoutSlash}`
|
||||
}
|
||||
|
||||
return defaultPath
|
||||
return addQueryString(newPath, queryString)
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ export default defineNuxtConfig({
|
||||
},
|
||||
experimental: {
|
||||
payloadExtraction: false,
|
||||
entryImportMap: false,
|
||||
},
|
||||
nitro: {
|
||||
prerender: { routes: [] },
|
||||
|
||||
Reference in New Issue
Block a user