모델 & 스키마

문서 모델과 스키마 설계: 노드 타입, 문서 구조, 마크 시스템, 검증, HTML 매핑.

개요

스키마는 문서 구조가 포함할 수 있는 것을 정의합니다. 모델과 이를 수정하는 작업 사이의 계약입니다. 잘 설계된 스키마는 문서가 항상 유효하고 예측 가능하도록 보장합니다.

모델은 스키마를 준수하는 실제 문서 인스턴스입니다. 구조화되고 검증된 형식으로 문서의 현재 상태를 나타냅니다.

스키마 정의

스키마는 문서의 구조와 규칙을 정의합니다:

노드 스펙

각 노드 타입은 해당 속성을 정의하는 스펙을 가집니다:

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);
    }
  });
}