250 lines
5.9 KiB
Vue
250 lines
5.9 KiB
Vue
<script setup>
|
|
const props = defineProps({
|
|
html: {
|
|
type: String,
|
|
default: ''
|
|
}
|
|
})
|
|
|
|
const allowedHtmlBlockTags = new Set([
|
|
'a',
|
|
'abbr',
|
|
'b',
|
|
'blockquote',
|
|
'br',
|
|
'caption',
|
|
'code',
|
|
'col',
|
|
'colgroup',
|
|
'dd',
|
|
'details',
|
|
'div',
|
|
'dl',
|
|
'dt',
|
|
'em',
|
|
'figcaption',
|
|
'figure',
|
|
'h2',
|
|
'h3',
|
|
'h4',
|
|
'h5',
|
|
'h6',
|
|
'hr',
|
|
'i',
|
|
'img',
|
|
'li',
|
|
'mark',
|
|
'ol',
|
|
'p',
|
|
'pre',
|
|
's',
|
|
'small',
|
|
'span',
|
|
'strong',
|
|
'sub',
|
|
'summary',
|
|
'sup',
|
|
'table',
|
|
'tbody',
|
|
'td',
|
|
'tfoot',
|
|
'th',
|
|
'thead',
|
|
'time',
|
|
'tr',
|
|
'u',
|
|
'ul'
|
|
])
|
|
const globalAllowedAttributes = new Set(['class', 'title', 'aria-label', 'aria-hidden'])
|
|
const allowedAttributesByTag = {
|
|
a: new Set(['href', 'target', 'rel']),
|
|
col: new Set(['span', 'width']),
|
|
img: new Set(['src', 'alt', 'width', 'height', 'loading']),
|
|
li: new Set(['value']),
|
|
ol: new Set(['start', 'type']),
|
|
td: new Set(['colspan', 'rowspan', 'headers']),
|
|
th: new Set(['colspan', 'rowspan', 'headers', 'scope']),
|
|
time: new Set(['datetime'])
|
|
}
|
|
const urlAttributeNames = new Set(['href', 'src'])
|
|
const allowedUrlSchemes = new Set(['http:', 'https:', 'mailto:', 'tel:'])
|
|
|
|
/**
|
|
* HTML 텍스트를 escape한다.
|
|
* @param {unknown} value - 원본 값
|
|
* @returns {string} HTML escape 문자열
|
|
*/
|
|
const escapeHtmlText = (value) => String(value || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
|
|
/**
|
|
* 브라우저에서 URL 속성 값을 허용할지 확인한다.
|
|
* @param {string} value - URL 값
|
|
* @returns {boolean} 허용 여부
|
|
*/
|
|
const isAllowedBrowserUrl = (value) => {
|
|
const trimmedValue = String(value || '').trim()
|
|
|
|
if (!trimmedValue || trimmedValue.startsWith('//')) {
|
|
return false
|
|
}
|
|
|
|
if (trimmedValue.startsWith('/') || trimmedValue.startsWith('#')) {
|
|
return true
|
|
}
|
|
|
|
try {
|
|
return allowedUrlSchemes.has(new URL(trimmedValue, window.location.origin).protocol)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 브라우저에서 HTML 노드를 허용 목록 기준으로 정리한다.
|
|
* @param {Node} node - 정리할 노드
|
|
* @returns {Node|null} 정리된 노드
|
|
*/
|
|
const sanitizeBrowserNode = (node) => {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
return document.createTextNode(node.textContent || '')
|
|
}
|
|
|
|
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
return null
|
|
}
|
|
|
|
const element = /** @type {Element} */ (node)
|
|
const tagName = element.tagName.toLowerCase()
|
|
|
|
if (!allowedHtmlBlockTags.has(tagName)) {
|
|
const fragment = document.createDocumentFragment()
|
|
Array.from(element.childNodes).forEach((childNode) => {
|
|
const sanitizedChild = sanitizeBrowserNode(childNode)
|
|
if (sanitizedChild) {
|
|
fragment.appendChild(sanitizedChild)
|
|
}
|
|
})
|
|
return fragment
|
|
}
|
|
|
|
const sanitizedElement = document.createElement(tagName)
|
|
const tagAllowedAttributes = allowedAttributesByTag[tagName] || new Set()
|
|
|
|
Array.from(element.attributes).forEach((attribute) => {
|
|
const attributeName = attribute.name.toLowerCase()
|
|
|
|
if (!globalAllowedAttributes.has(attributeName) && !tagAllowedAttributes.has(attributeName)) {
|
|
return
|
|
}
|
|
|
|
if (urlAttributeNames.has(attributeName) && !isAllowedBrowserUrl(attribute.value)) {
|
|
return
|
|
}
|
|
|
|
sanitizedElement.setAttribute(attributeName, attribute.value)
|
|
})
|
|
|
|
if (tagName === 'a' && sanitizedElement.getAttribute('target') === '_blank') {
|
|
const relValues = new Set(String(sanitizedElement.getAttribute('rel') || '').split(/\s+/).filter(Boolean))
|
|
relValues.add('noopener')
|
|
relValues.add('noreferrer')
|
|
sanitizedElement.setAttribute('rel', [...relValues].join(' '))
|
|
}
|
|
|
|
Array.from(element.childNodes).forEach((childNode) => {
|
|
const sanitizedChild = sanitizeBrowserNode(childNode)
|
|
if (sanitizedChild) {
|
|
sanitizedElement.appendChild(sanitizedChild)
|
|
}
|
|
})
|
|
|
|
return sanitizedElement
|
|
}
|
|
|
|
/**
|
|
* 브라우저에서 HTML 블록을 정리한다.
|
|
* @param {unknown} value - 원본 HTML
|
|
* @returns {string} 정리된 HTML
|
|
*/
|
|
const sanitizeHtmlBlockInBrowser = (value) => {
|
|
const html = String(value || '').trim()
|
|
|
|
if (!html) {
|
|
return ''
|
|
}
|
|
|
|
if (!import.meta.client || typeof DOMParser === 'undefined') {
|
|
return escapeHtmlText(html)
|
|
}
|
|
|
|
const parser = new DOMParser()
|
|
const documentFragment = document.createDocumentFragment()
|
|
const parsedDocument = parser.parseFromString(`<template>${html}</template>`, 'text/html')
|
|
const template = parsedDocument.querySelector('template')
|
|
|
|
Array.from(template?.content.childNodes || []).forEach((node) => {
|
|
const sanitizedNode = sanitizeBrowserNode(node)
|
|
if (sanitizedNode) {
|
|
documentFragment.appendChild(sanitizedNode)
|
|
}
|
|
})
|
|
|
|
const container = document.createElement('div')
|
|
container.appendChild(documentFragment)
|
|
return container.innerHTML.trim()
|
|
}
|
|
|
|
const sanitizeHtmlBlock = import.meta.server
|
|
? (await import('../../lib/markdown-html-block.js')).sanitizeHtmlBlock
|
|
: sanitizeHtmlBlockInBrowser
|
|
|
|
const sanitizedHtml = computed(() => sanitizeHtmlBlock(props.html))
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
v-if="sanitizedHtml"
|
|
class="prose-html prose-html-block mb-8 overflow-x-auto text-base leading-7 text-[var(--site-text)]"
|
|
v-html="sanitizedHtml"
|
|
/>
|
|
<p v-else class="prose-html prose-html-block mb-8 text-sm font-semibold text-[var(--site-muted)]">
|
|
표시할 수 있는 HTML이 없습니다.
|
|
</p>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.prose-html :deep(table) {
|
|
width: 100%;
|
|
min-width: 520px;
|
|
border-collapse: collapse;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.prose-html :deep(th),
|
|
.prose-html :deep(td) {
|
|
border: 1px solid var(--site-line);
|
|
padding: 0.5rem 0.75rem;
|
|
vertical-align: top;
|
|
}
|
|
|
|
.prose-html :deep(th) {
|
|
background: color-mix(in srgb, var(--site-text) 6%, transparent);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.prose-html :deep(p) {
|
|
margin-bottom: 0.625rem;
|
|
}
|
|
|
|
.prose-html :deep(a) {
|
|
color: var(--site-accent);
|
|
text-decoration: underline;
|
|
text-underline-offset: 0.18em;
|
|
}
|
|
</style>
|