feat: 커스텀 페이지 customContentReady 함수 추가

This commit is contained in:
“hyeonggkim”
2026-01-29 21:21:33 +09:00
parent 31a8674e82
commit a9c0182f5e
2 changed files with 168 additions and 57 deletions

View File

@@ -1,3 +1,4 @@
// @ts-ignore
import DOMPurify from 'dompurify'
import type { DirectiveBinding } from 'vue'
@@ -35,9 +36,12 @@ const commonTags = [
'thead',
'tbody',
'tfoot',
'video',
'source',
'tr',
'th',
'td',
'section',
] as const
// 공통 허용 속성
@@ -55,16 +59,20 @@ const commonAttrs = [
'colspan',
'rowspan',
'scope',
'type',
'autoplay',
'muted',
'loop',
'playsinline',
'controls',
'preload',
'poster',
] as const
// 기본 설정 (일반 사용자용)
const defaultConfig: DOMPurifyConfig = {
ALLOWED_TAGS: [
...commonTags
],
ALLOWED_ATTR: [
...commonAttrs
],
ALLOWED_TAGS: [...commonTags],
ALLOWED_ATTR: [...commonAttrs],
}
// 관리자용 설정 (확장된 태그 및 속성)
@@ -74,6 +82,7 @@ const adminConfig: DOMPurifyConfig = {
'iframe',
'link',
'style',
'script',
'blockquote',
],
ALLOWED_ATTR: [
@@ -133,16 +142,21 @@ function createDirectiveHandler(config: DOMPurifyConfig) {
}
}
export default defineNuxtPlugin((nuxtApp) => {
export default defineNuxtPlugin(nuxtApp => {
// SSR 환경에서는 디렉티브 등록하지 않음
// if (!import.meta.client) {
// return
// }
// 기본 디렉티브: v-dompurify-html
nuxtApp.vueApp.directive('dompurify-html', createDirectiveHandler(defaultConfig))
nuxtApp.vueApp.directive(
'dompurify-html',
createDirectiveHandler(defaultConfig)
)
// 관리자용 디렉티브: v-dompurify-admin
nuxtApp.vueApp.directive('dompurify-admin', createDirectiveHandler(adminConfig))
nuxtApp.vueApp.directive(
'dompurify-admin',
createDirectiveHandler(adminConfig)
)
})

View File

@@ -1,9 +1,23 @@
<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
}
}
interface Props {
components: PageDataTemplateComponents
pageVerTmplSeq: number
pageVerTmplNameEn: string
}
const props = defineProps<Props>()
@@ -15,21 +29,25 @@
const runtimeConfig = useRuntimeConfig()
const staticUrl = runtimeConfig.public.staticUrl
// CSR에서만 __customAction 즉시 설정 (HTML 렌더링 전에 필요)
// handleCustomAction 정의되기 전이므로 나중에 업데이트됨
// CSR에서만 fnCustomAction, fnCustomLog 즉시 설정 (HTML 렌더링 전에 필요)
// handleCustomAction, handleCustomLog가 정의되기 전이므로 나중에 업데이트됨
if (import.meta.client) {
;(window as any).__customAction = () => {
;window.fnCustomAction = () => {
// handleCustomAction이 아직 정의되지 않았을 수 있으므로 무시
// onMounted에서 실제 함수로 교체됨
}
;window.fnCustomLog = () => {
// handleCustomLog가 아직 정의되지 않았을 수 있으므로 무시
// onMounted에서 실제 함수로 교체됨
}
}
// handleSendLog 함수 정의
const handleSendLog = (item: string) => {
const handleSendLog = (clickSarea: string, clickItem: string) => {
const analytics = {
action_type: 'click',
click_item: item,
click_sarea: 'CUSTOM',
click_sarea: clickSarea,
click_item: clickItem,
}
sendLog(locale.value, analytics)
}
@@ -67,6 +85,8 @@
// 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> => {
@@ -180,11 +200,13 @@
async (cleanedResPath) => {
if (!cleanedResPath) {
fetchedHtml.value = ''
assetBaseUrl.value = ''
return
}
const contentStr = typeof cleanedResPath === 'string' ? `${staticUrl}${cleanedResPath}/index.html` : String(`${staticUrl}${cleanedResPath}/index.html`)
console.log("🚀 ~ contentStr:", contentStr)
const base = String(cleanedResPath).replace(/\/?$/, '')
assetBaseUrl.value = `${staticUrl}${base}/`
let html = ''
if (isUrl(contentStr)) {
@@ -271,7 +293,15 @@
}
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[] = []
@@ -336,7 +366,7 @@
return
}
const href = asset.href
const href = resolveAssetUrl(asset.href)
// 이미 로드된 스타일시트 확인
const existingLink = document.querySelector(`link[href="${href}"]`)
if (existingLink) {
@@ -365,7 +395,7 @@
return
}
const src = asset.src
const src = resolveAssetUrl(asset.src)
// 이미 로드된 스크립트 확인
const existingScript = document.querySelector(`script[src="${src}"]`)
if (existingScript) {
@@ -375,8 +405,7 @@
const script = document.createElement('script')
script.src = src
// 순서 보장을 위해 async=false
script.async = false
script.defer = true
if (asset.type) script.type = asset.type
if (asset.integrity) script.integrity = asset.integrity
if (asset.crossOrigin) script.crossOrigin = asset.crossOrigin
@@ -417,15 +446,26 @@
const modalStore = useModalStore()
// 로그 전송 함수 (fnCustomLog로 노출)
// - 호출 방식: fnCustomLog('로그값') 또는 fnCustomLog(element)
const handleCustomLog = (clickSarea: string, clickItem: string | null) => {
if (!import.meta.client) return
if (clickItem == null) return
if (!clickSarea || !clickItem) return
handleSendLog(clickSarea, clickItem)
}
// API HTML에서 onclick으로 호출할 수 있도록 window에 노출할 "단일 함수"
// - 인자 방식: onclick="window.__customAction('open-confirm', '내용', '확인', '취소', '로그값')"
// - dataset 방식도 호환: onclick="window.__customAction(this)"
// - 인자 방식: onclick="fnCustomAction('open-confirm', '내용', '확인', '취소')"
// - dataset 방식도 호환: onclick="fnCustomAction(this)"
const handleCustomAction = (
actionOrEl: string | HTMLElement | null,
rawContent?: string,
confirmText?: string,
cancelText?: string,
logValue?: string,
) => {
if (!import.meta.client) return
if (!actionOrEl) return
@@ -452,11 +492,6 @@
? cancelText || ''
: actionOrEl.getAttribute('data-cancel-text') || ''
const analyticsLogValue =
typeof actionOrEl === 'string'
? logValue || ''
: actionOrEl.getAttribute('data-log') || ''
// 1) open-confirm
if (action === 'open-confirm') {
const contentText = contentRaw ? applyI18nToApiHtml(contentRaw) : ''
@@ -478,12 +513,6 @@
})
}
}
// 2) analytics
if (analyticsLogValue) {
console.log("🚀 ~ handleCustomAction ~ analyticsLogValue:", analyticsLogValue)
handleSendLog(analyticsLogValue)
}
}
// custom_contents 변경 시 link/script를 "작성된 순서"대로 순차 로드
@@ -493,9 +522,10 @@
if (!import.meta.client) return
if (!html) return
// HTML이 업데이트될 때마다 __customAction 재설정 (CSR에서만)
// HTML이 업데이트될 때마다 fnCustomAction, fnCustomLog 재설정 (CSR에서만)
if (import.meta.client) {
;(window as any).__customAction = handleCustomAction
;window.fnCustomAction = handleCustomAction
;window.fnCustomLog = handleCustomLog
}
scriptsLoaded.value = false
@@ -508,27 +538,95 @@
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)
} else {
await loadScript(asset)
}
} catch (err) {
// eslint-disable-next-line no-console
console.warn(`${asset.kind} load error:`, err)
}
}
// 스크립트 로드 후에도 __customAction 재설정 (CSR에서만)
if (!cancelled && import.meta.client) {
scriptsLoaded.value = true
;(window as any).__customAction = handleCustomAction
} else if (!cancelled) {
scriptsLoaded.value = true
},
{ 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
;window.fnCustomAction = handleCustomAction
;window.fnCustomLog = handleCustomLog
scriptsLoaded.value = false
// 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)
}
scriptsLoaded.value = true
;window.fnCustomAction = handleCustomAction
;window.fnCustomLog = handleCustomLog
// 동적 로드 시점에는 DOMContentLoaded/load가 이미 발생했으므로, 스크립트에서 구독할 커스텀 이벤트 발생
if (import.meta.client) {
window.dispatchEvent(new CustomEvent('customContentReady'))
}
})
},
{ immediate: true },
)
@@ -540,8 +638,9 @@
const container = customContainerRef.value
if (!container) return
// onclick="window.__customAction(this)" 지원 (CSR에서만)
;(window as any).__customAction = handleCustomAction
// onclick="fnCustomAction(this)", fnCustomLog(this) 지원 (CSR에서만)
;window.fnCustomAction = handleCustomAction
;window.fnCustomLog = handleCustomLog
// onclick을 못 쓰는 케이스 대비: 이벤트 위임도 같이 지원
const onDelegatedClick = (e: MouseEvent) => {
@@ -561,8 +660,11 @@
onBeforeUnmount(() => {
if (!import.meta.client) return
const w = window as any
if (w.__customAction === handleCustomAction) {
delete w.__customAction
if (w.fnCustomAction === handleCustomAction) {
delete w.fnCustomAction
}
if (w.fnCustomLog === handleCustomLog) {
delete w.fnCustomLog
}
})
</script>
@@ -575,11 +677,6 @@
v-dompurify-admin="customContentsHtml"
class="custom-container"
></div>
<div
class="custom-container"
>
{{ tm('Newreturnevent_Section03_Title') }}
</div>
</section>
</template>