Files
sori.studio/components/content/ProseHtml.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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
/**
* 브라우저에서 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>