Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/lib/markdown/plugins/headings.ts
Original file line number Diff line number Diff line change
@@ -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<string>()
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>): string {
const baseId = "section"
let suffix = 0
let id = baseId

while (usedIds.has(id)) {
suffix += 1
id = `${baseId}-${suffix}`
}

usedIds.add(id)

return id
}
17 changes: 17 additions & 0 deletions src/lib/markdown/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -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"
62 changes: 62 additions & 0 deletions src/lib/markdown/plugins/math.ts
Original file line number Diff line number Diff line change
@@ -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]
})
}
}
23 changes: 23 additions & 0 deletions src/lib/markdown/plugins/sanitize.ts
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions src/lib/markdown/plugins/tables.ts
Original file line number Diff line number Diff line change
@@ -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
})
}
}
18 changes: 18 additions & 0 deletions src/lib/markdown/plugins/task-list.ts
Original file line number Diff line number Diff line change
@@ -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 ? "완료됨" : "완료되지 않음"
})
}
}
48 changes: 48 additions & 0 deletions src/lib/markdown/plugins/toc.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
Loading