feat: CtLayout01 커스텀 콘텐츠 기능 개선
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -14,4 +14,5 @@ logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
CLAUDE.md
|
||||
|
||||
@@ -147,6 +147,7 @@ const sendSA = (
|
||||
view_info: {
|
||||
game_no: gameNo,
|
||||
lang_cd: eventLocale,
|
||||
service_cd: 'official_home',
|
||||
...options?.viewInfo,
|
||||
},
|
||||
}
|
||||
@@ -158,6 +159,7 @@ const sendSA = (
|
||||
pwt_click_item: clickItem,
|
||||
game_no: gameNo,
|
||||
lang_cd: eventLocale,
|
||||
service_cd: 'official_home',
|
||||
...options?.clickItem,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import FxDownload01 from '#layers/templates/FxDownload01/index.vue'
|
||||
import FxCoupon01 from '#layers/templates/FxCoupon01/index.vue'
|
||||
import FxSecure01 from '#layers/templates/FxSecure01/index.vue'
|
||||
import FxPreregist01 from '#layers/templates/FxPreregist01/index.vue'
|
||||
import CtLayout01 from '#layers/templates/CtLayout01/index.vue'
|
||||
|
||||
const templateRegistry = {
|
||||
GR_VISUAL_01: { component: GrVisual01 },
|
||||
@@ -32,6 +33,7 @@ const templateRegistry = {
|
||||
FX_COUPON_01: { component: FxCoupon01 },
|
||||
FX_SECURE_01: { component: FxSecure01 },
|
||||
FX_PREREGIST_01: { component: FxPreregist01 },
|
||||
CT_LAYOUT_01: { component: CtLayout01 },
|
||||
} as const
|
||||
|
||||
type TemplateKey = keyof typeof templateRegistry
|
||||
|
||||
@@ -20,7 +20,7 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
|
||||
const pageDataStore = usePageDataStore()
|
||||
const loadingStore = useLoadingStore()
|
||||
|
||||
const { langCodes } = storeToRefs(gameDataStore)
|
||||
const { langCodes, defaultLangCode } = storeToRefs(gameDataStore)
|
||||
|
||||
const stoveApiBaseUrl = runtimeConfig.public.stoveApiUrl
|
||||
const accessToken = csrGetAccessToken()
|
||||
|
||||
@@ -1,55 +1,165 @@
|
||||
import VueDOMPurifyHTML from 'vue-dompurify-html'
|
||||
// @ts-ignore
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
import type { DirectiveBinding } from 'vue'
|
||||
|
||||
// DOMPurify 설정 타입
|
||||
interface DOMPurifyConfig {
|
||||
ALLOWED_TAGS: string[]
|
||||
ALLOWED_ATTR: string[]
|
||||
}
|
||||
|
||||
// 공통 허용 태그
|
||||
const commonTags = [
|
||||
'br',
|
||||
'div',
|
||||
'b',
|
||||
'strong',
|
||||
'i',
|
||||
'em',
|
||||
'u',
|
||||
'a',
|
||||
'p',
|
||||
'ul',
|
||||
'li',
|
||||
's',
|
||||
'ol',
|
||||
'button',
|
||||
'span',
|
||||
'img',
|
||||
'pre',
|
||||
'input',
|
||||
'dl',
|
||||
'dt',
|
||||
'dd',
|
||||
'table',
|
||||
'thead',
|
||||
'tbody',
|
||||
'tfoot',
|
||||
'video',
|
||||
'source',
|
||||
'tr',
|
||||
'th',
|
||||
'td',
|
||||
'section',
|
||||
] as const
|
||||
|
||||
// 공통 허용 속성
|
||||
const commonAttrs = [
|
||||
'style',
|
||||
'id',
|
||||
'href',
|
||||
'src',
|
||||
'target',
|
||||
'alt',
|
||||
'class',
|
||||
'width',
|
||||
'height',
|
||||
'frameborder',
|
||||
'colspan',
|
||||
'rowspan',
|
||||
'scope',
|
||||
'type',
|
||||
'autoplay',
|
||||
'muted',
|
||||
'loop',
|
||||
'playsinline',
|
||||
'controls',
|
||||
'preload',
|
||||
'poster',
|
||||
] as const
|
||||
|
||||
// 기본 설정 (일반 사용자용)
|
||||
const defaultConfig: DOMPurifyConfig = {
|
||||
ALLOWED_TAGS: [...commonTags],
|
||||
ALLOWED_ATTR: [...commonAttrs],
|
||||
}
|
||||
|
||||
// 관리자용 설정 (확장된 태그 및 속성)
|
||||
const adminConfig: DOMPurifyConfig = {
|
||||
ALLOWED_TAGS: [
|
||||
...commonTags,
|
||||
'iframe',
|
||||
'link',
|
||||
'style',
|
||||
'script',
|
||||
'blockquote',
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
...commonAttrs,
|
||||
'onclick',
|
||||
'rel',
|
||||
'media',
|
||||
'integrity',
|
||||
'crossorigin',
|
||||
'referrerpolicy',
|
||||
'allowfullscreen',
|
||||
'exclude-custom',
|
||||
'exclude',
|
||||
'defer',
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 문자열을 안전하게 정화하는 헬퍼 함수
|
||||
* @param value - 정화할 HTML 문자열
|
||||
* @param config - DOMPurify 설정
|
||||
* @returns 정화된 HTML 문자열
|
||||
*/
|
||||
function sanitizeHtml(value: unknown, config: DOMPurifyConfig): string {
|
||||
// SSR 환경에서는 빈 문자열 반환
|
||||
if (!import.meta.client) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 값이 없거나 문자열이 아닌 경우 빈 문자열 반환
|
||||
if (!value || typeof value !== 'string') {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
return DOMPurify.sanitize(value, config)
|
||||
} catch (error) {
|
||||
// 에러 발생 시 원본 값 반환하지 않고 빈 문자열 반환 (보안상 안전)
|
||||
if (import.meta.dev) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[DOMPurify] Sanitization error:', error)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 디렉티브 핸들러 생성 함수
|
||||
* @param config - DOMPurify 설정
|
||||
* @returns 디렉티브 핸들러 객체
|
||||
*/
|
||||
function createDirectiveHandler(config: DOMPurifyConfig) {
|
||||
return {
|
||||
mounted(el: HTMLElement, binding: DirectiveBinding) {
|
||||
el.innerHTML = sanitizeHtml(binding.value, config)
|
||||
},
|
||||
updated(el: HTMLElement, binding: DirectiveBinding) {
|
||||
el.innerHTML = sanitizeHtml(binding.value, config)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default defineNuxtPlugin(nuxtApp => {
|
||||
nuxtApp.vueApp.use(VueDOMPurifyHTML, {
|
||||
default: {
|
||||
ALLOWED_TAGS: [
|
||||
'br',
|
||||
'div',
|
||||
'b',
|
||||
'strong',
|
||||
'i',
|
||||
'em',
|
||||
'u',
|
||||
's',
|
||||
'a',
|
||||
'p',
|
||||
'ol',
|
||||
'ul',
|
||||
'li',
|
||||
'span',
|
||||
'img',
|
||||
'pre',
|
||||
'iframe',
|
||||
'input',
|
||||
'dl',
|
||||
'dt',
|
||||
'dd',
|
||||
'blockquote',
|
||||
'table',
|
||||
'thead',
|
||||
'tbody',
|
||||
'tfoot',
|
||||
'tr',
|
||||
'th',
|
||||
'td',
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
'style',
|
||||
'id',
|
||||
'href',
|
||||
'src',
|
||||
'target',
|
||||
'alt',
|
||||
'class',
|
||||
'width',
|
||||
'height',
|
||||
'frameborder',
|
||||
'allowfullscreen',
|
||||
'colspan',
|
||||
'rowspan',
|
||||
'scope',
|
||||
],
|
||||
},
|
||||
})
|
||||
// SSR 환경에서는 디렉티브 등록하지 않음
|
||||
// if (!import.meta.client) {
|
||||
// return
|
||||
// }
|
||||
|
||||
// 기본 디렉티브: v-dompurify-html
|
||||
nuxtApp.vueApp.directive(
|
||||
'dompurify-html',
|
||||
createDirectiveHandler(defaultConfig)
|
||||
)
|
||||
|
||||
// 관리자용 디렉티브: v-dompurify-admin
|
||||
nuxtApp.vueApp.directive(
|
||||
'dompurify-admin',
|
||||
createDirectiveHandler(adminConfig)
|
||||
)
|
||||
})
|
||||
|
||||
732
layers/templates/CtLayout01/index.vue
Normal file
732
layers/templates/CtLayout01/index.vue
Normal file
@@ -0,0 +1,732 @@
|
||||
<script setup lang="ts">
|
||||
import type { PageDataTemplateComponents } from '#layers/types/api/pageData'
|
||||
|
||||
// Window 타입 확장
|
||||
declare global {
|
||||
interface Window {
|
||||
fnCustomAction: (
|
||||
actionOrEl: string | HTMLElement | null,
|
||||
rawContent?: string,
|
||||
confirmText?: string,
|
||||
cancelText?: string,
|
||||
) => void
|
||||
fnCustomLog: (clickSarea: string, clickItem: string) => void
|
||||
fnCustomVideo: (youtubeUrl: string) => void
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
components: PageDataTemplateComponents
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { sendLog } = useAnalytics()
|
||||
const { tm, locale } = useI18n()
|
||||
const customContainerRef = ref<HTMLElement | null>(null)
|
||||
const nuxtApp = useNuxtApp()
|
||||
// Configuration
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const staticUrl = runtimeConfig.public.staticUrl
|
||||
|
||||
// CSR에서만 fnCustomAction, fnCustomLog, fnCustomVideo 즉시 설정 (HTML 렌더링 전에 필요)
|
||||
// handleCustomAction, handleCustomLog, handleCustomVideo가 정의되기 전이므로 나중에 업데이트됨
|
||||
if (import.meta.client) {
|
||||
// onMounted에서 실제 함수로 교체됨
|
||||
;window.fnCustomAction = () => {
|
||||
// handleCustomAction이 아직 정의되지 않았을 수 있으므로 무시
|
||||
}
|
||||
;window.fnCustomLog = () => {
|
||||
// handleCustomLog가 아직 정의되지 않았을 수 있으므로 무시
|
||||
}
|
||||
;window.fnCustomVideo = () => {
|
||||
// handleCustomVideo가 아직 정의되지 않았을 수 있으므로 무시
|
||||
}
|
||||
}
|
||||
|
||||
// handleSendLog 함수 정의
|
||||
const handleSendLog = (clickSarea: string, clickItem: string) => {
|
||||
const analytics = {
|
||||
action_type: 'click',
|
||||
click_sarea: clickSarea,
|
||||
click_item: clickItem,
|
||||
}
|
||||
sendLog(locale.value, analytics)
|
||||
}
|
||||
|
||||
const coerceToString = (value: unknown): string => {
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
||||
return ''
|
||||
}
|
||||
|
||||
// URL인지 확인하는 함수
|
||||
const isUrl = (str: string): boolean => {
|
||||
try {
|
||||
const url = new URL(str)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 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 => {
|
||||
// locale 변경 시 computed가 재평가되도록 의존성 연결
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
locale.value
|
||||
|
||||
return html.replace(
|
||||
/\{\{\s*tm\(\s*(["'])([^"']+)\1\s*\)\s*\}\}/g,
|
||||
(_full, _q, key: string) => coerceToString(tm(key)),
|
||||
)
|
||||
}
|
||||
|
||||
// fetch한 HTML을 저장할 ref
|
||||
const fetchedHtml = ref<string>('')
|
||||
// script/link 상대 경로 해석용 베이스 URL (staticUrl + cleanedResPath)
|
||||
const assetBaseUrl = ref<string>('')
|
||||
|
||||
// URL에서 HTML 가져오기
|
||||
const fetchHtmlFromUrl = async (url: string): Promise<string> => {
|
||||
try {
|
||||
const response = await $fetch<string>(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'text/html',
|
||||
},
|
||||
})
|
||||
return typeof response === 'string' ? response : JSON.stringify(response)
|
||||
} catch (error: any) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Exception] Failed to fetch custom HTML:', error)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// HTML 주석에서 customTranslang JSON URL 추출
|
||||
// 형식: <!-- customTranslang: https://example.com/translations.json -->
|
||||
const extractCustomTranslangUrl = (html: string): string | null => {
|
||||
const commentRegex = /<!--\s*customTranslang\s*:\s*([^\s]+)\s*-->/i
|
||||
const match = html.match(commentRegex)
|
||||
return match && match[1] ? match[1].trim() : null
|
||||
}
|
||||
|
||||
// JSON 파일을 fetch해서 i18n에 추가
|
||||
const loadCustomTranslations = async (jsonUrl: string): Promise<void> => {
|
||||
try {
|
||||
const $i18n = nuxtApp.$i18n as any
|
||||
if (!$i18n) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[CustomTranslations] i18n instance not found')
|
||||
return
|
||||
}
|
||||
|
||||
const translations = await $fetch<Record<string, any>>(jsonUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!translations || typeof translations !== 'object') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[CustomTranslations] Invalid translation data:', translations)
|
||||
return
|
||||
}
|
||||
|
||||
// 현재 locale에 해당하는 번역 데이터 찾기
|
||||
const currentLocale = locale.value
|
||||
|
||||
// 대소문자 구분 없이 로케일 데이터 찾기
|
||||
const localeData =
|
||||
translations[currentLocale] ||
|
||||
translations[currentLocale.toLowerCase()]
|
||||
|
||||
if (localeData && typeof localeData === 'object') {
|
||||
// 기존 메시지에 병합 (기존 키는 유지하고 새로운 키만 추가)
|
||||
const existingMessages = $i18n.getLocaleMessage(currentLocale) || {}
|
||||
$i18n.setLocaleMessage(currentLocale, {
|
||||
...existingMessages,
|
||||
...localeData,
|
||||
})
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[CustomTranslations] Loaded translations for locale:', currentLocale)
|
||||
}
|
||||
} catch (error: any) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[Exception] Failed to load custom translations:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// custom_contents가 URL인지 확인하고 HTML 가져오기
|
||||
watch(
|
||||
() => {
|
||||
// 타입 가드: 단일 컴포넌트 패턴인지 확인
|
||||
if ('group_sets' in props.components) {
|
||||
return null
|
||||
}
|
||||
const customContents = props.components.customContents
|
||||
const firstGroup = customContents?.groups?.[0]
|
||||
if (!firstGroup) {
|
||||
return null
|
||||
}
|
||||
// res_path는 PageDataResourceGroup의 직접 속성
|
||||
// 또는 btn_info.detail.res_path 형태일 수 있음
|
||||
const resPath = (firstGroup as any).detail?.res_path
|
||||
if (!resPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 문자열로 변환
|
||||
const resPathStr = String(resPath)
|
||||
// 마지막에 "/contents" 또는 "contents"가 있으면 제거
|
||||
const cleanedResPath = resPathStr.replace(/\/?contents\/?$/, '')
|
||||
return cleanedResPath
|
||||
},
|
||||
async (cleanedResPath) => {
|
||||
if (!cleanedResPath) {
|
||||
fetchedHtml.value = ''
|
||||
assetBaseUrl.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const contentStr = typeof cleanedResPath === 'string' ? `${staticUrl}${cleanedResPath}/index.html` : String(`${staticUrl}${cleanedResPath}/index.html`)
|
||||
const base = String(cleanedResPath).replace(/\/?$/, '')
|
||||
assetBaseUrl.value = `${staticUrl}${base}/`
|
||||
|
||||
let html = ''
|
||||
if (isUrl(contentStr)) {
|
||||
// URL인 경우 fetch
|
||||
html = await fetchHtmlFromUrl(contentStr)
|
||||
} else {
|
||||
// HTML 문자열인 경우 그대로 사용
|
||||
html = contentStr
|
||||
}
|
||||
|
||||
fetchedHtml.value = html
|
||||
|
||||
// HTML 주석에서 customTranslang JSON URL 추출 및 로드
|
||||
if (html) {
|
||||
const jsonUrl = extractCustomTranslangUrl(html)
|
||||
if (jsonUrl) {
|
||||
await loadCustomTranslations(jsonUrl)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// locale 변경 시에도 custom translations 다시 로드
|
||||
watch(
|
||||
() => locale.value,
|
||||
async () => {
|
||||
if (fetchedHtml.value) {
|
||||
const jsonUrl = extractCustomTranslangUrl(fetchedHtml.value)
|
||||
if (jsonUrl) {
|
||||
await loadCustomTranslations(jsonUrl)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// API HTML 원본 (i18n만 치환) - asset(link/style/script) 추출용
|
||||
const customContentsRaw = computed(() => {
|
||||
if (!fetchedHtml.value) return ''
|
||||
|
||||
let html = fetchedHtml.value
|
||||
|
||||
// i18n 치환 ({{ tm('...') }})
|
||||
html = applyI18nToApiHtml(html)
|
||||
return html
|
||||
})
|
||||
|
||||
// src 속성에 http/https가 없으면 assetBaseUrl 붙이기
|
||||
const resolveHtmlSrcUrls = (html: string): string => {
|
||||
const base = assetBaseUrl.value || ''
|
||||
if (!base) return html
|
||||
|
||||
return html.replace(
|
||||
/(<[^>]+\s)src\s*=\s*(["'])([^"']+)\2/gi,
|
||||
(_match, prefix: string, quote: string, srcValue: string) => {
|
||||
if (/^https?:\/\//i.test(srcValue)) {
|
||||
return `${prefix}src=${quote}${srcValue}${quote}`
|
||||
}
|
||||
const resolvedUrl = base + srcValue.replace(/^\//, '')
|
||||
return `${prefix}src=${quote}${resolvedUrl}${quote}`
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 렌더링용 HTML
|
||||
// - link/script는 head/body로 별도 주입 (렌더 위치에 남기지 않음)
|
||||
// - style은 "그 자리에 그대로" 렌더링하되, 적용 안정성을 위해 head에도 복제 주입함
|
||||
const customContentsHtml = computed(() => {
|
||||
const html = customContentsRaw.value
|
||||
if (!html) return ''
|
||||
|
||||
let result = html
|
||||
// link 태그 제거
|
||||
.replace(/<link\b[^>]*>/gi, '')
|
||||
// script 블록 제거
|
||||
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
|
||||
result = resolveHtmlSrcUrls(result)
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
type OrderedAsset =
|
||||
| { kind: 'link'; href: string; rel?: string | null; media?: string | null; integrity?: string | null; crossOrigin?: string | null; referrerPolicy?: string | null }
|
||||
| { kind: 'script'; src: string; type?: string | null; integrity?: string | null; crossOrigin?: string | null; referrerPolicy?: string | null }
|
||||
| { kind: 'style'; cssText: string }
|
||||
|
||||
const parseTagAttributes = (tagHtml: string): Record<string, string> => {
|
||||
const attrs: Record<string, string> = {}
|
||||
const attrRegex = /([^\s=]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = attrRegex.exec(tagHtml)) !== null) {
|
||||
const key = m[1]?.toLowerCase()
|
||||
const value = (m[2] ?? m[3] ?? m[4] ?? '').trim()
|
||||
if (key) attrs[key] = value
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
// asset(link/script) 주입 위치: main 우선, 없으면 body fallback
|
||||
const getAssetMountEl = (): HTMLElement => {
|
||||
if (!import.meta.client) {
|
||||
// SSR에서는 호출되지 않지만 타입 안정성을 위해
|
||||
return {} as HTMLElement
|
||||
}
|
||||
return (document.querySelector('main') as HTMLElement | null) ?? document.body
|
||||
}
|
||||
|
||||
// http/https가 없으면 assetBaseUrl(staticUrl + cleanedResPath)을 붙여 절대 경로로 변환
|
||||
const resolveAssetUrl = (path: string): string => {
|
||||
if (/^https?:\/\//i.test(path)) return path
|
||||
const base = assetBaseUrl.value || ''
|
||||
if (!base) return path
|
||||
return base + path.replace(/^\//, '')
|
||||
}
|
||||
|
||||
// custom_contents 원본 문자열에서 link/script를 "작성된 순서" 그대로 추출 (파서가 위치를 바꾸는 문제 방지)
|
||||
const extractOrderedAssets = (html: string): OrderedAsset[] => {
|
||||
const assets: OrderedAsset[] = []
|
||||
|
||||
// link/script 시작 태그 + style 블록 전체를 한 번에 매칭 (작성된 순서 보존)
|
||||
const tagRegex = /<(link|script)\b[^>]*>|<style\b[^>]*>[\s\S]*?<\/style>/gi
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = tagRegex.exec(html)) !== null) {
|
||||
const tagHtml = match[0] || ''
|
||||
const tagName = (match[1] || '').toLowerCase()
|
||||
|
||||
// style 블록
|
||||
if (/^<style\b/i.test(tagHtml)) {
|
||||
const cssText = tagHtml
|
||||
.replace(/^<style\b[^>]*>/i, '')
|
||||
.replace(/<\/style>\s*$/i, '')
|
||||
if (cssText.trim()) {
|
||||
assets.push({ kind: 'style', cssText })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const attrs = parseTagAttributes(tagHtml)
|
||||
|
||||
// exclude 속성이 있으면 건너뛰기 (boolean attribute)
|
||||
const hasExclude = /\bexclude\b/i.test(tagHtml)
|
||||
|
||||
if (tagName === 'link') {
|
||||
const href = attrs.href
|
||||
if (!href || hasExclude) continue
|
||||
assets.push({
|
||||
kind: 'link',
|
||||
href,
|
||||
rel: attrs.rel ?? null,
|
||||
media: attrs.media ?? null,
|
||||
integrity: attrs.integrity ?? null,
|
||||
crossOrigin: attrs.crossorigin ?? null,
|
||||
referrerPolicy: attrs.referrerpolicy ?? null,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (tagName === 'script') {
|
||||
const src = attrs.src
|
||||
if (!src || hasExclude) continue
|
||||
assets.push({
|
||||
kind: 'script',
|
||||
src,
|
||||
type: attrs.type ?? null,
|
||||
integrity: attrs.integrity ?? null,
|
||||
crossOrigin: attrs.crossorigin ?? null,
|
||||
referrerPolicy: attrs.referrerpolicy ?? null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return assets
|
||||
}
|
||||
|
||||
// CSS 파일 동적 로드
|
||||
const loadStylesheet = (asset: Extract<OrderedAsset, { kind: 'link' }>): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!import.meta.client) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const href = resolveAssetUrl(asset.href)
|
||||
// 이미 로드된 스타일시트 확인 (selector injection 방지)
|
||||
const existingLink = Array.from(document.querySelectorAll('link')).find(
|
||||
el => el.getAttribute('href') === href
|
||||
)
|
||||
if (existingLink) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const link = document.createElement('link')
|
||||
link.rel = asset.rel || 'stylesheet'
|
||||
link.href = href
|
||||
if (asset.media) link.media = asset.media
|
||||
if (asset.integrity) link.integrity = asset.integrity
|
||||
if (asset.crossOrigin) link.crossOrigin = asset.crossOrigin
|
||||
if (asset.referrerPolicy) link.referrerPolicy = asset.referrerPolicy
|
||||
link.onload = () => resolve()
|
||||
link.onerror = () => reject(new Error(`Failed to load stylesheet: ${href}`))
|
||||
getAssetMountEl().appendChild(link)
|
||||
})
|
||||
}
|
||||
|
||||
// JS 파일 동적 로드
|
||||
const loadScript = (asset: Extract<OrderedAsset, { kind: 'script' }>): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!import.meta.client) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const src = resolveAssetUrl(asset.src)
|
||||
// 이미 로드된 스크립트 확인 (selector injection 방지)
|
||||
const existingScript = Array.from(document.querySelectorAll('script')).find(
|
||||
el => el.getAttribute('src') === src
|
||||
)
|
||||
if (existingScript) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = src
|
||||
script.defer = true
|
||||
if (asset.type) script.type = asset.type
|
||||
if (asset.integrity) script.integrity = asset.integrity
|
||||
if (asset.crossOrigin) script.crossOrigin = asset.crossOrigin
|
||||
if (asset.referrerPolicy) script.referrerPolicy = asset.referrerPolicy
|
||||
script.onload = () => resolve()
|
||||
script.onerror = () => reject(new Error(`Failed to load script: ${src}`))
|
||||
getAssetMountEl().appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
// 문자열 해시(간단) - style 중복 주입 방지용
|
||||
const hashString = (str: string): string => {
|
||||
let hash = 5381
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash * 33) ^ str.charCodeAt(i)
|
||||
}
|
||||
// unsigned 32-bit
|
||||
return (hash >>> 0).toString(16)
|
||||
}
|
||||
|
||||
// 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 = 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
|
||||
mountEl.appendChild(styleEl)
|
||||
}
|
||||
|
||||
const modalStore = useModalStore()
|
||||
|
||||
// 로그 전송 함수 (fnCustomLog로 노출)
|
||||
// - 호출 방식: fnCustomLog('clickSarea', 'clickItem')
|
||||
const handleCustomLog = (clickSarea: string, clickItem: string | null) => {
|
||||
if (!import.meta.client) return
|
||||
// null, undefined, 빈 문자열 모두 체크
|
||||
if (!clickSarea || !clickItem) return
|
||||
|
||||
handleSendLog(clickSarea, clickItem)
|
||||
}
|
||||
|
||||
// API HTML에서 onclick으로 호출할 수 있도록 window에 노출할 "단일 함수"
|
||||
// - 인자 방식: onclick="fnCustomAction('open-confirm', '내용', '확인', '취소')"
|
||||
// - dataset 방식도 호환: onclick="fnCustomAction(this)"
|
||||
const handleCustomAction = (
|
||||
actionOrEl: string | HTMLElement | null,
|
||||
rawContent?: string,
|
||||
confirmText?: string,
|
||||
cancelText?: string,
|
||||
) => {
|
||||
if (!import.meta.client) return
|
||||
if (!actionOrEl) return
|
||||
|
||||
const action =
|
||||
typeof actionOrEl === 'string'
|
||||
? actionOrEl
|
||||
: actionOrEl.getAttribute('data-action') || ''
|
||||
|
||||
const contentRaw =
|
||||
typeof actionOrEl === 'string'
|
||||
? rawContent || ''
|
||||
: actionOrEl.getAttribute('data-content') ||
|
||||
actionOrEl.getAttribute('data-confirm-content') ||
|
||||
''
|
||||
|
||||
const confirmButtonText =
|
||||
typeof actionOrEl === 'string'
|
||||
? confirmText || ''
|
||||
: actionOrEl.getAttribute('data-confirm-text') || ''
|
||||
|
||||
const cancelButtonText =
|
||||
typeof actionOrEl === 'string'
|
||||
? 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') {
|
||||
if (contentText) {
|
||||
modalStore.handleOpenConfirm({
|
||||
contentText,
|
||||
...(resolvedConfirmText ? { confirmButtonText: resolvedConfirmText } : {}),
|
||||
...(resolvedCancelText ? { cancelButtonText: resolvedCancelText } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
if (action === 'open-alert') {
|
||||
if (contentText) {
|
||||
modalStore.handleOpenAlert({
|
||||
contentText,
|
||||
...(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,
|
||||
async (html, _prev, onCleanup) => {
|
||||
if (!import.meta.client) return
|
||||
if (!html) return
|
||||
|
||||
// HTML이 업데이트될 때마다 전역 함수 재설정
|
||||
registerGlobalFunctions()
|
||||
|
||||
let cancelled = false
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
})
|
||||
|
||||
const assets = extractOrderedAssets(html)
|
||||
for (const asset of assets) {
|
||||
if (cancelled) return
|
||||
if (asset.kind === 'script') continue
|
||||
try {
|
||||
if (asset.kind === 'link') {
|
||||
await loadStylesheet(asset)
|
||||
} else if (asset.kind === 'style') {
|
||||
loadInlineStyle(asset)
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`${asset.kind} load error:`, err)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// DOM이 실제로 렌더링되었는지 확인하는 함수
|
||||
const waitForDomReady = (container: HTMLElement): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
// v-dompurify-admin이 DOM을 업데이트할 때까지 대기
|
||||
const observer = new MutationObserver((_mutations, obs) => {
|
||||
// 컨테이너에 자식 요소가 있으면 DOM 준비 완료
|
||||
if (container.children.length > 0) {
|
||||
obs.disconnect()
|
||||
// 추가로 한 프레임 대기하여 렌더링 완료 보장
|
||||
requestAnimationFrame(() => {
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 이미 자식이 있으면 바로 resolve
|
||||
if (container.children.length > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
resolve()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
observer.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
|
||||
// 타임아웃 fallback (3초)
|
||||
setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve()
|
||||
}, 3000)
|
||||
})
|
||||
}
|
||||
|
||||
// customContentsHtml 주입이 끝난 뒤에만 script 동적 로드
|
||||
watch(
|
||||
() => [customContentsHtml.value, customContainerRef.value],
|
||||
() => {
|
||||
if (!import.meta.client || !customContainerRef.value || !customContentsHtml.value) return
|
||||
nextTick(async () => {
|
||||
const html = customContentsRaw.value
|
||||
if (!html) return
|
||||
|
||||
const container = customContainerRef.value
|
||||
if (!container) return
|
||||
|
||||
registerGlobalFunctions()
|
||||
|
||||
// DOM이 실제로 렌더링될 때까지 대기
|
||||
await waitForDomReady(container)
|
||||
|
||||
const assets = extractOrderedAssets(html)
|
||||
const scriptAssets = assets.filter((a): a is Extract<OrderedAsset, { kind: 'script' }> => a.kind === 'script')
|
||||
try {
|
||||
for (const asset of scriptAssets) {
|
||||
await loadScript(asset)
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[CustomContent] Script load error:', err)
|
||||
}
|
||||
|
||||
// 스크립트 로드 완료 후 전역 함수 재등록 및 이벤트 발생
|
||||
registerGlobalFunctions()
|
||||
window.dispatchEvent(new CustomEvent('customContentReady'))
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 이벤트 위임 핸들러
|
||||
const onDelegatedClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement | null
|
||||
const el =
|
||||
(target?.closest?.('[data-action]') as HTMLElement | null) ?? null
|
||||
handleCustomAction(el)
|
||||
}
|
||||
|
||||
// onMounted에서 초기화 코드 실행 (CSR에서만)
|
||||
if (import.meta.client) {
|
||||
onMounted(() => {
|
||||
const container = customContainerRef.value
|
||||
if (!container) return
|
||||
|
||||
// 전역 함수 등록
|
||||
registerGlobalFunctions()
|
||||
|
||||
// 이벤트 위임 등록
|
||||
container.addEventListener('click', onDelegatedClick)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const container = customContainerRef.value
|
||||
if (container) {
|
||||
container.removeEventListener('click', onDelegatedClick)
|
||||
}
|
||||
|
||||
const w = window as any
|
||||
if (w.fnCustomAction === handleCustomAction) {
|
||||
delete w.fnCustomAction
|
||||
}
|
||||
if (w.fnCustomLog === handleCustomLog) {
|
||||
delete w.fnCustomLog
|
||||
}
|
||||
if (w.fnCustomVideo === handleCustomVideo) {
|
||||
delete w.fnCustomVideo
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="customContentsHtml"
|
||||
ref="customContainerRef"
|
||||
v-dompurify-admin="customContentsHtml"
|
||||
class="custom-container"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ export default defineNuxtConfig({
|
||||
},
|
||||
experimental: {
|
||||
payloadExtraction: false,
|
||||
entryImportMap: false,
|
||||
},
|
||||
nitro: {
|
||||
prerender: { routes: [] },
|
||||
|
||||
Reference in New Issue
Block a user