diff --git a/Package.resolved b/Package.resolved index f08609a7a2..b576e66f28 100644 --- a/Package.resolved +++ b/Package.resolved @@ -15,7 +15,7 @@ "repositoryURL": "https://github.com/apple/swift-cmark.git", "state": { "branch": "gfm", - "revision": "476f7b4fcf12eba381b4aaed8987006fd8fcec9c", + "revision": "eb9a6a357b6816c68f4b194eaa5b67f3cd1fa5c3", "version": null } }, @@ -60,7 +60,7 @@ "repositoryURL": "https://github.com/apple/swift-markdown.git", "state": { "branch": "main", - "revision": "87c9e60b73174643f1f0bdfd81d41ce379defde0", + "revision": "395fbf23fc09e76efaecbb731edb2aebf910948b", "version": null } }, diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index e66eb7875f..7ada39d088 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -235,13 +235,16 @@ public enum RenderBlockContent: Equatable { public var header: HeaderType /// The rows in this table. public var rows: [TableRow] + /// Any extended information that describes cells in this table. + public var extendedData: Set /// Additional metadata for this table, if present. public var metadata: RenderContentMetadata? /// Creates a new table with the given data. - public init(header: HeaderType, rows: [TableRow], metadata: RenderContentMetadata? = nil) { + public init(header: HeaderType, rows: [TableRow], extendedData: Set, metadata: RenderContentMetadata? = nil) { self.header = header self.rows = rows + self.extendedData = extendedData self.metadata = metadata } } @@ -382,6 +385,36 @@ public enum RenderBlockContent: Equatable { cells = try container.decode([Cell].self) } } + + /// Extended data that may be applied to a table cell. + public struct TableCellExtendedData: Equatable, Hashable { + /// The row coordinate for the cell described by this data. + public let rowIndex: Int + /// The column coordinate for the cell described by this data. + public let columnIndex: Int + + /// The number of columns this cell spans over. + /// + /// A value of 1 is the default. A value of zero means that this cell is being "spanned + /// over" by a previous cell in this row. A value of greater than 1 means that this cell + /// "spans over" later cells in this row. + public let colspan: UInt + + /// The number of rows this cell spans over. + /// + /// A value of 1 is the default. A value of zero means that this cell is being "spanned + /// over" by another cell in a previous row. A value of greater than one means that this + /// cell "spans over" other cells in later rows. + public let rowspan: UInt + + public init(rowIndex: Int, columnIndex: Int, + colspan: UInt, rowspan: UInt) { + self.rowIndex = rowIndex + self.columnIndex = columnIndex + self.colspan = colspan + self.rowspan = rowspan + } + } /// A term definition. /// @@ -442,6 +475,83 @@ public enum RenderBlockContent: Equatable { } } +// Writing a manual Codable implementation for tables because the encoding of `extendedData` does +// not follow from the struct layout. +extension RenderBlockContent.Table: Codable { + enum CodingKeys: String, CodingKey { + case header, rows, extendedData, metadata + } + + // TableCellExtendedData encodes the row and column indices as a dynamic key with the format "{row}_{column}". + struct DynamicIndexCodingKey: CodingKey, Equatable { + let row, column: Int + init(row: Int, column: Int) { + self.row = row + self.column = column + } + + var stringValue: String { + return "\(row)_\(column)" + } + init?(stringValue: String) { + let coordinates = stringValue.split(separator: "_") + guard coordinates.count == 2, + let rowIndex = Int(coordinates.first!), + let columnIndex = Int(coordinates.last!) else { + return nil + } + row = rowIndex + column = columnIndex + } + // The key is only represented by a string value + var intValue: Int? { nil } + init?(intValue: Int) { nil } + } + + enum ExtendedDataCodingKeys: String, CodingKey { + case colspan, rowspan + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.header = try container.decode(RenderBlockContent.HeaderType.self, forKey: .header) + self.rows = try container.decode([RenderBlockContent.TableRow].self, forKey: .rows) + self.metadata = try container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata) + + var extendedData = Set() + if container.contains(.extendedData) { + let dataContainer = try container.nestedContainer(keyedBy: DynamicIndexCodingKey.self, forKey: .extendedData) + + for index in dataContainer.allKeys { + let cellContainer = try dataContainer.nestedContainer(keyedBy: ExtendedDataCodingKeys.self, forKey: index) + extendedData.insert(.init(rowIndex: index.row, + columnIndex: index.column, + colspan: try cellContainer.decode(UInt.self, forKey: .colspan), + rowspan: try cellContainer.decode(UInt.self, forKey: .rowspan))) + } + } + self.extendedData = extendedData + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(header, forKey: .header) + try container.encode(rows, forKey: .rows) + try container.encodeIfPresent(metadata, forKey: .metadata) + + if !extendedData.isEmpty { + var dataContainer = container.nestedContainer(keyedBy: DynamicIndexCodingKey.self, forKey: .extendedData) + for data in extendedData { + var cellContainer = dataContainer.nestedContainer(keyedBy: ExtendedDataCodingKeys.self, + forKey: .init(row: data.rowIndex, column: data.columnIndex)) + try cellContainer.encode(data.colspan, forKey: .colspan) + try cellContainer.encode(data.rowspan, forKey: .rowspan) + } + } + } +} + // Codable conformance extension RenderBlockContent: Codable { private enum CodingKeys: CodingKey { @@ -488,11 +598,8 @@ extension RenderBlockContent: Codable { case .dictionaryExample: self = try .dictionaryExample(.init(summary: container.decodeIfPresent([RenderBlockContent].self, forKey: .summary), example: container.decode(CodeExample.self, forKey: .example))) case .table: - self = try .table(.init( - header: container.decode(HeaderType.self, forKey: .header), - rows: container.decode([TableRow].self, forKey: .rows), - metadata: container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata) - )) + // Defer to Table's own Codable implemenatation to parse `extendedData` properly. + self = try .table(.init(from: decoder)) case .termList: self = try .termList(.init(items: container.decode([TermListItem].self, forKey: .items))) case .row: @@ -569,9 +676,8 @@ extension RenderBlockContent: Codable { try container.encodeIfPresent(e.summary, forKey: .summary) try container.encode(e.example, forKey: .example) case .table(let t): - try container.encode(t.header, forKey: .header) - try container.encode(t.rows, forKey: .rows) - try container.encodeIfPresent(t.metadata, forKey: .metadata) + // Defer to Table's own Codable implemenatation to format `extendedData` properly. + try t.encode(to: encoder) case .termList(items: let l): try container.encode(l.items, forKey: .items) case .row(let row): diff --git a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift index a61cba25e1..790ed86530 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -185,23 +185,32 @@ struct RenderContentCompiler: MarkupVisitor { } mutating func visitTable(_ table: Table) -> [RenderContent] { + var extendedData = Set() + var headerCells = [RenderBlockContent.TableRow.Cell]() for cell in table.head.cells { let cellContent = cell.children.reduce(into: [], { result, child in result.append(contentsOf: visit(child))}) headerCells.append([RenderBlockContent.paragraph(.init(inlineContent: cellContent as! [RenderInlineContent]))]) + if cell.colspan != 1 || cell.rowspan != 1 { + extendedData.insert(.init(rowIndex: 0, columnIndex: cell.indexInParent, colspan: cell.colspan, rowspan: cell.rowspan)) + } } var rows = [RenderBlockContent.TableRow]() for row in table.body.rows { + let rowIndex = row.indexInParent + 1 var cells = [RenderBlockContent.TableRow.Cell]() for cell in row.cells { let cellContent = cell.children.reduce(into: [], { result, child in result.append(contentsOf: visit(child))}) cells.append([RenderBlockContent.paragraph(.init(inlineContent: cellContent as! [RenderInlineContent]))]) + if cell.colspan != 1 || cell.rowspan != 1 { + extendedData.insert(.init(rowIndex: rowIndex, columnIndex: cell.indexInParent, colspan: cell.colspan, rowspan: cell.rowspan)) + } } rows.append(RenderBlockContent.TableRow(cells: cells)) } - return [RenderBlockContent.table(.init(header: .row, rows: [RenderBlockContent.TableRow(cells: headerCells)] + rows, metadata: nil))] + return [RenderBlockContent.table(.init(header: .row, rows: [RenderBlockContent.TableRow(cells: headerCells)] + rows, extendedData: extendedData, metadata: nil))] } mutating func visitStrikethrough(_ strikethrough: Strikethrough) -> [RenderContent] { diff --git a/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json b/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json index 8d122ee1dc..f7bbaea0ce 100644 --- a/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json +++ b/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json @@ -861,11 +861,29 @@ } } }, + "extendedData": { + "type": "object", + "description": "Additional data that can be applied per-cell. Property keys have the pattern 'X_Y', where X is the numerical row index and Y is the numerical column index, both starting from zero.", + "additionalProperties": { + "$ref": "#/components/schemas/TableExtendedData" + } + }, "metadata": { "$ref": "#/components/schemas/RenderContentMetadata" } } }, + "TableExtendedData": { + "type": "object", + "properties": { + "colspan": { + "type": "integer" + }, + "rowspan": { + "type": "integer" + } + } + }, "Step": { "required": [ "type", diff --git a/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift b/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift index 031040d14d..e1c85e9b25 100644 --- a/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift +++ b/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift @@ -37,7 +37,7 @@ class RenderContentMetadataTests: XCTestCase { RenderInlineContent.text("Content"), ]) - let table = RenderBlockContent.table(.init(header: .both, rows: [], metadata: metadata)) + let table = RenderBlockContent.table(.init(header: .both, rows: [], extendedData: [], metadata: metadata)) let data = try JSONEncoder().encode(table) let roundtrip = try JSONDecoder().decode(RenderBlockContent.self, from: data) @@ -105,6 +105,58 @@ class RenderContentMetadataTests: XCTestCase { default: XCTFail("Unexpected element") } } + + func testRenderingTableSpans() throws { + let (bundle, context) = try testBundleAndContext(named: "TestBundle") + var renderContentCompiler = RenderContentCompiler(context: context, bundle: bundle, identifier: ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/path", fragment: nil, sourceLanguage: .swift)) + + let source = """ + | one | two | three | + | --- | --- | ----- | + | big || small | + | ^ || small | + """ + let document = Document(parsing: source) + + // Verifies that a markdown table renders correctly. + + let result = try XCTUnwrap(renderContentCompiler.visit(document.child(at: 0)!)) + let renderedTable = try XCTUnwrap(result.first as? RenderBlockContent) + + let renderCell: ([RenderBlockContent]) -> String = { cell in + return cell.reduce(into: "") { (result, element) in + switch element { + case .paragraph(let p): + guard let para = p.inlineContent.first else { return } + result.append(para.plainText) + default: XCTFail("Unexpected element"); return + } + } + } + + let expectedExtendedData: [RenderBlockContent.TableCellExtendedData] = [ + .init(rowIndex: 1, columnIndex: 0, colspan: 2, rowspan: 2), + .init(rowIndex: 1, columnIndex: 1, colspan: 0, rowspan: 1), + .init(rowIndex: 2, columnIndex: 0, colspan: 2, rowspan: 0), + .init(rowIndex: 2, columnIndex: 1, colspan: 0, rowspan: 1) + ] + + switch renderedTable { + case .table(let t): + XCTAssertEqual(t.header, .row) + XCTAssertEqual(t.rows.count, 3) + guard t.rows.count == 3 else { return } + XCTAssertEqual(t.rows[0].cells.map(renderCell), ["one", "two", "three"]) + XCTAssertEqual(t.rows[1].cells.map(renderCell), ["big", "", "small"]) + XCTAssertEqual(t.rows[2].cells.map(renderCell), ["", "", "small"]) + for expectedData in expectedExtendedData { + XCTAssert(t.extendedData.contains(expectedData)) + } + default: XCTFail("Unexpected element") + } + + try assertRoundTripCoding(renderedTable) + } func testStrikethrough() throws { let (bundle, context) = try testBundleAndContext(named: "TestBundle")