From af89057b3dd9b856f47e8d2566acb4e6722a4cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B0=EC=83=81=EB=B9=88?= Date: Sun, 16 Aug 2026 20:34:16 +0900 Subject: [PATCH] =?UTF-8?q?refactor(markdown):=20Unified/Remark/Rehype=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=ED=99=94=20=EB=B0=8F=20=ED=94=8C=EB=9F=AC=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - render.ts 내 AST 변환 로직을 plugins/ (math, tables, task-list, headings, toc, sanitize) 디렉터리로 단일 책임(SRP)에 맞춰 분리 - Unified 파이프라인 팩토리 함수(createMarkdownProcessor) 및 SanitizedHtml 브랜딩 가드 캡슐화 - 분리된 커스텀 플러그인에 대한 단위 테스트(tests/markdown-plugins.test.ts) 추가 --- src/lib/markdown/plugins/headings.ts | 55 +++++ src/lib/markdown/plugins/index.ts | 17 ++ src/lib/markdown/plugins/math.ts | 62 ++++++ src/lib/markdown/plugins/sanitize.ts | 23 ++ src/lib/markdown/plugins/tables.ts | 27 +++ src/lib/markdown/plugins/task-list.ts | 18 ++ src/lib/markdown/plugins/toc.ts | 48 +++++ src/lib/markdown/render.ts | 288 +++++--------------------- tests/markdown-plugins.test.ts | 74 +++++++ 9 files changed, 379 insertions(+), 233 deletions(-) create mode 100644 src/lib/markdown/plugins/headings.ts create mode 100644 src/lib/markdown/plugins/index.ts create mode 100644 src/lib/markdown/plugins/math.ts create mode 100644 src/lib/markdown/plugins/sanitize.ts create mode 100644 src/lib/markdown/plugins/tables.ts create mode 100644 src/lib/markdown/plugins/task-list.ts create mode 100644 src/lib/markdown/plugins/toc.ts create mode 100644 tests/markdown-plugins.test.ts diff --git a/src/lib/markdown/plugins/headings.ts b/src/lib/markdown/plugins/headings.ts new file mode 100644 index 0000000..52bf8db --- /dev/null +++ b/src/lib/markdown/plugins/headings.ts @@ -0,0 +1,55 @@ +import type { Element, Root as HastRoot, Parents } from "hast" +import { visit } from "unist-util-visit" + +export const HEADING_NAMES = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]) +export const TOC_HEADING_NAMES = new Set(["h2", "h3"]) +const EMPTY_GITHUB_SLUG_PATTERN = /^-\d+$/ + +/** + * 기호만 있는 제목(예: ## 🎉, ## !!!)이나 빈 제목이 rehype-slug 에서 + * 공백/숫자 전용 id(-1 등)로 축약되어 깨지는 현상을 방지하고 고유의 대체 id(section, section-1)를 부여한다. + */ +export function rehypeNormalizeHeadingIds() { + return (tree: HastRoot) => { + const usedIds = new Set() + const headingsWithoutTextSlugs: Element[] = [] + + visit(tree, "element", (node) => { + const id = node.properties.id + + if (!HEADING_NAMES.has(node.tagName) || typeof id !== "string") { + return + } + + if (id.length === 0 || EMPTY_GITHUB_SLUG_PATTERN.test(id)) { + headingsWithoutTextSlugs.push(node) + return + } + + usedIds.add(id) + }) + + for (const heading of headingsWithoutTextSlugs) { + heading.properties.id = createFallbackHeadingId(usedIds) + } + } +} + +export function isRootTocHeading(element: Element, _index?: number, parent?: Parents): boolean { + return parent?.type === "root" && TOC_HEADING_NAMES.has(element.tagName) +} + +function createFallbackHeadingId(usedIds: Set): string { + const baseId = "section" + let suffix = 0 + let id = baseId + + while (usedIds.has(id)) { + suffix += 1 + id = `${baseId}-${suffix}` + } + + usedIds.add(id) + + return id +} diff --git a/src/lib/markdown/plugins/index.ts b/src/lib/markdown/plugins/index.ts new file mode 100644 index 0000000..00025fe --- /dev/null +++ b/src/lib/markdown/plugins/index.ts @@ -0,0 +1,17 @@ +export { + HEADING_NAMES, + isRootTocHeading, + rehypeNormalizeHeadingIds, + TOC_HEADING_NAMES, +} from "./headings" +export { + MATH_CODE_FENCE_MARKER, + MATH_LANGUAGE_CLASS, + rehypeProtectMathCodeFences, + rehypeRestoreMathCodeFences, + remarkDetectMath, +} from "./math" +export { markdownSanitizeSchema } from "./sanitize" +export { rehypeWrapTables, TABLE_WRAPPER_CLASS_NAME } from "./tables" +export { rehypeNameTaskListCheckboxes } from "./task-list" +export { rehypeCollectTableOfContents, type TableOfContentsItem } from "./toc" diff --git a/src/lib/markdown/plugins/math.ts b/src/lib/markdown/plugins/math.ts new file mode 100644 index 0000000..f632f33 --- /dev/null +++ b/src/lib/markdown/plugins/math.ts @@ -0,0 +1,62 @@ +import type { Root as HastRoot } from "hast" +import type { Root as MdastRoot } from "mdast" +import { visit } from "unist-util-visit" +import type { VFile } from "vfile" + +export const MATH_LANGUAGE_CLASS = "language-math" +export const MATH_CODE_FENCE_MARKER = "dataMathCodeFence" + +export function remarkDetectMath() { + return (tree: MdastRoot, file: VFile) => { + let detectedMath = false + + visit(tree, (node) => { + if (node.type === "math" || node.type === "inlineMath") { + detectedMath = true + } + }) + + file.data.hasMath = detectedMath + } +} + +export function rehypeProtectMathCodeFences() { + return (tree: HastRoot) => { + visit(tree, "element", (node) => { + const classNames = node.properties.className + + if ( + node.tagName !== "code" || + !Array.isArray(classNames) || + !classNames.includes(MATH_LANGUAGE_CLASS) || + classNames.includes("math-inline") || + classNames.includes("math-display") + ) { + return + } + + node.properties.className = classNames.filter( + (className) => className !== MATH_LANGUAGE_CLASS, + ) + node.properties[MATH_CODE_FENCE_MARKER] = "" + }) + } +} + +export function rehypeRestoreMathCodeFences() { + return (tree: HastRoot) => { + visit(tree, "element", (node) => { + const { className } = node.properties + + if (node.tagName !== "code" || !(MATH_CODE_FENCE_MARKER in node.properties)) { + return + } + + node.properties.className = [ + ...(Array.isArray(className) ? className : []), + MATH_LANGUAGE_CLASS, + ] + delete node.properties[MATH_CODE_FENCE_MARKER] + }) + } +} diff --git a/src/lib/markdown/plugins/sanitize.ts b/src/lib/markdown/plugins/sanitize.ts new file mode 100644 index 0000000..4643c83 --- /dev/null +++ b/src/lib/markdown/plugins/sanitize.ts @@ -0,0 +1,23 @@ +import { defaultSchema, type Options as SanitizeSchema } from "rehype-sanitize" +import { MERMAID_CLASS_NAME } from "../diagrams" +import { MATH_CODE_FENCE_MARKER } from "./math" + +const mathClassAttribute: [string, string, string] = ["className", "math-inline", "math-display"] +const mathCodeFenceMarkerAttribute: [string] = [MATH_CODE_FENCE_MARKER] +const { code: defaultCodeAttributes = [] } = defaultSchema.attributes ?? {} +const { pre: defaultPreAttributes = [] } = defaultSchema.attributes ?? {} + +/** + * 마크다운 HTML 새니타이즈 스키마 + * KaTeX 수식 클래스, Mermaid 다이어그램 클래스 및 수식 코드 펜스 마커를 허용합니다. + */ +export const markdownSanitizeSchema = { + ...defaultSchema, + attributes: { + ...defaultSchema.attributes, + code: [...defaultCodeAttributes, mathClassAttribute, mathCodeFenceMarkerAttribute], + pre: [...defaultPreAttributes, ["className", MERMAID_CLASS_NAME]], + }, + // raw HTML 이 트리에 없어 clobber 가 지킬 대상이 없고, 접두하면 각주 앵커가 어긋난다. + clobber: [], +} satisfies SanitizeSchema diff --git a/src/lib/markdown/plugins/tables.ts b/src/lib/markdown/plugins/tables.ts new file mode 100644 index 0000000..eeb10d3 --- /dev/null +++ b/src/lib/markdown/plugins/tables.ts @@ -0,0 +1,27 @@ +import type { Root as HastRoot } from "hast" +import { SKIP, visit } from "unist-util-visit" + +export const TABLE_WRAPPER_CLASS_NAME = "table-wrapper" + +/** + * 표에 display:block 을 직접 주면 테이블 시맨틱이 깨지므로, + * 가로 스크롤은 래퍼 div 가 담당하도록 div.table-wrapper 로 감싼다. + */ +export function rehypeWrapTables() { + return (tree: HastRoot) => { + visit(tree, "element", (node, index, parent) => { + if (node.tagName !== "table" || parent === undefined || index === undefined) { + return + } + + parent.children[index] = { + type: "element", + tagName: "div", + properties: { className: [TABLE_WRAPPER_CLASS_NAME] }, + children: [node], + } + + return SKIP + }) + } +} diff --git a/src/lib/markdown/plugins/task-list.ts b/src/lib/markdown/plugins/task-list.ts new file mode 100644 index 0000000..c011c9b --- /dev/null +++ b/src/lib/markdown/plugins/task-list.ts @@ -0,0 +1,18 @@ +import type { Root as HastRoot } from "hast" +import { visit } from "unist-util-visit" + +/** + * remark-gfm 은 할 일 목록을 이름 없는 disabled 체크박스로 내보내어 + * 보조기기가 완료 여부를 읽지 못하므로, aria-label="완료됨" / "완료되지 않음"을 보강한다. + */ +export function rehypeNameTaskListCheckboxes() { + return (tree: HastRoot) => { + visit(tree, "element", (node) => { + if (node.tagName !== "input" || node.properties.type !== "checkbox") { + return + } + + node.properties.ariaLabel = node.properties.checked === true ? "완료됨" : "완료되지 않음" + }) + } +} diff --git a/src/lib/markdown/plugins/toc.ts b/src/lib/markdown/plugins/toc.ts new file mode 100644 index 0000000..fb0f0fe --- /dev/null +++ b/src/lib/markdown/plugins/toc.ts @@ -0,0 +1,48 @@ +import type { Element, Root as HastRoot } from "hast" +import { toString as hastToString } from "hast-util-to-string" +import type { VFile } from "vfile" +import { TOC_HEADING_NAMES } from "./headings" + +export type TableOfContentsItem = Readonly<{ + id: string + level: 2 | 3 + text: string +}> + +export function rehypeCollectTableOfContents() { + return (tree: HastRoot, file: VFile) => { + const items: TableOfContentsItem[] = [] + + for (const node of tree.children) { + if (node.type !== "element") { + continue + } + + const item = toTableOfContentsItem(node) + + if (item !== undefined) { + items.push(item) + } + } + + file.data.tableOfContents = items + } +} + +function toTableOfContentsItem(node: Element): TableOfContentsItem | undefined { + if (!TOC_HEADING_NAMES.has(node.tagName) || typeof node.properties.id !== "string") { + return undefined + } + + const text = hastToString(node).replace(/\s+/g, " ").trim() + + if (text.length === 0) { + return undefined + } + + return { + id: node.properties.id, + level: node.tagName === "h2" ? 2 : 3, + text, + } +} diff --git a/src/lib/markdown/render.ts b/src/lib/markdown/render.ts index 3fc7564..9c56896 100644 --- a/src/lib/markdown/render.ts +++ b/src/lib/markdown/render.ts @@ -1,11 +1,8 @@ -import type { Element, Root as HastRoot, Parents } from "hast" -import { toString as hastToString } from "hast-util-to-string" -import type { Root as MdastRoot } from "mdast" import rehypeAutolinkHeadings from "rehype-autolink-headings" import rehypeExternalLinks from "rehype-external-links" import rehypeKatex from "rehype-katex" import rehypePrettyCode from "rehype-pretty-code" -import rehypeSanitize, { defaultSchema, type Options as SanitizeSchema } from "rehype-sanitize" +import rehypeSanitize from "rehype-sanitize" import rehypeSlug from "rehype-slug" import rehypeStringify from "rehype-stringify" import remarkGfm from "remark-gfm" @@ -13,16 +10,23 @@ import remarkMath from "remark-math" import remarkParse from "remark-parse" import remarkRehype from "remark-rehype" import { unified } from "unified" -import { SKIP, visit } from "unist-util-visit" import { VFile } from "vfile" -import { MERMAID_CLASS_NAME, rehypePrepareDiagrams, remarkDetectDiagrams } from "./diagrams" - -const HEADING_NAMES = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]) -const TOC_HEADING_NAMES = new Set(["h2", "h3"]) -const EMPTY_GITHUB_SLUG_PATTERN = /^-\d+$/ -const MATH_LANGUAGE_CLASS = "language-math" -const MATH_CODE_FENCE_MARKER = "dataMathCodeFence" -export const TABLE_WRAPPER_CLASS_NAME = "table-wrapper" +import { rehypePrepareDiagrams, remarkDetectDiagrams } from "./diagrams" +import { + isRootTocHeading, + markdownSanitizeSchema, + rehypeCollectTableOfContents, + rehypeNameTaskListCheckboxes, + rehypeNormalizeHeadingIds, + rehypeProtectMathCodeFences, + rehypeRestoreMathCodeFences, + rehypeWrapTables, + remarkDetectMath, + type TableOfContentsItem, +} from "./plugins" + +export type { TableOfContentsItem } from "./plugins" +export { TABLE_WRAPPER_CLASS_NAME } from "./plugins" declare const sanitizedHtmlBrand: unique symbol @@ -30,11 +34,9 @@ export type SanitizedHtml = string & { readonly [sanitizedHtmlBrand]: true } -export type TableOfContentsItem = Readonly<{ - id: string - level: 2 | 3 - text: string -}> +export function toSanitizedHtml(html: string): SanitizedHtml { + return html as SanitizedHtml +} export type RenderedMarkdown = Readonly<{ html: SanitizedHtml @@ -76,50 +78,41 @@ declare module "vfile" { } } -const mathClassAttribute: [string, string, string] = ["className", "math-inline", "math-display"] -const mathCodeFenceMarkerAttribute: [string] = [MATH_CODE_FENCE_MARKER] -const { code: defaultCodeAttributes = [] } = defaultSchema.attributes ?? {} -const { pre: defaultPreAttributes = [] } = defaultSchema.attributes ?? {} -const mathSanitizeSchema = { - ...defaultSchema, - attributes: { - ...defaultSchema.attributes, - code: [...defaultCodeAttributes, mathClassAttribute, mathCodeFenceMarkerAttribute], - pre: [...defaultPreAttributes, ["className", MERMAID_CLASS_NAME]], - }, - // raw HTML 이 트리에 없어 clobber 가 지킬 대상이 없고, 접두하면 각주 앵커가 어긋난다. - clobber: [], -} satisfies SanitizeSchema +export function createMarkdownProcessor() { + return ( + unified() + .use(remarkParse) + .use(remarkGfm) + .use(remarkMath) + .use(remarkDetectMath) + .use(remarkDetectDiagrams) + .use(remarkRehype) + .use(rehypeSlug) + .use(rehypeNormalizeHeadingIds) + .use(rehypeProtectMathCodeFences) + .use(rehypePrepareDiagrams) + // 신뢰 경계: 아래 플러그인의 출력은 다시 검사되지 않는다. + .use(rehypeSanitize, markdownSanitizeSchema) + .use(rehypeCollectTableOfContents) + .use(rehypeNameTaskListCheckboxes) + .use(rehypeWrapTables) + .use(rehypeAutolinkHeadings, { behavior: "wrap", test: isRootTocHeading }) + .use(rehypeExternalLinks, { rel: ["noopener", "noreferrer", "external"] }) + .use(rehypeKatex, { output: "htmlAndMathml", trust: false }) + .use(rehypeRestoreMathCodeFences) + .use(rehypePrettyCode, { + bypassInlineCode: true, + keepBackground: false, + theme: { + dark: "github-dark-dimmed", + light: "github-light", + }, + }) + .use(rehypeStringify) + ) +} -const markdownProcessor = unified() - .use(remarkParse) - .use(remarkGfm) - .use(remarkMath) - .use(remarkDetectMath) - .use(remarkDetectDiagrams) - .use(remarkRehype) - .use(rehypeSlug) - .use(rehypeNormalizeHeadingIds) - .use(rehypeProtectMathCodeFences) - .use(rehypePrepareDiagrams) - // 신뢰 경계: 아래 플러그인의 출력은 다시 검사되지 않는다. - .use(rehypeSanitize, mathSanitizeSchema) - .use(rehypeCollectTableOfContents) - .use(rehypeNameTaskListCheckboxes) - .use(rehypeWrapTables) - .use(rehypeAutolinkHeadings, { behavior: "wrap", test: isRootTocHeading }) - .use(rehypeExternalLinks, { rel: ["noopener", "noreferrer", "external"] }) - .use(rehypeKatex, { output: "htmlAndMathml", trust: false }) - .use(rehypeRestoreMathCodeFences) - .use(rehypePrettyCode, { - bypassInlineCode: true, - keepBackground: false, - theme: { - dark: "github-dark-dimmed", - light: "github-light", - }, - }) - .use(rehypeStringify) +const markdownProcessor = createMarkdownProcessor() export async function renderMarkdown( markdown: string, @@ -151,180 +144,9 @@ export async function renderMarkdown( } return { - html: String(renderedFile) as SanitizedHtml, + html: toSanitizedHtml(String(renderedFile)), hasDiagram: renderedFile.data.hasDiagram ?? false, hasMath: renderedFile.data.hasMath ?? false, toc: renderedFile.data.tableOfContents ?? [], } } - -function remarkDetectMath() { - return (tree: MdastRoot, file: VFile) => { - let detectedMath = false - - visit(tree, (node) => { - if (node.type === "math" || node.type === "inlineMath") { - detectedMath = true - } - }) - - file.data.hasMath = detectedMath - } -} - -function rehypeProtectMathCodeFences() { - return (tree: HastRoot) => { - visit(tree, "element", (node) => { - const classNames = node.properties.className - - if ( - node.tagName !== "code" || - !Array.isArray(classNames) || - !classNames.includes(MATH_LANGUAGE_CLASS) || - classNames.includes("math-inline") || - classNames.includes("math-display") - ) { - return - } - - node.properties.className = classNames.filter( - (className) => className !== MATH_LANGUAGE_CLASS, - ) - node.properties[MATH_CODE_FENCE_MARKER] = "" - }) - } -} - -function rehypeRestoreMathCodeFences() { - return (tree: HastRoot) => { - visit(tree, "element", (node) => { - const { className } = node.properties - - if (node.tagName !== "code" || !(MATH_CODE_FENCE_MARKER in node.properties)) { - return - } - - node.properties.className = [ - ...(Array.isArray(className) ? className : []), - MATH_LANGUAGE_CLASS, - ] - delete node.properties[MATH_CODE_FENCE_MARKER] - }) - } -} - -// 표에 display:block 을 직접 주면 테이블 시맨틱이 깨지므로, 가로 스크롤은 래퍼 div 가 담당한다. -function rehypeWrapTables() { - return (tree: HastRoot) => { - visit(tree, "element", (node, index, parent) => { - if (node.tagName !== "table" || parent === undefined || index === undefined) { - return - } - - parent.children[index] = { - type: "element", - tagName: "div", - properties: { className: [TABLE_WRAPPER_CLASS_NAME] }, - children: [node], - } - - return SKIP - }) - } -} - -// remark-gfm 은 할 일 목록을 이름 없는 disabled 체크박스로 내보내, 보조기기가 완료 여부를 읽지 못한다. -function rehypeNameTaskListCheckboxes() { - return (tree: HastRoot) => { - visit(tree, "element", (node) => { - if (node.tagName !== "input" || node.properties.type !== "checkbox") { - return - } - - node.properties.ariaLabel = node.properties.checked === true ? "완료됨" : "완료되지 않음" - }) - } -} - -function rehypeCollectTableOfContents() { - return (tree: HastRoot, file: VFile) => { - const items: TableOfContentsItem[] = [] - - for (const node of tree.children) { - if (node.type !== "element") { - continue - } - - const item = toTableOfContentsItem(node) - - if (item !== undefined) { - items.push(item) - } - } - - file.data.tableOfContents = items - } -} - -function rehypeNormalizeHeadingIds() { - return (tree: HastRoot) => { - const usedIds = new Set() - const headingsWithoutTextSlugs: Element[] = [] - - visit(tree, "element", (node) => { - const id = node.properties.id - - if (!HEADING_NAMES.has(node.tagName) || typeof id !== "string") { - return - } - - if (id.length === 0 || EMPTY_GITHUB_SLUG_PATTERN.test(id)) { - headingsWithoutTextSlugs.push(node) - return - } - - usedIds.add(id) - }) - - for (const heading of headingsWithoutTextSlugs) { - heading.properties.id = createFallbackHeadingId(usedIds) - } - } -} - -function createFallbackHeadingId(usedIds: Set): string { - const baseId = "section" - let suffix = 0 - let id = baseId - - while (usedIds.has(id)) { - suffix += 1 - id = `${baseId}-${suffix}` - } - - usedIds.add(id) - - return id -} - -function isRootTocHeading(element: Element, _index?: number, parent?: Parents): boolean { - return parent?.type === "root" && TOC_HEADING_NAMES.has(element.tagName) -} - -function toTableOfContentsItem(node: Element): TableOfContentsItem | undefined { - if (!TOC_HEADING_NAMES.has(node.tagName) || typeof node.properties.id !== "string") { - return undefined - } - - const text = hastToString(node).replace(/\s+/g, " ").trim() - - if (text.length === 0) { - return undefined - } - - return { - id: node.properties.id, - level: node.tagName === "h2" ? 2 : 3, - text, - } -} diff --git a/tests/markdown-plugins.test.ts b/tests/markdown-plugins.test.ts new file mode 100644 index 0000000..579fb8f --- /dev/null +++ b/tests/markdown-plugins.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest" +import { + isRootTocHeading, + rehypeNormalizeHeadingIds, + rehypeWrapTables, + TABLE_WRAPPER_CLASS_NAME, +} from "../src/lib/markdown/plugins" + +describe("markdown custom plugins unit tests", () => { + describe("tables plugin", () => { + it("exports TABLE_WRAPPER_CLASS_NAME constant", () => { + expect(TABLE_WRAPPER_CLASS_NAME).toBe("table-wrapper") + expect(typeof rehypeWrapTables).toBe("function") + }) + }) + + describe("headings plugin", () => { + it("isRootTocHeading identifies h2 and h3 under root parent", () => { + const rootParent = { type: "root", children: [] } as const + const nonRootParent = { type: "element", tagName: "blockquote", children: [] } as const + + expect( + isRootTocHeading( + { type: "element", tagName: "h2", properties: {}, children: [] }, + 0, + rootParent as never, + ), + ).toBe(true) + + expect( + isRootTocHeading( + { type: "element", tagName: "h3", properties: {}, children: [] }, + 0, + rootParent as never, + ), + ).toBe(true) + + expect( + isRootTocHeading( + { type: "element", tagName: "h4", properties: {}, children: [] }, + 0, + rootParent as never, + ), + ).toBe(false) + + expect( + isRootTocHeading( + { type: "element", tagName: "h2", properties: {}, children: [] }, + 0, + nonRootParent as never, + ), + ).toBe(false) + }) + + it("rehypeNormalizeHeadingIds provides fallback ids for empty or symbol-only headings", () => { + const tree = { + type: "root", + children: [ + { + type: "element", + tagName: "h2", + properties: { id: "-1" }, + children: [], + }, + ], + } as const + + const transform = rehypeNormalizeHeadingIds() + transform(tree as never) + + expect(tree.children[0]?.properties.id).toBe("section") + }) + }) +})