diff --git a/Sources/Markdown/Base/Markup.swift b/Sources/Markdown/Base/Markup.swift index 952460ab..bf51fe51 100644 --- a/Sources/Markdown/Base/Markup.swift +++ b/Sources/Markdown/Base/Markup.swift @@ -69,6 +69,8 @@ func makeMarkup(_ data: _MarkupData) -> Markup { return Table.Cell(data) case .symbolLink: return SymbolLink(data) + case .inlineAttributes: + return InlineAttributes(data) } } diff --git a/Sources/Markdown/Base/RawMarkup.swift b/Sources/Markdown/Base/RawMarkup.swift index c9051306..61d12760 100644 --- a/Sources/Markdown/Base/RawMarkup.swift +++ b/Sources/Markdown/Base/RawMarkup.swift @@ -40,6 +40,7 @@ enum RawMarkupData: Equatable { case strong case text(String) case symbolLink(destination: String?) + case inlineAttributes(attributes: String) // Extensions case strikethrough @@ -290,6 +291,10 @@ final class RawMarkup: ManagedBuffer { return .create(data: .symbolLink(destination: destination), parsedRange: parsedRange, children: []) } + static func inlineAttributes(attributes: String, parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup { + return .create(data: .inlineAttributes(attributes: attributes), parsedRange: parsedRange, children: children) + } + // MARK: Extensions static func strikethrough(parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup { diff --git a/Sources/Markdown/Inline Nodes/Inline Containers/InlineAttributes.swift b/Sources/Markdown/Inline Nodes/Inline Containers/InlineAttributes.swift new file mode 100644 index 00000000..5cffe1ff --- /dev/null +++ b/Sources/Markdown/Inline Nodes/Inline Containers/InlineAttributes.swift @@ -0,0 +1,59 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +/// A set of one or more inline attributes. +public struct InlineAttributes: InlineMarkup, InlineContainer { + public var _data: _MarkupData + + init(_ raw: RawMarkup) throws { + guard case .inlineAttributes = raw.data else { + throw RawMarkup.Error.concreteConversionError(from: raw, to: InlineAttributes.self) + } + let absoluteRaw = AbsoluteRawMarkup(markup: raw, metadata: MarkupMetadata(id: .newRoot(), indexInParent: 0)) + self.init(_MarkupData(absoluteRaw)) + } + + init(_ data: _MarkupData) { + self._data = data + } +} + +// MARK: - Public API + +public extension InlineAttributes { + /// Create a set of custom inline attributes applied to zero or more child inline elements. + init(attributes: String, _ children: Children) where Children.Element == RecurringInlineMarkup { + try! self.init(.inlineAttributes(attributes: attributes, parsedRange: nil, children.map { $0.raw.markup })) + } + + /// Create a set of custom attributes applied to zero or more child inline elements. + init(attributes: String, _ children: RecurringInlineMarkup...) { + self.init(attributes: attributes, children) + } + + /// The specified attributes in JSON5 format. + var attributes: String { + get { + guard case let .inlineAttributes(attributes) = _data.raw.markup.data else { + fatalError("\(self) markup wrapped unexpected \(_data.raw)") + } + return attributes + } + set { + _data = _data.replacingSelf(.inlineAttributes(attributes: newValue, parsedRange: nil, _data.raw.markup.copyChildren())) + } + } + + // MARK: Visitation + + func accept(_ visitor: inout V) -> V.Result { + return visitor.visitInlineAttributes(self) + } +} diff --git a/Sources/Markdown/Parser/CommonMarkConverter.swift b/Sources/Markdown/Parser/CommonMarkConverter.swift index 06782433..f2a62da3 100644 --- a/Sources/Markdown/Parser/CommonMarkConverter.swift +++ b/Sources/Markdown/Parser/CommonMarkConverter.swift @@ -54,6 +54,7 @@ fileprivate enum CommonMarkNodeType: String { case strong case link case image + case inlineAttributes = "attribute" case none = "NONE" case unknown = "" @@ -229,6 +230,8 @@ struct MarkupParser { return convertTableRow(state) case .tableCell: return convertTableCell(state) + case .inlineAttributes: + return convertInlineAttributes(state) default: fatalError("Unknown cmark node type '\(state.nodeType.rawValue)' encountered during conversion") } @@ -578,6 +581,17 @@ struct MarkupParser { return MarkupConversion(state: childConversion.state.next(), result: .tableCell(parsedRange: parsedRange, colspan: colspan, rowspan: rowspan, childConversion.result)) } + private static func convertInlineAttributes(_ state: MarkupConverterState) -> MarkupConversion { + precondition(state.event == CMARK_EVENT_ENTER) + precondition(state.nodeType == .inlineAttributes) + let parsedRange = state.range(state.node) + let childConversion = convertChildren(state) + let attributes = String(cString: cmark_node_get_attributes(state.node)) + precondition(childConversion.state.node == state.node) + precondition(childConversion.state.event == CMARK_EVENT_EXIT) + return MarkupConversion(state: childConversion.state.next(), result: .inlineAttributes(attributes: attributes, parsedRange: parsedRange, childConversion.result)) + } + static func parseString(_ string: String, source: URL?, options: ParseOptions) -> Document { cmark_gfm_core_extensions_ensure_registered() diff --git a/Sources/Markdown/Rewriter/MarkupRewriter.swift b/Sources/Markdown/Rewriter/MarkupRewriter.swift index a486283b..931224b9 100644 --- a/Sources/Markdown/Rewriter/MarkupRewriter.swift +++ b/Sources/Markdown/Rewriter/MarkupRewriter.swift @@ -75,6 +75,9 @@ extension MarkupRewriter { public mutating func visitLink(_ link: Link) -> Result { return defaultVisit(link) } + public mutating func visitInlineAttributes(_ attributes: InlineAttributes) -> Result { + return defaultVisit(attributes) + } public mutating func visitSoftBreak(_ softBreak: SoftBreak) -> Result { return defaultVisit(softBreak) } diff --git a/Sources/Markdown/Visitor/MarkupVisitor.swift b/Sources/Markdown/Visitor/MarkupVisitor.swift index c9c83796..37ccbeb4 100644 --- a/Sources/Markdown/Visitor/MarkupVisitor.swift +++ b/Sources/Markdown/Visitor/MarkupVisitor.swift @@ -266,6 +266,14 @@ public protocol MarkupVisitor { - returns: The result of the visit. */ mutating func visitSymbolLink(_ symbolLink: SymbolLink) -> Result + + /** + Visit an `InlineAttributes` element and return the result. + + - parameter attribute: An `InlineAttributes` element. + - returns: The result of the visit. + */ + mutating func visitInlineAttributes(_ attributes: InlineAttributes) -> Result } extension MarkupVisitor { @@ -362,4 +370,7 @@ extension MarkupVisitor { public mutating func visitSymbolLink(_ symbolLink: SymbolLink) -> Result { return defaultVisit(symbolLink) } + public mutating func visitInlineAttributes(_ attributes: InlineAttributes) -> Result { + return defaultVisit(attributes) + } } diff --git a/Sources/Markdown/Walker/Walkers/MarkupFormatter.swift b/Sources/Markdown/Walker/Walkers/MarkupFormatter.swift index 6c8307fc..21e50781 100644 --- a/Sources/Markdown/Walker/Walkers/MarkupFormatter.swift +++ b/Sources/Markdown/Walker/Walkers/MarkupFormatter.swift @@ -1120,4 +1120,28 @@ public struct MarkupFormatter: MarkupWalker { print(symbolLink.destination ?? "", for: symbolLink) print("``", for: symbolLink) } + + public mutating func visitInlineAttributes(_ attributes: InlineAttributes) { + let savedState = state + func printInlineAttributes() { + print("[", for: attributes) + descendInto(attributes) + print("](", for: attributes) + print(attributes.attributes, for: attributes) + print(")", for: attributes) + } + + printInlineAttributes() + + // Inline attributes *can* have their key-value pairs split across multiple + // lines as they are formatted as JSON5, however formatting the output as such + // gets into the realm of JSON formatting which might be out of scope of + // this formatter. Therefore if exceeded, prefer to print it on the next + // line to give as much opportunity to keep the attributes on one line. + if attributes.indexInParent > 0 && (isOverPreferredLineLimit || state.lineNumber > savedState.lineNumber) { + restoreState(to: savedState) + queueNewline() + printInlineAttributes() + } + } } diff --git a/Sources/Markdown/Walker/Walkers/MarkupTreeDumper.swift b/Sources/Markdown/Walker/Walkers/MarkupTreeDumper.swift index 2acdcd13..8f5cc8aa 100644 --- a/Sources/Markdown/Walker/Walkers/MarkupTreeDumper.swift +++ b/Sources/Markdown/Walker/Walkers/MarkupTreeDumper.swift @@ -282,4 +282,8 @@ struct MarkupTreeDumper: MarkupWalker { dump(tableCell) } } + + mutating func visitInlineAttributes(_ attributes: InlineAttributes) -> () { + dump(attributes, customDescription: "attributes: `\(attributes.attributes)`") + } } diff --git a/Tests/MarkdownTests/Inline Nodes/InlineAttributesTests.swift b/Tests/MarkdownTests/Inline Nodes/InlineAttributesTests.swift new file mode 100644 index 00000000..91544c33 --- /dev/null +++ b/Tests/MarkdownTests/Inline Nodes/InlineAttributesTests.swift @@ -0,0 +1,49 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import XCTest +@testable import Markdown + +class InlineAttributesTests: XCTestCase { + func testInlineAttributesAttributes() { + let attributes = "rainbow: 'extreme'" + let inlineAttributes = InlineAttributes(attributes: attributes) + XCTAssertEqual(attributes, inlineAttributes.attributes) + XCTAssertEqual(0, inlineAttributes.childCount) + + let newAttributes = "rainbow: 'medium'" + var newInlineAttributes = inlineAttributes + newInlineAttributes.attributes = newAttributes + XCTAssertEqual(newAttributes, newInlineAttributes.attributes) + XCTAssertFalse(inlineAttributes.isIdentical(to: newInlineAttributes)) + } + + func testInlineAttributesFromSequence() { + let children = [Text("Hello, world!")] + let inlineAttributes = InlineAttributes(attributes: "rainbow: 'extreme'", children) + let expectedDump = """ + InlineAttributes attributes: `rainbow: 'extreme'` + └─ Text "Hello, world!" + """ + XCTAssertEqual(expectedDump, inlineAttributes.debugDescription()) + } + + func testParseInlineAttributes() { + let source = "^[Hello, world!](rainbow: 'extreme')" + let document = Document(parsing: source) + let expectedDump = """ + Document @1:1-1:37 + └─ Paragraph @1:1-1:37 + └─ InlineAttributes @1:1-1:37 attributes: `rainbow: 'extreme'` + └─ Text @1:3-1:16 "Hello, world!" + """ + XCTAssertEqual(expectedDump, document.debugDescription(options: .printSourceLocations)) + } +}