개요
스키마는 문서 구조가 포함할 수 있는 것을 정의합니다. 모델과 이를 수정하는 작업 사이의 계약입니다. 잘 설계된 스키마는 문서가 항상 유효하고 예측 가능하도록 보장합니다.
모델은 스키마를 준수하는 실제 문서 인스턴스입니다. 구조화되고 검증된 형식으로 문서의 현재 상태를 나타냅니다.
스키마 정의
스키마는 문서의 구조와 규칙을 정의합니다:
노드 스펙
각 노드 타입은 해당 속성을 정의하는 스펙을 가집니다:
const schema = {
nodes: {
document: {
content: 'block+', // 하나 이상의 블록을 포함해야 함
},
paragraph: {
content: 'inline*', // 0개 이상의 인라인을 포함할 수 있음
group: 'block', // 블록 그룹에 속함
},
heading: {
content: 'inline*',
group: 'block',
attrs: {
level: { default: 1 } // 기본값이 있는 속성
}
},
text: {
group: 'inline',
// 텍스트 노드는 자식을 가지지 않음
},
link: {
content: 'inline*',
group: 'inline',
attrs: {
href: { default: '' }
}
}
}
};마크 스펙
마크는 텍스트에 적용할 수 있는 포맷팅을 정의합니다:
const schema = {
marks: {
bold: {
// 속성이 없는 단순 마크
},
italic: {},
underline: {},
link: {
attrs: {
href: { default: '' },
title: { default: '' }
}
},
code: {
// 코드 마크는 다른 마크를 배제할 수 있음
excludes: 'bold italic underline'
}
}
};콘텐츠 규칙
콘텐츠 규칙은 각 노드 내부에 중첩될 수 있는 것을 정의합니다:
'block+'- 하나 이상의 블록'block*'- 0개 이상의 블록'inline*'- 0개 이상의 인라인'paragraph | heading'- 단락 또는 제목'(paragraph | heading)+'- 하나 이상의 단락 또는 제목
// 콘텐츠 규칙 예제
{
document: {
content: 'block+' // 문서는 최소 하나의 블록을 가져야 함
},
paragraph: {
content: 'inline*' // 단락은 모든 인라인을 가질 수 있음
},
list: {
content: 'listItem+', // 목록은 최소 하나의 항목을 가져야 함
group: 'block'
},
listItem: {
content: 'paragraph block*', // 항목은 단락으로 시작하고, 그 다음 선택적 블록
group: 'block'
}
}노드 타입
블록 노드
블록 노드는 일반적으로 새 줄에서 시작하는 구조적 요소입니다:
- 단락
- 제목 (h1-h6)
- 목록 (순서 있는, 순서 없는)
- 코드 블록
- 인용구
- 테이블
// 블록 노드 예제
{
type: 'paragraph',
children: [
{ type: 'text', text: '이것은 단락입니다.' }
]
}
{
type: 'heading',
level: 2,
children: [
{ type: 'text', text: '제목' }
]
}
{
type: 'codeBlock',
language: 'javascript',
children: [
{ type: 'text', text: 'const x = 1;' }
]
}인라인 노드
인라인 노드는 블록 내부에 존재하며 줄을 끊지 않습니다:
- 링크
- 이미지
- 멘션
- 사용자 정의 인라인 요소
// 인라인 노드 예제
{
type: 'link',
attrs: { href: 'https://example.com' },
children: [
{ type: 'text', text: '예제' }
]
}
{
type: 'image',
attrs: {
src: 'image.jpg',
alt: '설명'
}
// 이미지는 일반적으로 자식을 가지지 않음
}텍스트 노드
텍스트 노드는 실제 텍스트 콘텐츠를 포함하며 마크를 가질 수 있습니다:
// 마크가 있는 텍스트 노드
{
type: 'text',
text: '볼드와 이탤릭',
marks: [
{ type: 'bold' },
{ type: 'italic' }
]
}
// 일반 텍스트 노드
{
type: 'text',
text: '일반 텍스트',
marks: []
}문서 구조
계층적 구조
문서는 루트 문서 노드를 가진 트리입니다:
// 완전한 문서 구조
{
type: 'document',
children: [
{
type: 'heading',
level: 1,
children: [
{ type: 'text', text: '제목' }
]
},
{
type: 'paragraph',
children: [
{ type: 'text', text: '첫 번째 단락.' }
]
},
{
type: 'paragraph',
children: [
{ type: 'text', text: '두 번째 ' },
{ type: 'text', text: '단락', marks: [{ type: 'bold' }] },
{ type: 'text', text: '.' }
]
}
]
}중첩 규칙
스키마는 유효하지 않은 구조를 방지하기 위해 중첩 규칙을 강제합니다:
- 블록은 인라인 내부에 중첩될 수 없음
- 텍스트 노드는 인라인 또는 블록 내부에만 존재할 수 있음
- 일부 노드는 특정 콘텐츠 요구사항을 가짐
// 유효한 구조
{
type: 'paragraph',
children: [
{ type: 'text', text: '텍스트' }
]
}
// 유효하지 않은 구조 (인라인 내부의 블록)
{
type: 'link',
children: [
{
type: 'paragraph', // ❌ 유효하지 않음: 인라인 내부의 블록
children: [...]
}
]
}
// 유효함: 블록 내부의 인라인
{
type: 'paragraph',
children: [
{
type: 'link', // ✅ 유효함: 블록 내부의 인라인
children: [
{ type: 'text', text: '링크' }
]
}
]
}마크 시스템
마크 타입
마크는 텍스트 노드에 적용되는 포맷팅입니다:
// 단일 마크가 있는 텍스트
{
type: 'text',
text: '볼드 텍스트',
marks: [{ type: 'bold' }]
}
// 여러 마크가 있는 텍스트
{
type: 'text',
text: '볼드와 이탤릭',
marks: [
{ type: 'bold' },
{ type: 'italic' }
]
}
// 속성이 있는 마크가 있는 텍스트
{
type: 'text',
text: '링크 텍스트',
marks: [
{
type: 'link',
attrs: { href: 'https://example.com' }
}
]
}마크 속성
일부 마크는 속성을 가집니다:
// 속성이 있는 링크 마크
{
type: 'text',
text: '예제',
marks: [
{
type: 'link',
attrs: {
href: 'https://example.com',
title: '예제 웹사이트'
}
}
]
}
// 속성이 있는 색상 마크
{
type: 'text',
text: '빨간 텍스트',
marks: [
{
type: 'color',
attrs: { color: '#ff0000' }
}
]
}마크 배타성
일부 마크는 다른 마크를 배제합니다 (예: 코드 마크는 포맷팅을 배제):
const schema = {
marks: {
code: {
excludes: 'bold italic underline link' // 코드는 다른 마크를 가질 수 없음
},
link: {
// 링크는 볼드, 이탤릭 등과 공존할 수 있음
}
}
};
// 유효함: 볼드와 이탤릭 함께
{
type: 'text',
text: '볼드 이탤릭',
marks: [{ type: 'bold' }, { type: 'italic' }]
}
// 유효하지 않음: 코드와 볼드
{
type: 'text',
text: '코드 볼드',
marks: [
{ type: 'code' },
{ type: 'bold' } // ❌ 코드는 볼드를 배제함
]
}스키마 검증
구조 검증
문서 구조가 스키마와 일치하는지 검증합니다:
function validateDocument(doc, schema) {
// 루트 노드 타입 확인
if (doc.type !== schema.topNode) {
return { valid: false, error: '유효하지 않은 루트 노드' };
}
// 각 자식 검증
for (const child of doc.children) {
const result = validateNode(child, schema);
if (!result.valid) {
return result;
}
}
return { valid: true };
}
function validateNode(node, schema) {
const spec = schema.nodes[node.type];
if (!spec) {
return { valid: false, error: '알 수 없는 노드 타입: ' + node.type };
}
// 콘텐츠가 스펙과 일치하는지 검증
if (!matchesContentRule(node.children, spec.content)) {
return { valid: false, error: '콘텐츠가 스펙과 일치하지 않음' };
}
// 속성 검증
if (!validateAttributes(node.attrs, spec.attrs)) {
return { valid: false, error: '유효하지 않은 속성' };
}
// 재귀적으로 자식 검증
for (const child of node.children) {
const result = validateNode(child, schema);
if (!result.valid) {
return result;
}
}
return { valid: true };
}콘텐츠 검증
노드 콘텐츠가 콘텐츠 규칙과 일치하는지 검증합니다:
function matchesContentRule(children, rule) {
// 콘텐츠 규칙 파싱 (예: 'block+', 'inline*')
const parsed = parseContentRule(rule);
// 자식이 일치하는지 확인
if (parsed.type === 'group') {
// 모든 자식이 그룹에 속하는지 확인
return children.every(child =>
isInGroup(child, parsed.group)
);
}
// 다른 규칙 타입 처리...
return true;
}
function isInGroup(node, group) {
const spec = schema.nodes[node.type];
return spec?.group === group;
}HTML 매핑
모델과 HTML 간의 매핑은 렌더링과 파싱에 필수적입니다:
모델에서 HTML로
모델을 HTML로 직렬화합니다:
function serializeNode(node) {
switch (node.type) {
case 'document':
return serializeChildren(node.children);
case 'paragraph':
return '<p>' + serializeChildren(node.children) + '</p>';
case 'heading':
return '<h' + node.level + '>' + serializeChildren(node.children) + '</h' + node.level + '>';
case 'text':
let html = escapeHtml(node.text);
// 마크 적용
if (node.marks) {
node.marks.forEach(mark => {
html = wrapWithMark(html, mark);
});
}
return html;
case 'link':
const href = node.attrs?.href || '';
return '<a href="' + escapeHtml(href) + '">' + serializeChildren(node.children) + '</a>';
default:
return serializeChildren(node.children);
}
}
function wrapWithMark(html, mark) {
const tagMap = {
bold: 'strong',
italic: 'em',
underline: 'u',
code: 'code'
};
const tag = tagMap[mark.type];
if (!tag) return html;
const attrs = mark.attrs ? serializeAttrs(mark.attrs) : '';
return '<' + tag + attrs + '>' + html + '</' + tag + '>';
}HTML에서 모델로
HTML을 모델로 파싱합니다:
function parseHTML(html) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
return {
type: 'document',
children: Array.from(doc.body.childNodes)
.map(node => parseNode(node))
.filter(Boolean)
};
}
function parseNode(domNode) {
if (domNode.nodeType === Node.TEXT_NODE) {
return {
type: 'text',
text: domNode.textContent,
marks: extractMarks(domNode)
};
}
if (domNode.nodeType === Node.ELEMENT_NODE) {
const nodeType = getNodeType(domNode.tagName);
if (!nodeType) {
// 알 수 없는 요소, 언래핑하고 자식 파싱
return parseChildren(domNode.childNodes);
}
return {
type: nodeType,
attrs: extractAttributes(domNode, nodeType),
children: parseChildren(domNode.childNodes)
};
}
return null;
}
function extractMarks(textNode) {
const marks = [];
let current = textNode.parentElement;
while (current && current !== editor) {
const mark = getMarkFromElement(current);
if (mark) {
marks.push(mark);
}
current = current.parentElement;
}
return marks;
}HTML 정규화
일관성 없는 HTML을 스키마에 맞게 정규화합니다:
<b>를<strong>로 변환<i>를<em>로 변환- 적절한 경우
<div>를<p>로 변환 - 유효하지 않은 속성 제거
- 중첩 위반 수정
function normalizeHTML(html) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// 요소 정규화
normalizeElements(doc.body);
// 중첩 수정
fixNesting(doc.body);
// 유효하지 않은 속성 제거
removeInvalidAttributes(doc.body);
return doc.body.innerHTML;
}
function normalizeElements(element) {
// b를 strong으로 변환
element.querySelectorAll('b').forEach(b => {
const strong = document.createElement('strong');
strong.innerHTML = b.innerHTML;
b.parentNode.replaceChild(strong, b);
});
// i를 em으로 변환
element.querySelectorAll('i').forEach(i => {
const em = document.createElement('em');
em.innerHTML = i.innerHTML;
i.parentNode.replaceChild(em, i);
});
// div를 p로 변환 (적절한 경우)
element.querySelectorAll('div').forEach(div => {
if (!div.querySelector('p, ul, ol, h1, h2, h3, h4, h5, h6')) {
const p = document.createElement('p');
p.innerHTML = div.innerHTML;
div.parentNode.replaceChild(p, div);
}
});
}