diff --git a/index.html b/index.html index 57a21c3..1d6c058 100644 --- a/index.html +++ b/index.html @@ -24,7 +24,7 @@ @@ -32,13 +32,13 @@
diff --git a/src/scripts/breadcrumb.ts b/src/scripts/breadcrumb.ts index 58d180d..c0360ea 100644 --- a/src/scripts/breadcrumb.ts +++ b/src/scripts/breadcrumb.ts @@ -107,29 +107,60 @@ async function generateBreadcrumbItems(): Promise { return breadcrumbItems; } +/** + * XSS 방지를 위한 HTML escape + */ +function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +/** + * href 속성값을 안전하게 escape + * 따옴표, 꺾쇠괄호 등을 HTML 엔티티로 변환 + */ +function escapeHtmlAttribute(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>'); +} + +/** + * Breadcrumb 아이템을 HTML 문자열로 변환 + */ +function renderBreadcrumbItem(item: BreadcrumbItem, isLast: boolean): string { + const escapedName = escapeHtml(item.name); + const escapedPath = escapeHtmlAttribute(item.path); + + if (isLast) { + return `${escapedName}`; + } + + if (item.linkable) { + return `${escapedName} / `; + } + + // linkable=false인 항목은 회색으로 표시 (클릭 불가 시각 표시) + return `${escapedName} / `; +} + /** * Breadcrumb HTML 요소를 생성합니다. */ function createBreadcrumbElement(items: BreadcrumbItem[]): HTMLElement { const breadcrumbNav = document.createElement('nav'); breadcrumbNav.id = 'breadcrumbs'; - breadcrumbNav.className = - 'pb-3 flex min-w-0 items-center gap-2 text-gray-400 dark:text-gray-300'; + // nav 요소에서 색상 클래스 제거 (자식 요소에서 색상 관리) + breadcrumbNav.className = 'pb-3 flex min-w-0 items-center gap-2'; const breadcrumbHTML = items - .map((item, index) => { - const isLast = index === items.length - 1; - - if (isLast) { - return `${item.name}`; - } - - if (!item.linkable) { - return `${item.name} / `; - } else { - return `${item.name} / `; - } - }) + .map((item, index) => + renderBreadcrumbItem(item, index === items.length - 1) + ) .join(''); breadcrumbNav.innerHTML = breadcrumbHTML; diff --git a/src/styles/content_style.css b/src/styles/content_style.css index 7b808f3..cb9214f 100644 --- a/src/styles/content_style.css +++ b/src/styles/content_style.css @@ -1,163 +1,347 @@ /* * Markdown 콘텐츠 전용 스타일 - * 직접 자식만 타겟팅하여 Web Components 제외 + * 기본적으로 .markdown-content의 직접 자식만 타겟팅하여 Web Components 경계를 침범하지 않으며, + * 코드/링크 등 인라인 요소에 한해서는 예외적으로 자손 셀렉터도 사용합니다. + * + * BEM 구조: + * Block: .markdown-content (마크다운 콘텐츠 컨테이너) + * CSS 변수 네임스페이스: --md-code-* (코드 블록 관련) */ +/* ======================================== + CSS 변수 정의 + ======================================== */ +:root { + /* ======================================== + Spacing Scale - 8px base unit + 네임스페이스: md-spacing (markdown-spacing) + 원칙: Vertical rhythm 유지, Proximity principle 적용 + ======================================== */ + --md-spacing-xs: 0.5rem; /* 8px - 리스트 아이템 간격 */ + --md-spacing-sm: 0.75rem; /* 12px - h4 하단, blockquote 내부 문단 */ + --md-spacing-md: 1rem; /* 16px - 기본 문단 간격, h2/h3 하단 */ + --md-spacing-lg: 1.5rem; /* 24px - h1 상하, 코드/인용 상하 */ + --md-spacing-xl: 2rem; /* 32px - h4 상단 */ + --md-spacing-2xl: 2.5rem; /* 40px - h3 상단 */ + --md-spacing-3xl: 3rem; /* 48px - h2 상단 (섹션 구분 강화) */ + + /* ======================================== + 코드 블록 색상 + 네임스페이스: md-code (markdown-code) + ======================================== */ + /* Light mode - GitHub 스타일 */ + --md-code-bg-light: #f6f8fa; + --md-code-border-light: #d0d7de; + --md-code-text-light: #24292f; + --md-code-inline-bg-light: rgba(175, 184, 193, 0.2); + --md-code-inline-border-light: rgba(175, 184, 193, 0.3); + + /* Dark mode - GitHub 스타일 */ + --md-code-bg-dark: #161b22; + --md-code-border-dark: #30363d; + --md-code-text-dark: #e6edf3; + --md-code-inline-bg-dark: rgba(110, 118, 129, 0.3); + --md-code-inline-border-dark: rgba(110, 118, 129, 0.4); + + /* Active theme (기본값: light) */ + --md-code-bg: var(--md-code-bg-light); + --md-code-border: var(--md-code-border-light); + --md-code-text: var(--md-code-text-light); + --md-code-inline-bg: var(--md-code-inline-bg-light); + --md-code-inline-border: var(--md-code-inline-border-light); + + /* 공통 폰트 스택 */ + --md-code-font-family: + 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', + monospace; + + /* ======================================== + 링크 색상 + 네임스페이스: md-link (markdown-link) + WCAG AA 기준 충족 (4.5:1 대비) + Note: visited 링크도 동일한 색상 유지 (보라색 변경 없음) + ======================================== */ + /* Light mode - GitHub 스타일 */ + --md-link-color-light: #0969da; + --md-link-color-hover-light: #0550ae; + + /* Dark mode - GitHub 스타일 */ + --md-link-color-dark: #58a6ff; + --md-link-color-hover-dark: #79c0ff; + + /* Active theme (기본값: light) */ + --md-link-color: var(--md-link-color-light); + --md-link-color-hover: var(--md-link-color-hover-light); +} + +@media (prefers-color-scheme: dark) { + :root { + --md-code-bg: var(--md-code-bg-dark); + --md-code-border: var(--md-code-border-dark); + --md-code-text: var(--md-code-text-dark); + --md-code-inline-bg: var(--md-code-inline-bg-dark); + --md-code-inline-border: var(--md-code-inline-border-dark); + + /* 링크 다크 모드 */ + --md-link-color: var(--md-link-color-dark); + --md-link-color-hover: var(--md-link-color-hover-dark); + } +} + /* ======================================== 타이포그래피 + 원칙: + - Proximity principle: 제목은 하위 콘텐츠와 가까이 (margin-top > margin-bottom) + - Visual hierarchy: h2 > h3 > h4 순으로 상단 여백 차등 적용 + - Vertical rhythm: 8px base unit 사용 ======================================== */ -#content > h1, -#content > h2, -#content > h3, -#content > h4 { +.markdown-content > h1, +.markdown-content > h2, +.markdown-content > h3, +.markdown-content > h4 { font-weight: 500; - margin-bottom: 1rem; - margin-top: 2rem; color: #444; } -#content > h1 { +/* h1 - 페이지 제목 */ +.markdown-content > h1 { font-size: 2rem; font-weight: bold; - margin-top: 1rem; + margin-top: var(--md-spacing-lg); /* 24px - 페이지 시작 여백 */ + margin-bottom: var(--md-spacing-lg); /* 24px - 하위 콘텐츠와 적절한 거리 */ } -#content > h2 { +/* h2 - 섹션 구분 (가장 중요한 구조) */ +.markdown-content > h2 { font-size: 1.75rem; + margin-top: var(--md-spacing-3xl); /* 48px - 섹션 구분 강화 */ + margin-bottom: var(--md-spacing-md); /* 16px - 하위 콘텐츠와 가까이 */ } -#content > h3 { +/* h3 - 하위 섹션 */ +.markdown-content > h3 { font-size: 1.5rem; + margin-top: var(--md-spacing-2xl); /* 40px - 중간 계층 구분 */ + margin-bottom: var(--md-spacing-md); /* 16px - 하위 콘텐츠와 가까이 */ } -#content > h4 { +/* h4 - 세부 항목 */ +.markdown-content > h4 { font-size: 1.2rem; + margin-top: var(--md-spacing-xl); /* 32px - 최소 구분 여백 */ + margin-bottom: var(--md-spacing-sm); /* 12px - 하위 콘텐츠와 매우 가까이 */ } -#content > p { +/* p - 문단 (가장 중요한 변경) */ +.markdown-content > p { font-size: 1rem; - line-height: 1.6; - margin-bottom: 0.5rem; + line-height: 1.7; /* 1.6 → 1.7 증가: 가독성 향상 */ + margin-bottom: var(--md-spacing-md); /* 16px - 0.5rem(8px)에서 증가 */ color: #222; } +/* ======================================== + Contextual spacing - 제목 바로 다음 요소 + 원칙: 제목과 첫 번째 콘텐츠는 더욱 가까이 + ======================================== */ +.markdown-content > h2 + p, +.markdown-content > h3 + p, +.markdown-content > h4 + p { + margin-top: 0; /* 제목의 margin-bottom만 적용 */ +} + /* ======================================== 리스트 + 원칙: + - 리스트 컨테이너는 다른 블록 요소와 동일한 하단 여백 + - 리스트 아이템 간격은 최소 단위 사용 (밀도 유지) + - 중첩 리스트는 상하 여백 최소화 ======================================== */ -#content > ul, -#content > ol { +/* 리스트 컨테이너 */ +.markdown-content > ul, +.markdown-content > ol { margin-left: 1.5rem; padding-left: 1rem; + margin-bottom: var(--md-spacing-lg); /* 24px - 블록 요소 간격 통일 */ color: #222; } -#content > ul { +.markdown-content > ul { list-style-type: disc; } -#content > ol { +.markdown-content > ol { list-style-type: decimal; } -/* 리스트 아이템은 ul/ol의 직접 자식 */ -#content > ul > li, -#content > ol > li { - margin-bottom: 0.5rem; +/* 리스트 아이템 */ +.markdown-content > ul > li, +.markdown-content > ol > li { + margin-bottom: var(--md-spacing-xs); /* 8px - 최소 단위 */ font-size: 1rem; - line-height: 1.6; + line-height: 1.7; /* 1.6 → 1.7: 문단과 통일 */ +} + +/* 마지막 아이템은 하단 여백 제거 */ +.markdown-content > ul > li:last-child, +.markdown-content > ol > li:last-child { + margin-bottom: 0; } /* 중첩 리스트 */ -#content ul ul, -#content ol ol, -#content ul ol, -#content ol ul { +.markdown-content ul ul, +.markdown-content ol ol, +.markdown-content ul ol, +.markdown-content ol ul { margin-left: 1.5rem; padding-left: 1rem; + margin-top: var(--md-spacing-xs); /* 8px - 상위 아이템과 간격 */ + margin-bottom: var(--md-spacing-xs); /* 8px - 다음 아이템과 간격 */ } /* ======================================== - 코드 블록 + 코드 블록 - BEM 구조 + Block: .markdown-content + Element (논리적): pre (코드 블록), code (코드 텍스트/인라인) + Note: 마크다운 파서가 생성하는 HTML이므로 Element 클래스를 직접 붙일 수 없음 + 대신 하위 셀렉터로 타겟팅 ======================================== */ -#content > pre { - padding: 0.5rem; - border: 2px solid #e1e2e6; +/* Element (논리적): 인라인 코드 () - pre 안에 없는 경우만 */ +.markdown-content :not(pre) > code { + font-size: 0.875rem; + font-family: var(--md-code-font-family); + background-color: var(--md-code-inline-bg); + color: var(--md-code-text); + padding: 0.125rem 0.375rem; + border: 1px solid var(--md-code-inline-border); + border-radius: 4px; + white-space: nowrap; +} + +/* Element (논리적): 코드 블록 컨테이너 (
) - 자손 셀렉터 사용 */
+.markdown-content pre {
+  padding: 1.25rem; /* 20px - 1rem(16px)에서 증가: 내부 여유 공간 확보 */
+  background-color: var(--md-code-bg);
+  border: 1px solid var(--md-code-border);
   border-radius: 8px;
   overflow-x: auto;
   line-height: 1.5;
-  margin-bottom: 0.5rem;
+  margin-top: var(--md-spacing-lg); /* 24px - 1rem(16px)에서 증가 */
+  margin-bottom: var(--md-spacing-lg); /* 24px - 1rem(16px)에서 증가 */
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
 }
 
