From 91a30f7ecaac3247f1dbca554d93aa55f063b1dd Mon Sep 17 00:00:00 2001 From: Dongyu Zhao Date: Mon, 21 Jul 2025 10:36:16 +0800 Subject: [PATCH] Separate admonitions from custom containers --- MARKDOWN_PARSER.md | 2 +- .../Builders/MarkdownAdmonitionBuilder.swift | 73 +++++++------------ .../MarkdownCustomContainerBuilder.swift | 71 ++++++++++++++++++ .../Markdown/MarkdownLanguage.swift | 1 + .../MarkdownAllFeaturesBuilderTests.swift | 5 +- .../Builders/MarkdownBlockElementTests.swift | 11 ++- 6 files changed, 113 insertions(+), 50 deletions(-) create mode 100644 Sources/SwiftParser/Markdown/Builders/MarkdownCustomContainerBuilder.swift diff --git a/MARKDOWN_PARSER.md b/MARKDOWN_PARSER.md index 3c230b6..5077df5 100644 --- a/MARKDOWN_PARSER.md +++ b/MARKDOWN_PARSER.md @@ -32,7 +32,7 @@ This document provides an overview of the Markdown parser built on top of the Sw ### Other Extensions - ✅ **Definition lists**: term/definition pairs -- ✅ **Admonitions**: note/warning/info blocks using `:::` +- ✅ **Admonitions**: note/warning/info blocks using `> [!NOTE]` style - ✅ **Custom containers**: generic container syntax (`:::`) ### Advanced List Features diff --git a/Sources/SwiftParser/Markdown/Builders/MarkdownAdmonitionBuilder.swift b/Sources/SwiftParser/Markdown/Builders/MarkdownAdmonitionBuilder.swift index 0733a40..92054c9 100644 --- a/Sources/SwiftParser/Markdown/Builders/MarkdownAdmonitionBuilder.swift +++ b/Sources/SwiftParser/Markdown/Builders/MarkdownAdmonitionBuilder.swift @@ -4,60 +4,43 @@ public class MarkdownAdmonitionBuilder: CodeNodeBuilder { public init() {} public func build(from context: inout CodeContext) -> Bool { - guard context.consuming + 2 < context.tokens.count, + guard context.consuming < 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 + let gt = context.tokens[context.consuming] as? MarkdownToken, + gt.element == .gt else { return false } + var idx = context.consuming + 1 + if idx < context.tokens.count, + let space = context.tokens[idx] as? MarkdownToken, + space.element == .space { idx += 1 } - name = name.trimmingCharacters(in: .whitespaces) + guard idx + 3 < context.tokens.count, + let lb = context.tokens[idx] as? MarkdownToken, lb.element == .leftBracket, + let ex = context.tokens[idx+1] as? MarkdownToken, ex.element == .exclamation, + let text = context.tokens[idx+2] as? MarkdownToken, text.element == .text, + let rb = context.tokens[idx+3] as? MarkdownToken, rb.element == .rightBracket else { return false } + let kind = text.text.lowercased() + idx += 4 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 - } + guard idx < context.tokens.count, + isStartOfLine(index: idx, tokens: context.tokens), + let gt2 = context.tokens[idx] as? MarkdownToken, + gt2.element == .gt else { return false } + idx += 1 + if idx < context.tokens.count, + let sp = context.tokens[idx] as? MarkdownToken, + sp.element == .space { 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 - } + let children = MarkdownInlineParser.parseInline(&context) + let node = AdmonitionNode(kind: kind) + for c in children { node.append(c) } context.current.append(node) + if context.consuming < context.tokens.count, + let nl2 = context.tokens[context.consuming] as? MarkdownToken, + nl2.element == .newline { context.consuming += 1 } return true } diff --git a/Sources/SwiftParser/Markdown/Builders/MarkdownCustomContainerBuilder.swift b/Sources/SwiftParser/Markdown/Builders/MarkdownCustomContainerBuilder.swift new file mode 100644 index 0000000..b7e9dba --- /dev/null +++ b/Sources/SwiftParser/Markdown/Builders/MarkdownCustomContainerBuilder.swift @@ -0,0 +1,71 @@ +import Foundation + +public class MarkdownCustomContainerBuilder: 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 container = CustomContainerNode(name: name) + for c in children { container.append(c) } + context.current.append(container) + 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/MarkdownLanguage.swift b/Sources/SwiftParser/Markdown/MarkdownLanguage.swift index d092da7..48e6229 100644 --- a/Sources/SwiftParser/Markdown/MarkdownLanguage.swift +++ b/Sources/SwiftParser/Markdown/MarkdownLanguage.swift @@ -21,6 +21,7 @@ public class MarkdownLanguage: CodeLanguage { MarkdownHTMLBlockBuilder(), MarkdownDefinitionListBuilder(), MarkdownAdmonitionBuilder(), + MarkdownCustomContainerBuilder(), MarkdownTableBuilder(), MarkdownListBuilder(), MarkdownBlockquoteBuilder(), diff --git a/Tests/SwiftParserTests/Markdown/Builders/MarkdownAllFeaturesBuilderTests.swift b/Tests/SwiftParserTests/Markdown/Builders/MarkdownAllFeaturesBuilderTests.swift index cb3721d..d7474e0 100644 --- a/Tests/SwiftParserTests/Markdown/Builders/MarkdownAllFeaturesBuilderTests.swift +++ b/Tests/SwiftParserTests/Markdown/Builders/MarkdownAllFeaturesBuilderTests.swift @@ -18,9 +18,8 @@ final class MarkdownAllFeaturesBuilderTests: XCTestCase { This paragraph has *italic*, **bold**, ~~strike~~, and `code` with a $x+1$ formula. -::: note -Admonition content -::: +> [!NOTE] +> Admonition content ::: custom Custom container diff --git a/Tests/SwiftParserTests/Markdown/Builders/MarkdownBlockElementTests.swift b/Tests/SwiftParserTests/Markdown/Builders/MarkdownBlockElementTests.swift index 5c3705d..58d8e51 100644 --- a/Tests/SwiftParserTests/Markdown/Builders/MarkdownBlockElementTests.swift +++ b/Tests/SwiftParserTests/Markdown/Builders/MarkdownBlockElementTests.swift @@ -73,11 +73,20 @@ final class MarkdownBlockElementTests: XCTestCase { } func testAdmonitionBlock() { - let input = "::: note\nhello\n:::" + let input = "> [!NOTE]\n> hello" 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) } + + func testCustomContainerBlock() { + let input = "::: custom\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 CustomContainerNode) + } }