diff --git a/MARKDOWN_PARSER.md b/MARKDOWN_PARSER.md index d0375d2..2224024 100644 --- a/MARKDOWN_PARSER.md +++ b/MARKDOWN_PARSER.md @@ -12,12 +12,12 @@ This document provides an overview of the Markdown parser built on top of the Sw - ✅ Fenced code blocks (```code```) - ✅ Block quotes (> quote) with multi-line merging - ✅ Lists (ordered and unordered) with automatic numbering -- ✅ Task lists (- [ ] unchecked, - [x] checked) – GFM extension - ✅ Links ([text](URL) and reference style) - ✅ Images (![alt](URL)) - ✅ Autolinks () - ✅ Horizontal rules (---) - ✅ HTML inline elements +- ✅ HTML block elements - ✅ Line break handling ### GitHub Flavored Markdown (GFM) Extensions @@ -28,6 +28,12 @@ This document provides an overview of the Markdown parser built on top of the Sw ### Academic Extensions - ✅ **Footnotes**: Definition and reference support ([^1]: footnote, [^1]) - ✅ **Citations**: Academic citation support ([@author2023]: reference, [@author2023]) +- ✅ **Math formulas**: inline ($math$) and block ($$math$$) + +### Other Extensions +- ✅ **Definition lists**: term/definition pairs +- ✅ **Admonitions**: note/warning/info blocks using `:::` +- ✅ **Custom containers**: generic container syntax (`:::`) ### Advanced List Features - ✅ **Unordered lists**: supports `-`, `*`, `+` markers @@ -657,11 +663,10 @@ When reporting bugs, include: ## Future Roadmap ### Planned Features -- [ ] **Math Support**: LaTeX-style math expressions (`$inline$`, `$$block$$`) -- [ ] **Definition Lists**: Support for definition list syntax -- [ ] **Admonitions**: Support for warning/info/note blocks +- [x] **Definition Lists**: Support for definition list syntax +- [x] **Admonitions**: Support for warning/info/note blocks - [ ] **Mermaid Diagrams**: Inline diagram support -- [ ] **Custom Containers**: Generic container syntax (:::) +- [x] **Custom Containers**: Generic container syntax (:::) - [ ] **Syntax Highlighting**: Code block syntax highlighting - [ ] **Export Formats**: HTML, PDF, and other output formats @@ -690,4 +695,4 @@ This project is licensed under the MIT License - see the LICENSE file for detail --- -*Last updated: 2025-07-18* +*Last updated: 2025-07-20* diff --git a/Sources/SwiftParser/Markdown/Builders/MarkdownAdmonitionBuilder.swift b/Sources/SwiftParser/Markdown/Builders/MarkdownAdmonitionBuilder.swift new file mode 100644 index 0000000..0733a40 --- /dev/null +++ b/Sources/SwiftParser/Markdown/Builders/MarkdownAdmonitionBuilder.swift @@ -0,0 +1,80 @@ +import Foundation + +public class MarkdownAdmonitionBuilder: CodeNodeBuilder { + public init() {} + + public func build(from context: inout CodeContext) -> Bool { + guard context.consuming + 2 < context.tokens.count, + isStartOfLine(context), + let c1 = context.tokens[context.consuming] as? MarkdownToken, + let c2 = context.tokens[context.consuming + 1] as? MarkdownToken, + let c3 = context.tokens[context.consuming + 2] as? MarkdownToken, + c1.element == .colon, c2.element == .colon, c3.element == .colon else { return false } + var idx = context.consuming + 3 + var name = "" + while idx < context.tokens.count, + let t = context.tokens[idx] as? MarkdownToken, + t.element != .newline { + name += t.text + idx += 1 + } + name = name.trimmingCharacters(in: .whitespaces) + guard idx < context.tokens.count, + let nl = context.tokens[idx] as? MarkdownToken, + nl.element == .newline else { return false } + idx += 1 + var innerTokens: [any CodeToken] = [] + while idx < context.tokens.count { + if isStartOfLine(index: idx, tokens: context.tokens), + idx + 2 < context.tokens.count, + let e1 = context.tokens[idx] as? MarkdownToken, + let e2 = context.tokens[idx + 1] as? MarkdownToken, + let e3 = context.tokens[idx + 2] as? MarkdownToken, + e1.element == .colon, e2.element == .colon, e3.element == .colon { + idx += 3 + while idx < context.tokens.count, + let t = context.tokens[idx] as? MarkdownToken, + t.element != .newline { idx += 1 } + if idx < context.tokens.count, + let nl2 = context.tokens[idx] as? MarkdownToken, + nl2.element == .newline { idx += 1 } + break + } + innerTokens.append(context.tokens[idx]) + idx += 1 + } + context.consuming = idx + var subContext = CodeContext(current: DocumentNode(), tokens: innerTokens) + let children = MarkdownInlineParser.parseInline(&subContext) + let lower = name.lowercased() + let node: MarkdownNodeBase + if ["note", "warning", "info"].contains(lower) { + let admon = AdmonitionNode(kind: lower) + for c in children { admon.append(c) } + node = admon + } else { + let container = CustomContainerNode(name: name) + for c in children { container.append(c) } + node = container + } + context.current.append(node) + return true + } + + private func isStartOfLine(_ context: CodeContext) -> Bool { + if context.consuming == 0 { return true } + if let prev = context.tokens[context.consuming - 1] as? MarkdownToken { + return prev.element == .newline + } + return false + } + + private func isStartOfLine(index: Int, tokens: [any CodeToken]) -> Bool { + if index == 0 { return true } + if index - 1 < tokens.count, + let prev = tokens[index - 1] as? MarkdownToken { + return prev.element == .newline + } + return false + } +} diff --git a/Sources/SwiftParser/Markdown/Builders/MarkdownDefinitionListBuilder.swift b/Sources/SwiftParser/Markdown/Builders/MarkdownDefinitionListBuilder.swift new file mode 100644 index 0000000..07159c0 --- /dev/null +++ b/Sources/SwiftParser/Markdown/Builders/MarkdownDefinitionListBuilder.swift @@ -0,0 +1,84 @@ +import Foundation + +public class MarkdownDefinitionListBuilder: CodeNodeBuilder { + public init() {} + + public func build(from context: inout CodeContext) -> Bool { + guard context.consuming < context.tokens.count, + isStartOfLine(context) else { return false } + let state = context.state as? MarkdownContextState ?? MarkdownContextState() + if context.state == nil { context.state = state } + + var idx = context.consuming + var termTokens: [any CodeToken] = [] + while idx < context.tokens.count, + let t = context.tokens[idx] as? MarkdownToken, + t.element != .newline { + termTokens.append(t) + idx += 1 + } + guard idx < context.tokens.count, + let _ = context.tokens[idx] as? MarkdownToken, + (context.tokens[idx] as! MarkdownToken).element == .newline else { + state.currentDefinitionList = nil + return false + } + idx += 1 + guard idx < context.tokens.count, + let colon = context.tokens[idx] as? MarkdownToken, + colon.element == .colon else { + state.currentDefinitionList = nil + return false + } + idx += 1 + if idx < context.tokens.count, + let sp = context.tokens[idx] as? MarkdownToken, + sp.element == .space { + idx += 1 + } + var defTokens: [any CodeToken] = [] + while idx < context.tokens.count, + let t = context.tokens[idx] as? MarkdownToken, + t.element != .newline { + defTokens.append(t) + idx += 1 + } + context.consuming = idx + if idx < context.tokens.count, + let nl = context.tokens[idx] as? MarkdownToken, + nl.element == .newline { + context.consuming += 1 + } + + var termContext = CodeContext(current: DocumentNode(), tokens: termTokens) + let termChildren = MarkdownInlineParser.parseInline(&termContext) + var defContext = CodeContext(current: DocumentNode(), tokens: defTokens) + let defChildren = MarkdownInlineParser.parseInline(&defContext) + + let item = DefinitionItemNode() + let termNode = DefinitionTermNode() + for c in termChildren { termNode.append(c) } + let descNode = DefinitionDescriptionNode() + for c in defChildren { descNode.append(c) } + item.append(termNode) + item.append(descNode) + + if let list = state.currentDefinitionList { + list.append(item) + } else { + let list = DefinitionListNode() + list.append(item) + context.current.append(list) + state.currentDefinitionList = list + } + return true + } + + private func isStartOfLine(_ context: CodeContext) -> Bool { + if context.consuming == 0 { return true } + if let prev = context.tokens[context.consuming - 1] as? MarkdownToken { + return prev.element == .newline + } + return false + } +} diff --git a/Sources/SwiftParser/Markdown/Builders/MarkdownFencedCodeBuilder.swift b/Sources/SwiftParser/Markdown/Builders/MarkdownFencedCodeBuilder.swift new file mode 100644 index 0000000..7835cde --- /dev/null +++ b/Sources/SwiftParser/Markdown/Builders/MarkdownFencedCodeBuilder.swift @@ -0,0 +1,40 @@ +import Foundation + +public class MarkdownFencedCodeBuilder: CodeNodeBuilder { + public init() {} + + public func build(from context: inout CodeContext) -> Bool { + guard context.consuming < context.tokens.count, + let token = context.tokens[context.consuming] as? MarkdownToken, + token.element == .fencedCodeBlock, + isStartOfLine(context) else { return false } + context.consuming += 1 + let code = trimFence(token.text) + let node = CodeBlockNode(source: code, language: nil) + context.current.append(node) + if context.consuming < context.tokens.count, + let nl = context.tokens[context.consuming] as? MarkdownToken, + nl.element == .newline { + context.consuming += 1 + } + return true + } + + private func trimFence(_ text: String) -> String { + var lines = text.split(separator: "\n") + guard lines.count >= 2 else { return text } + lines.removeFirst() + if let last = lines.last, last.starts(with: "```") { + lines.removeLast() + } + return lines.joined(separator: "\n") + } + + private func isStartOfLine(_ context: CodeContext) -> Bool { + if context.consuming == 0 { return true } + if let prev = context.tokens[context.consuming - 1] as? MarkdownToken { + return prev.element == .newline + } + return false + } +} diff --git a/Sources/SwiftParser/Markdown/Builders/MarkdownFormulaBlockBuilder.swift b/Sources/SwiftParser/Markdown/Builders/MarkdownFormulaBlockBuilder.swift new file mode 100644 index 0000000..1d7ccdb --- /dev/null +++ b/Sources/SwiftParser/Markdown/Builders/MarkdownFormulaBlockBuilder.swift @@ -0,0 +1,30 @@ +import Foundation + +public class MarkdownFormulaBlockBuilder: CodeNodeBuilder { + public init() {} + + public func build(from context: inout CodeContext) -> Bool { + guard context.consuming < context.tokens.count, + let token = context.tokens[context.consuming] as? MarkdownToken, + token.element == .formulaBlock else { return false } + context.consuming += 1 + let expr = trimFormula(token.text) + let node = FormulaBlockNode(expression: expr) + context.current.append(node) + if context.consuming < context.tokens.count, + let nl = context.tokens[context.consuming] as? MarkdownToken, + nl.element == .newline { + context.consuming += 1 + } + return true + } + + private func trimFormula(_ text: String) -> String { + var t = text + if t.hasPrefix("$$") { t.removeFirst(2) } + if t.hasSuffix("$$") { t.removeLast(2) } + if t.hasPrefix("\\[") { t.removeFirst(2) } + if t.hasSuffix("\\]") { t.removeLast(2) } + return t.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/SwiftParser/Markdown/Builders/MarkdownHTMLBlockBuilder.swift b/Sources/SwiftParser/Markdown/Builders/MarkdownHTMLBlockBuilder.swift new file mode 100644 index 0000000..55721f9 --- /dev/null +++ b/Sources/SwiftParser/Markdown/Builders/MarkdownHTMLBlockBuilder.swift @@ -0,0 +1,20 @@ +import Foundation + +public class MarkdownHTMLBlockBuilder: CodeNodeBuilder { + public init() {} + + public func build(from context: inout CodeContext) -> Bool { + guard context.consuming < context.tokens.count, + let token = context.tokens[context.consuming] as? MarkdownToken, + (token.element == .htmlBlock || token.element == .htmlUnclosedBlock) else { return false } + context.consuming += 1 + let node = HTMLBlockNode(name: "", content: token.text) + context.current.append(node) + if context.consuming < context.tokens.count, + let nl = context.tokens[context.consuming] as? MarkdownToken, + nl.element == .newline { + context.consuming += 1 + } + return true + } +} diff --git a/Sources/SwiftParser/Markdown/Builders/MarkdownInlineParser.swift b/Sources/SwiftParser/Markdown/Builders/MarkdownInlineParser.swift index 9be8392..2d06be6 100644 --- a/Sources/SwiftParser/Markdown/Builders/MarkdownInlineParser.swift +++ b/Sources/SwiftParser/Markdown/Builders/MarkdownInlineParser.swift @@ -13,7 +13,7 @@ struct MarkdownInlineParser { if stopAt.contains(token.element) { break } switch token.element { - case .asterisk, .underscore: + case .asterisk, .underscore, .tilde: let marker = token.element var count = 0 while context.consuming < context.tokens.count, @@ -22,7 +22,12 @@ struct MarkdownInlineParser { count += 1 context.consuming += 1 } - handleDelimiter(marker: marker, count: count, nodes: &nodes, stack: &delimiters) + if marker == .tilde && count < 2 { + let text = String(repeating: "~", count: count) + nodes.append(TextNode(content: text)) + } else { + handleDelimiter(marker: marker, count: count, nodes: &nodes, stack: &delimiters) + } case .inlineCode: nodes.append(InlineCodeNode(code: trimBackticks(token.text))) context.consuming += 1 @@ -90,7 +95,14 @@ struct MarkdownInlineParser { while remaining > 0, let openIdx = stack.lastIndex(where: { $0.marker == marker }) { let open = stack.remove(at: openIdx) - let closeCount = min(open.count, remaining) + var closeCount = min(open.count, remaining) + if marker == .tilde { + guard open.count >= 2 && remaining >= 2 else { + stack.append(open) + break + } + closeCount = 2 + } let start = open.index + 1 let removedCount = nodes.count - open.index @@ -102,7 +114,12 @@ struct MarkdownInlineParser { } } - let node: MarkdownNodeBase = (closeCount >= 2) ? StrongNode(content: "") : EmphasisNode(content: "") + let node: MarkdownNodeBase + if marker == .tilde { + node = StrikeNode(content: "") + } else { + node = (closeCount >= 2) ? StrongNode(content: "") : EmphasisNode(content: "") + } for child in content { node.append(child) } nodes.append(node) @@ -119,7 +136,7 @@ struct MarkdownInlineParser { private static func parseLinkOrFootnote(_ context: inout CodeContext) -> MarkdownNodeBase? { let start = context.consuming context.consuming += 1 - // Footnote reference [^id] + // Footnote reference [^id] or citation [@id] if context.consuming < context.tokens.count, let caret = context.tokens[context.consuming] as? MarkdownToken, caret.element == .caret { @@ -136,6 +153,22 @@ struct MarkdownInlineParser { rb.element == .rightBracket else { context.consuming = start; return nil } context.consuming += 1 return FootnoteNode(identifier: ident, content: "", referenceText: nil, range: rb.range) + } else if context.consuming < context.tokens.count, + let at = context.tokens[context.consuming] as? MarkdownToken, + at.element == .text, at.text == "@" { + context.consuming += 1 + var ident = "" + while context.consuming < context.tokens.count, + let t = context.tokens[context.consuming] as? MarkdownToken, + t.element != .rightBracket { + ident += t.text + context.consuming += 1 + } + guard context.consuming < context.tokens.count, + let rb = context.tokens[context.consuming] as? MarkdownToken, + rb.element == .rightBracket else { context.consuming = start; return nil } + context.consuming += 1 + return CitationReferenceNode(identifier: ident) } let textNodes = parseInline(&context, stopAt: [.rightBracket]) diff --git a/Sources/SwiftParser/Markdown/Builders/MarkdownListBuilder.swift b/Sources/SwiftParser/Markdown/Builders/MarkdownListBuilder.swift new file mode 100644 index 0000000..6d2f713 --- /dev/null +++ b/Sources/SwiftParser/Markdown/Builders/MarkdownListBuilder.swift @@ -0,0 +1,99 @@ +import Foundation + +public class MarkdownListBuilder: CodeNodeBuilder { + public init() {} + + public func build(from context: inout CodeContext) -> Bool { + guard context.consuming < context.tokens.count else { return false } + let state = context.state as? MarkdownContextState ?? MarkdownContextState() + if context.state == nil { context.state = state } + + var idx = context.consuming + var indent = 0 + while idx < context.tokens.count, + let sp = context.tokens[idx] as? MarkdownToken, + sp.element == .space { + indent += 1 + idx += 1 + } + guard idx < context.tokens.count, + let marker = context.tokens[idx] as? MarkdownToken else { return false } + + var listType: MarkdownNodeElement? + var markerText = marker.text + var startNum = 1 + if marker.element == .dash || marker.element == .plus || marker.element == .asterisk { + listType = .unorderedList + idx += 1 + } else if marker.element == .number { + if idx + 1 < context.tokens.count, + let dot = context.tokens[idx + 1] as? MarkdownToken, + dot.element == .dot { + listType = .orderedList + startNum = Int(marker.text) ?? 1 + markerText += dot.text + idx += 2 + } + } + guard let type = listType else { return false } + if idx < context.tokens.count, + let sp = context.tokens[idx] as? MarkdownToken, + sp.element == .space { idx += 1 } else { return false } + + context.consuming = idx + + while let last = state.listStack.last, last.level > indent { + state.listStack.removeLast() + context.current = last.parent ?? context.current + } + + var listNode: ListNode + if let last = state.listStack.last, last.level == indent, last.element == type { + listNode = last + } else { + if type == .unorderedList { + listNode = UnorderedListNode(level: indent) + } else { + listNode = OrderedListNode(start: startNum, level: indent) + } + context.current.append(listNode) + state.listStack.append(listNode) + } + context.current = listNode + + var isTask = false + var checked = false + if context.consuming + 2 < context.tokens.count, + let lb = context.tokens[context.consuming] as? MarkdownToken, + lb.element == .leftBracket, + let status = context.tokens[context.consuming + 1] as? MarkdownToken, + let rb = context.tokens[context.consuming + 2] as? MarkdownToken, + rb.element == .rightBracket { + isTask = true + if status.element == .text && status.text.lowercased() == "x" { + checked = true + } + context.consuming += 3 + if context.consuming < context.tokens.count, + let sp = context.tokens[context.consuming] as? MarkdownToken, + sp.element == .space { context.consuming += 1 } + } + + let item: MarkdownNodeBase + if isTask { + item = TaskListItemNode(checked: checked) + } else { + item = ListItemNode(marker: markerText) + } + let children = MarkdownInlineParser.parseInline(&context) + for child in children { item.append(child) } + listNode.append(item) + + if context.consuming < context.tokens.count, + let nl = context.tokens[context.consuming] as? MarkdownToken, + nl.element == .newline { + context.consuming += 1 + } + return true + } +} diff --git a/Sources/SwiftParser/Markdown/Builders/MarkdownReferenceDefinitionBuilder.swift b/Sources/SwiftParser/Markdown/Builders/MarkdownReferenceDefinitionBuilder.swift index 2856e2f..d42e61c 100644 --- a/Sources/SwiftParser/Markdown/Builders/MarkdownReferenceDefinitionBuilder.swift +++ b/Sources/SwiftParser/Markdown/Builders/MarkdownReferenceDefinitionBuilder.swift @@ -10,11 +10,17 @@ public class MarkdownReferenceDefinitionBuilder: CodeNodeBuilder { lb.element == .leftBracket else { return false } var idx = context.consuming + 1 var isFootnote = false + var isCitation = false if idx < context.tokens.count, let caret = context.tokens[idx] as? MarkdownToken, caret.element == .caret { isFootnote = true idx += 1 + } else if idx < context.tokens.count, + let at = context.tokens[idx] as? MarkdownToken, + at.element == .text, at.text == "@" { + isCitation = true + idx += 1 } var identifier = "" while idx < context.tokens.count, @@ -53,6 +59,9 @@ public class MarkdownReferenceDefinitionBuilder: CodeNodeBuilder { if isFootnote { let node = FootnoteNode(identifier: identifier, content: value, referenceText: nil, range: lb.range) context.current.append(node) + } else if isCitation { + let node = CitationNode(identifier: identifier, content: value) + context.current.append(node) } else { let node = ReferenceNode(identifier: identifier, url: value, title: "") context.current.append(node) diff --git a/Sources/SwiftParser/Markdown/Builders/MarkdownTableBuilder.swift b/Sources/SwiftParser/Markdown/Builders/MarkdownTableBuilder.swift new file mode 100644 index 0000000..409fcb0 --- /dev/null +++ b/Sources/SwiftParser/Markdown/Builders/MarkdownTableBuilder.swift @@ -0,0 +1,55 @@ +import Foundation + +public class MarkdownTableBuilder: CodeNodeBuilder { + public init() {} + + public func build(from context: inout CodeContext) -> Bool { + guard context.consuming < context.tokens.count, + let first = context.tokens[context.consuming] as? MarkdownToken, + first.element == .pipe else { return false } + + let table = TableNode(range: first.range) + context.current.append(table) + + while true { + guard parseRow(into: table, context: &context) else { break } + if context.consuming >= context.tokens.count { break } + guard let next = context.tokens[context.consuming] as? MarkdownToken, + next.element == .pipe else { break } + } + return true + } + + private func parseRow(into table: TableNode, context: inout CodeContext) -> Bool { + guard context.consuming < context.tokens.count, + let start = context.tokens[context.consuming] as? MarkdownToken, + start.element == .pipe else { return false } + var rowTokens: [MarkdownToken] = [] + while context.consuming < context.tokens.count { + guard let tok = context.tokens[context.consuming] as? MarkdownToken else { break } + if tok.element == .newline { break } + rowTokens.append(tok) + context.consuming += 1 + } + if context.consuming < context.tokens.count, + let nl = context.tokens[context.consuming] as? MarkdownToken, + nl.element == .newline { context.consuming += 1 } + + let row = TableRowNode(range: start.range) + var cellTokens: [MarkdownToken] = [] + for tok in rowTokens + [MarkdownToken.pipe(at: start.range)] { + if tok.element == .pipe { + let cell = TableCellNode(range: start.range) + var subCtx = CodeContext(current: cell, tokens: cellTokens, state: context.state) + let children = MarkdownInlineParser.parseInline(&subCtx, stopAt: []) + for child in children { cell.append(child) } + row.append(cell) + cellTokens.removeAll() + } else { + cellTokens.append(tok) + } + } + table.append(row) + return true + } +} diff --git a/Sources/SwiftParser/Markdown/Builders/MarkdownThematicBreakBuilder.swift b/Sources/SwiftParser/Markdown/Builders/MarkdownThematicBreakBuilder.swift new file mode 100644 index 0000000..85a76c7 --- /dev/null +++ b/Sources/SwiftParser/Markdown/Builders/MarkdownThematicBreakBuilder.swift @@ -0,0 +1,49 @@ +import Foundation + +public class MarkdownThematicBreakBuilder: CodeNodeBuilder { + public init() {} + + public func build(from context: inout CodeContext) -> Bool { + guard context.consuming < context.tokens.count, + isStartOfLine(context) else { return false } + var idx = context.consuming + var count = 0 + var char: MarkdownTokenElement? + while idx < context.tokens.count, + let t = context.tokens[idx] as? MarkdownToken { + if t.element == .dash || t.element == .asterisk || t.element == .underscore { + if char == nil { char = t.element } + if t.element == char { + count += 1 + } else { + return false + } + } else if t.element == .space { + // ignore + } else if t.element == .newline || t.element == .eof { + break + } else { + return false + } + idx += 1 + } + guard count >= 3 else { return false } + context.consuming = idx + if idx < context.tokens.count, + let nl = context.tokens[idx] as? MarkdownToken, + nl.element == .newline { + context.consuming += 1 + } + let node = ThematicBreakNode() + context.current.append(node) + return true + } + + private func isStartOfLine(_ context: CodeContext) -> Bool { + if context.consuming == 0 { return true } + if let prev = context.tokens[context.consuming - 1] as? MarkdownToken { + return prev.element == .newline + } + return false + } +} diff --git a/Sources/SwiftParser/Markdown/MarkdownContextState.swift b/Sources/SwiftParser/Markdown/MarkdownContextState.swift index 9216609..0d7f984 100644 --- a/Sources/SwiftParser/Markdown/MarkdownContextState.swift +++ b/Sources/SwiftParser/Markdown/MarkdownContextState.swift @@ -4,5 +4,9 @@ public class MarkdownContextState: CodeContextState { public typealias Node = MarkdownNodeElement public typealias Token = MarkdownTokenElement + /// Stack for nested list processing + public var listStack: [ListNode] = [] + public var currentDefinitionList: DefinitionListNode? + public init() {} } diff --git a/Sources/SwiftParser/Markdown/MarkdownLanguage.swift b/Sources/SwiftParser/Markdown/MarkdownLanguage.swift index 6b62d87..d092da7 100644 --- a/Sources/SwiftParser/Markdown/MarkdownLanguage.swift +++ b/Sources/SwiftParser/Markdown/MarkdownLanguage.swift @@ -15,6 +15,14 @@ public class MarkdownLanguage: CodeLanguage { consumers: [any CodeNodeBuilder] = [ MarkdownReferenceDefinitionBuilder(), MarkdownHeadingBuilder(), + MarkdownThematicBreakBuilder(), + MarkdownFencedCodeBuilder(), + MarkdownFormulaBlockBuilder(), + MarkdownHTMLBlockBuilder(), + MarkdownDefinitionListBuilder(), + MarkdownAdmonitionBuilder(), + MarkdownTableBuilder(), + MarkdownListBuilder(), MarkdownBlockquoteBuilder(), MarkdownParagraphBuilder(), MarkdownNewlineBuilder() diff --git a/Sources/SwiftParser/Markdown/MarkdownNodeElement.swift b/Sources/SwiftParser/Markdown/MarkdownNodeElement.swift index 5370969..896fe99 100644 --- a/Sources/SwiftParser/Markdown/MarkdownNodeElement.swift +++ b/Sources/SwiftParser/Markdown/MarkdownNodeElement.swift @@ -17,6 +17,12 @@ public enum MarkdownNodeElement: String, CaseIterable, CodeNodeElement { case codeBlock = "code_block" case htmlBlock = "html_block" case imageBlock = "image_block" + case definitionList = "definition_list" + case definitionItem = "definition_item" + case definitionTerm = "definition_term" + case definitionDescription = "definition_description" + case admonition = "admonition" + case customContainer = "custom_container" // MARK: - Inline Elements (CommonMark) case text = "text" @@ -41,6 +47,8 @@ public enum MarkdownNodeElement: String, CaseIterable, CodeNodeElement { case taskListItem = "task_list_item" case reference = "reference" case footnote = "footnote" + case citation = "citation" + case citationReference = "citation_reference" // MARK: - Math Elements (LaTeX/TeX) case formula = "formula" diff --git a/Sources/SwiftParser/Markdown/MarkdownNodes.swift b/Sources/SwiftParser/Markdown/MarkdownNodes.swift index 3b525db..77debfc 100644 --- a/Sources/SwiftParser/Markdown/MarkdownNodes.swift +++ b/Sources/SwiftParser/Markdown/MarkdownNodes.swift @@ -193,6 +193,58 @@ public class ImageBlockNode: MarkdownNodeBase { } } +public class DefinitionListNode: MarkdownNodeBase { + public init() { + super.init(element: .definitionList) + } +} + +public class DefinitionItemNode: MarkdownNodeBase { + public init() { + super.init(element: .definitionItem) + } +} + +public class DefinitionTermNode: MarkdownNodeBase { + public init() { + super.init(element: .definitionTerm) + } +} + +public class DefinitionDescriptionNode: MarkdownNodeBase { + public init() { + super.init(element: .definitionDescription) + } +} + +public class AdmonitionNode: MarkdownNodeBase { + public var kind: String + + public init(kind: String) { + self.kind = kind + super.init(element: .admonition) + } + + public override func hash(into hasher: inout Hasher) { + super.hash(into: &hasher) + hasher.combine(kind) + } +} + +public class CustomContainerNode: MarkdownNodeBase { + public var name: String + + public init(name: String) { + self.name = name + super.init(element: .customContainer) + } + + public override func hash(into hasher: inout Hasher) { + super.hash(into: &hasher) + hasher.combine(name) + } +} + // MARK: - Inline Elements public class TextNode: MarkdownNodeBase { public var content: String @@ -410,6 +462,37 @@ public class FootnoteNode: MarkdownNodeBase { } } +public class CitationNode: MarkdownNodeBase { + public var identifier: String + public var content: String + + public init(identifier: String, content: String) { + self.identifier = identifier + self.content = content + super.init(element: .citation) + } + + public override func hash(into hasher: inout Hasher) { + super.hash(into: &hasher) + hasher.combine(identifier) + hasher.combine(content) + } +} + +public class CitationReferenceNode: MarkdownNodeBase { + public var identifier: String + + public init(identifier: String) { + self.identifier = identifier + super.init(element: .citationReference) + } + + public override func hash(into hasher: inout Hasher) { + super.hash(into: &hasher) + hasher.combine(identifier) + } +} + // MARK: - Math Elements public class FormulaNode: MarkdownNodeBase { public var expression: String diff --git a/Tests/SwiftParserTests/Markdown/Consumer/MarkdownBlockElementTests.swift b/Tests/SwiftParserTests/Markdown/Consumer/MarkdownBlockElementTests.swift new file mode 100644 index 0000000..0e59a3d --- /dev/null +++ b/Tests/SwiftParserTests/Markdown/Consumer/MarkdownBlockElementTests.swift @@ -0,0 +1,79 @@ +import XCTest +@testable import SwiftParser + +final class MarkdownBlockElementTests: XCTestCase { + var parser: CodeParser! + var language: MarkdownLanguage! + + override func setUp() { + super.setUp() + language = MarkdownLanguage() + parser = CodeParser(language: language) + } + + func testFencedCodeBlock() { + let input = "```swift\nlet x = 1\n```" + let root = language.root(of: input) + let (node, context) = parser.parse(input, root: root) + XCTAssertTrue(context.errors.isEmpty) + XCTAssertEqual(node.children.count, 1) + XCTAssertTrue(node.children.first is CodeBlockNode) + } + + func testHorizontalRule() { + let input = "---" + let root = language.root(of: input) + let (node, context) = parser.parse(input, root: root) + XCTAssertTrue(context.errors.isEmpty) + XCTAssertEqual(node.children.count, 1) + XCTAssertTrue(node.children.first is ThematicBreakNode) + } + + func testUnorderedList() { + let input = "- item" + let root = language.root(of: input) + let (node, context) = parser.parse(input, root: root) + XCTAssertTrue(context.errors.isEmpty) + XCTAssertEqual(node.children.count, 1) + let list = node.children.first as? UnorderedListNode + XCTAssertNotNil(list) + XCTAssertEqual(list?.children().count, 1) + } + + func testStrikethroughInline() { + let input = "~~strike~~" + let root = language.root(of: input) + let (node, context) = parser.parse(input, root: root) + XCTAssertTrue(context.errors.isEmpty) + guard let para = node.children.first as? ParagraphNode else { return XCTFail("Expected ParagraphNode") } + XCTAssertTrue(para.children.first is StrikeNode) + } + + func testFormulaBlock() { + let input = "$$x=1$$" + let root = language.root(of: input) + let (node, context) = parser.parse(input, root: root) + XCTAssertTrue(context.errors.isEmpty) + XCTAssertTrue(node.children.first is FormulaBlockNode) + } + + func testDefinitionList() { + let input = "Term\n: Definition" + let root = language.root(of: input) + let (node, context) = parser.parse(input, root: root) + XCTAssertTrue(context.errors.isEmpty) + XCTAssertEqual(node.children.count, 1) + let list = node.children.first as? DefinitionListNode + XCTAssertNotNil(list) + XCTAssertEqual(list?.children().count, 1) + } + + func testAdmonitionBlock() { + let input = "::: note\nhello\n:::" + let root = language.root(of: input) + let (node, context) = parser.parse(input, root: root) + XCTAssertTrue(context.errors.isEmpty) + XCTAssertEqual(node.children.count, 1) + XCTAssertTrue(node.children.first is AdmonitionNode) + } +}