-#content pre code {
-  display: block;
-  background: #fff;
-  color: #000000;
-  white-space: pre-wrap;
+/* Contextual spacing - 제목 바로 다음 코드 블록 */
+.markdown-content > h2 + pre,
+.markdown-content > h3 + pre,
+.markdown-content > h4 + pre {
+  margin-top: var(--md-spacing-md); /* 16px - 제목과 가까이 */
 }
 
-#content code {
-  font-size: 13px;
-  background-color: #e1e2e6;
-  color: #000000;
-  padding: 2px 6px;
-  border-radius: 4px;
-  white-space: nowrap;
+/* Contextual spacing - 코드 블록 바로 다음 제목 */
+.markdown-content > pre + h2,
+.markdown-content > pre + h3,
+.markdown-content > pre + h4 {
+  margin-top: var(--md-spacing-3xl); /* 48px - 섹션 전환 명확화 */
 }
 
-/* ========================================
-   기타 요소
-   ======================================== */
+/* Dark mode 추가 조정 */
+@media (prefers-color-scheme: dark) {
+  .markdown-content pre {
+    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
+  }
+}
 
-#content > blockquote {
-  border-left: 4px solid #007bff;
-  padding: 0.75rem 1rem;
-  margin: 1rem 0;
-  background-color: #f8f9fa;
-  font-style: italic;
-  color: #555;
+/* Element (논리적): 코드 블록 내부 텍스트 (
) - 인라인 스타일 오버라이드 */
+.markdown-content pre code {
+  display: block;
+  background: transparent;
+  color: var(--md-code-text);
+  white-space: pre;
+  font-family: var(--md-code-font-family);
+  font-size: 0.875rem;
+  line-height: 1.6;
+  padding: 0;
+  border: none;
+  border-radius: 0;
 }
 
-#content > a {
-  color: #007bff;
+/* ========================================
+   링크 스타일
+   Note: visited 링크도 동일한 색상 유지 (보라색 변경 없음)
+   ======================================== */
+
+/* 모든 하위 링크에 기본 스타일 적용 */
+.markdown-content a {
+  color: var(--md-link-color);
   text-decoration: none;
   transition: color 0.2s ease-in-out;
 }
 
-#content > a:hover {
-  color: #007bff;
+.markdown-content a:hover {
+  color: var(--md-link-color-hover);
+}
+
+/* visited 링크도 동일한 파란색 유지 */
+.markdown-content a:visited {
+  color: var(--md-link-color);
+}
+
+/* 키보드 네비게이션 접근성 */
+.markdown-content a:focus-visible {
+  outline: 2px solid var(--md-link-color);
+  outline-offset: 2px;
+  border-radius: 2px;
 }
 
-#content > a.external-link::after {
+/* 외부 링크 아이콘 */
+.markdown-content a.external-link::after {
   content: ' 🔗';
   font-size: 0.8em;
   opacity: 0.7;
 }
 
-/* 문단 내 링크 */
-#content p a {
-  color: #007bff;
-  text-decoration: none;
-  transition: color 0.2s ease-in-out;
+/* ========================================
+   인용구 (blockquote)
+   원칙: 코드 블록과 동일한 상하 여백, 내부는 밀도 높게
+   ======================================== */
+
+.markdown-content > blockquote {
+  border-left: 4px solid #007bff;
+  padding: var(--md-spacing-md) 1.25rem; /* 상하 16px, 좌우 20px */
+  margin-top: var(--md-spacing-lg); /* 24px */
+  margin-bottom: var(--md-spacing-lg); /* 24px */
+  background-color: #f8f9fa;
+  font-style: italic;
+  color: #555;
+}
+
+/* blockquote 내부 문단 간격 */
+.markdown-content > blockquote > p {
+  margin-bottom: var(--md-spacing-sm); /* 12px - 인용구 내부는 밀도 높게 */
 }
 
-#content p a:hover {
-  color: #0056b3;
+.markdown-content > blockquote > p:last-child {
+  margin-bottom: 0; /* 마지막 문단은 여백 제거 */
 }
 
+/* ========================================
+   기타 요소
+   ======================================== */
+
 /* YouTube 임베드는 클래스 기반이므로 그대로 */
-#content .youtube-video {
+.markdown-content .youtube-video {
   position: relative;
   padding-bottom: 56.25%;
   height: 0;
   overflow: hidden;
   max-width: 100%;
-  max-height: auto;
 }
 
-#content .youtube-video iframe {
+.markdown-content .youtube-video iframe {
   position: absolute;
   top: 0;
   left: 0;
diff --git a/tests/markdown.test.ts b/tests/markdown.test.ts
index 273cc8f..00fdbd3 100644
--- a/tests/markdown.test.ts
+++ b/tests/markdown.test.ts
@@ -27,7 +27,7 @@ afterAll(() => {
 
 beforeEach(() => {
   document = testEnv.document;
-  document.body.innerHTML = '
'; + document.body.innerHTML = '
'; contentElement = document.getElementById('content')!; }); diff --git a/tests/table-contents.test.ts b/tests/table-contents.test.ts index 5f08922..d3d6a76 100644 --- a/tests/table-contents.test.ts +++ b/tests/table-contents.test.ts @@ -25,7 +25,7 @@ beforeAll(async () => { testMarkdownContent = await marked.parse(rawMarkdown); dom = new JSDOM( - '
' + '
' ); // JSDOM 글로벌 환경 설정 @@ -47,7 +47,8 @@ beforeAll(async () => { beforeEach(() => { /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ document = (global as any).document; - document.body.innerHTML = '
'; + document.body.innerHTML = + '
'; contentElement = document.getElementById('content')!; tocElement = document.getElementById('toc')!;