A list item holds block elements, not just paragraphs !7
12 files changed, +144 −93
Layout: unified · split
GAPS.md +5 −1
| @@ -5,7 +5,7 @@ goldens come from orgo (validated against Emacs `ox-html`). The renderer is run | |||
| 5 | orgo-compatible mode — `OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1)` — | 5 | orgo-compatible mode — `OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1)` — |
| 6 | so that only real rendering differences remain. | 6 | so that only real rendering differences remain. |
| 7 | 7 | ||
| 8 | Of the 12 corpus cases, **11 match orgo exactly** — every case except `outofscope`, which is | 8 | Of the 13 corpus cases, **12 match orgo exactly** — every case except `outofscope`, which is |
| 9 | `scope: out` in the corpus (deliberately unsupported constructs like `#+INCLUDE`, babel, and | 9 | `scope: out` in the corpus (deliberately unsupported constructs like `#+INCLUDE`, babel, and |
| 10 | LaTeX; orgo may also differ there, so it is documentation, not a target). The per-case state | 10 | LaTeX; orgo may also differ there, so it is documentation, not a target). The per-case state |
| 11 | is recorded in the `expectations` map in `Tests/OrgSwiftTests/ConformanceTests.swift`: a case | 11 | is recorded in the `expectations` map in `Tests/OrgSwiftTests/ConformanceTests.swift`: a case |
| @@ -54,6 +54,10 @@ they are presentation policy, not parser capability: | |||
| 54 | description lists (`term :: definition` → `<dl>`), and multi-paragraph items (an item's | 54 | description lists (`term :: definition` → `<dl>`), and multi-paragraph items (an item's |
| 55 | paragraphs, separated by blank lines, wrap in `<p>`). A latent link-parser bug that let a | 55 | paragraphs, separated by blank lines, wrap in `<p>`). A latent link-parser bug that let a |
| 56 | no-description link swallow a later link's `][` was fixed in passing. | 56 | no-description link swallow a later link's `][` was fixed in passing. |
| 57 | - **`listblocks`** — a list item's body is a block sequence, not a run of paragraphs, so an | ||
| 58 | item holds a source block, an example, a quote, or a table alongside its prose and its | ||
| 59 | sublist, in source order. Paragraphs wrap in `<p>` only when the item has more than one, | ||
| 60 | matching orgo; blocks follow bare. | ||
| 57 | - **`tblfm`** — `^` superscript renders (`N^2` → `N<sup>2</sup>`, `x^{group}` supported). | 61 | - **`tblfm`** — `^` superscript renders (`N^2` → `N<sup>2</sup>`, `x^{group}` supported). |
| 58 | - **`blocks`** — example blocks are a bare `<pre>`; `#+BEGIN_EXPORT html` passes through | 62 | - **`blocks`** — example blocks are a bare `<pre>`; `#+BEGIN_EXPORT html` passes through |
| 59 | verbatim while other export backends are dropped; verse renders as `<p class="verse">` | 63 | verbatim while other export backends are dropped; verse renders as `<p class="verse">` |
README.md +1
| @@ -17,6 +17,7 @@ parsed once into a document model, and each output format walks it. See | |||
| 17 | Headings (with trailing `:tags:`), paragraphs, ordered/unordered lists (with | 17 | Headings (with trailing `:tags:`), paragraphs, ordered/unordered lists (with |
| 18 | arbitrary-depth nesting, wrapped lines, and `[ ]`/`[x]`/`[-]` task checkboxes), | 18 | arbitrary-depth nesting, wrapped lines, and `[ ]`/`[x]`/`[-]` task checkboxes), |
| 19 | description lists (`term :: definition` → `<dl>`), multi-paragraph list items, | 19 | description lists (`term :: definition` → `<dl>`), multi-paragraph list items, |
| 20 | list items holding block elements (a source block, table, or quote inside an item), | ||
| 20 | tables (with `:---:` alignment), `#+begin_src` / `example` / `quote` / `center` / | 21 | tables (with `:---:` alignment), `#+begin_src` / `example` / `quote` / `center` / |
| 21 | `verse` / `export` blocks and `#+begin_<name>` special blocks, | 22 | `verse` / `export` blocks and `#+begin_<name>` special blocks, |
| 22 | `#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+ATTR_HTML`/`#+NAME` | 23 | `#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+ATTR_HTML`/`#+NAME` |
Sources/OrgSwift/AST/OrgDocument.swift +11 −6
| @@ -93,17 +93,22 @@ public struct OrgListItem: Sendable, Equatable { | |||
| 93 | public var checkbox: OrgCheckbox? | 93 | public var checkbox: OrgCheckbox? |
| 94 | /// The term of a description-list item (`term :: definition`). | 94 | /// The term of a description-list item (`term :: definition`). |
| 95 | public var term: [OrgObject]? | 95 | public var term: [OrgObject]? |
| 96 | /// The item's own content: one paragraph normally, several for a multi-paragraph item. | 96 | /// The item's body, in document order. Usually one paragraph, but an item may hold any |
| 97 | public var content: [[OrgObject]] | 97 | /// block element — a nested list, a source block, a table — so this is a block sequence |
| 98 | /// A nested list, when the item has one. | 98 | /// rather than a list of paragraphs. |
| 99 | public var sublist: OrgList? | 99 | public var content: [OrgElement] |
| 100 | 100 | ||
| 101 | public init(checkbox: OrgCheckbox? = nil, term: [OrgObject]? = nil, | 101 | public init(checkbox: OrgCheckbox? = nil, term: [OrgObject]? = nil, |
| 102 | content: [[OrgObject]], sublist: OrgList? = nil) { | 102 | content: [OrgElement]) { |
| 103 | self.checkbox = checkbox | 103 | self.checkbox = checkbox |
| 104 | self.term = term | 104 | self.term = term |
| 105 | self.content = content | 105 | self.content = content |
| 106 | self.sublist = sublist | 106 | } |
| 107 | |||
| 108 | /// The item's paragraphs, ignoring any other block it carries. Used where only prose is | ||
| 109 | /// meaningful — the `<dd>` of a description list, an item's plain-text reduction. | ||
| 110 | public var paragraphs: [[OrgObject]] { | ||
| 111 | content.compactMap { if case .paragraph(let objects) = $0 { objects } else { nil } } | ||
| 107 | } | 112 | } |
| 108 | } | 113 | } |
| 109 | 114 | ||
Sources/OrgSwift/AST/OrgHTMLTreeRenderer.swift +20 −10
| @@ -143,8 +143,8 @@ public struct OrgHTMLTreeRenderer { | |||
| 143 | if let term = item.term { | 143 | if let term = item.term { |
| 144 | html += "<dt>" + renderInline(term, ¬es) + "</dt>\n" | 144 | html += "<dt>" + renderInline(term, ¬es) + "</dt>\n" |
| 145 | } | 145 | } |
| 146 | if let first = item.content.first { | 146 | if !item.content.isEmpty { |
| 147 | html += "<dd>" + renderInline(first, ¬es) + "</dd>\n" | 147 | html += "<dd>" + renderItemContent(item, ¬es) + "</dd>\n" |
| 148 | } | 148 | } |
| 149 | } | 149 | } |
| 150 | return html + "</dl>\n" | 150 | return html + "</dl>\n" |
| @@ -161,19 +161,29 @@ public struct OrgHTMLTreeRenderer { | |||
| 161 | case .partial: html += "<code>[-]</code> " | 161 | case .partial: html += "<code>[-]</code> " |
| 162 | } | 162 | } |
| 163 | } | 163 | } |
| 164 | if item.content.count <= 1 { | 164 | html += renderItemContent(item, ¬es) |
| 165 | html += renderInline(item.content.first ?? [], ¬es) | ||
| 166 | } else { | ||
| 167 | html += item.content.map { "<p>" + renderInline($0, ¬es) + "</p>" }.joined(separator: "\n") | ||
| 168 | } | ||
| 169 | if let sublist = item.sublist { | ||
| 170 | html += "\n" + renderList(sublist, ¬es) | ||
| 171 | } | ||
| 172 | html += "</li>\n" | 165 | html += "</li>\n" |
| 173 | } | 166 | } |
| 174 | return html + "</\(tag)>\n" | 167 | return html + "</\(tag)>\n" |
| 175 | } | 168 | } |
| 176 | 169 | ||
| 170 | /// A list item's body. A lone paragraph renders bare inside the `<li>`; two or more get | ||
| 171 | /// their own `<p>`, matching org's exporter. Anything that is not a paragraph — a nested | ||
| 172 | /// list, a source block, a table — renders as the block it is, in source order. | ||
| 173 | private func renderItemContent(_ item: OrgListItem, _ notes: inout OrgFootnotes) -> String { | ||
| 174 | let wrapParagraphs = item.paragraphs.count > 1 | ||
| 175 | var parts: [String] = [] | ||
| 176 | for child in item.content { | ||
| 177 | if case .paragraph(let objects) = child { | ||
| 178 | let text = renderInline(objects, ¬es) | ||
| 179 | parts.append(wrapParagraphs ? "<p>" + text + "</p>" : text) | ||
| 180 | } else { | ||
| 181 | parts.append(element(child, ¬es)) | ||
| 182 | } | ||
| 183 | } | ||
| 184 | return parts.joined(separator: "\n") | ||
| 185 | } | ||
| 186 | |||
| 177 | private func renderTable(_ table: OrgTable, _ notes: inout OrgFootnotes) -> String { | 187 | private func renderTable(_ table: OrgTable, _ notes: inout OrgFootnotes) -> String { |
| 178 | var html = "<table>\n" | 188 | var html = "<table>\n" |
| 179 | var wroteHeader = false | 189 | var wroteHeader = false |
Sources/OrgSwift/AST/OrgParser.swift +12 −39
| @@ -320,49 +320,22 @@ public enum OrgParser { | |||
| 320 | else if head.hasPrefix("[X] ") || head.hasPrefix("[x] ") { checkbox = .on; head = String(head.dropFirst(4)) } | 320 | else if head.hasPrefix("[X] ") || head.hasPrefix("[x] ") { checkbox = .on; head = String(head.dropFirst(4)) } |
| 321 | else if head.hasPrefix("[-] ") { checkbox = .partial; head = String(head.dropFirst(4)) } | 321 | else if head.hasPrefix("[-] ") { checkbox = .partial; head = String(head.dropFirst(4)) } |
| 322 | 322 | ||
| 323 | // `term :: definition` splits on the item's first line only, as org requires. | ||
| 324 | var term: [OrgObject]? | ||
| 325 | if kind == .description, let range = head.range(of: " :: ") { | ||
| 326 | term = parseInline(String(head[..<range.lowerBound])) | ||
| 327 | head = String(head[range.upperBound...]) | ||
| 328 | } | ||
| 329 | |||
| 330 | // The item's body is outdented to column 0 and parsed as a document of its own, so an | ||
| 331 | // item holds whatever a document holds — several paragraphs, a nested list, a source | ||
| 332 | // block, a table — in source order. | ||
| 323 | let rest = Array(lines.dropFirst()) | 333 | let rest = Array(lines.dropFirst()) |
| 324 | let childIndent = rest.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } | 334 | let childIndent = rest.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } |
| 325 | .map(leadingWidth).min() ?? 0 | 335 | .map(leadingWidth).min() ?? 0 |
| 326 | let outdented = rest.map { dropLeading($0, childIndent) } | 336 | let body = ([head] + rest.map { dropLeading($0, childIndent) }).joined(separator: "\n") |
| 327 | |||
| 328 | var paragraphs: [String] = [] | ||
| 329 | var currentParagraph = [head] | ||
| 330 | var sublistLines: [String] = [] | ||
| 331 | var inSublist = false | ||
| 332 | |||
| 333 | func flush() { | ||
| 334 | let joined = currentParagraph.joined(separator: " ").trimmingCharacters(in: .whitespaces) | ||
| 335 | if !joined.isEmpty { paragraphs.append(joined) } | ||
| 336 | currentParagraph = [] | ||
| 337 | } | ||
| 338 | |||
| 339 | for line in outdented { | ||
| 340 | let trimmed = line.trimmingCharacters(in: .whitespaces) | ||
| 341 | if isListMarkerLine(line) || inSublist { | ||
| 342 | if !inSublist { flush() } | ||
| 343 | inSublist = true | ||
| 344 | sublistLines.append(line) | ||
| 345 | } else if trimmed.isEmpty { | ||
| 346 | flush() | ||
| 347 | } else { | ||
| 348 | currentParagraph.append(trimmed) | ||
| 349 | } | ||
| 350 | } | ||
| 351 | flush() | ||
| 352 | |||
| 353 | var term: [OrgObject]? | ||
| 354 | var content = paragraphs | ||
| 355 | if kind == .description, let first = paragraphs.first, let range = first.range(of: " :: ") { | ||
| 356 | term = parseInline(String(first[..<range.lowerBound])) | ||
| 357 | content[0] = String(first[range.upperBound...]) | ||
| 358 | } | ||
| 359 | 337 | ||
| 360 | return OrgListItem( | 338 | return OrgListItem(checkbox: checkbox, term: term, content: parse(body).elements) |
| 361 | checkbox: checkbox, | ||
| 362 | term: term, | ||
| 363 | content: content.map(parseInline), | ||
| 364 | sublist: sublistLines.isEmpty ? nil : buildList(sublistLines) | ||
| 365 | ) | ||
| 366 | } | 339 | } |
| 367 | 340 | ||
| 368 | // MARK: - Helpers | 341 | // MARK: - Helpers |
Sources/OrgSwiftUI/OrgLayout.swift +3 −2
| @@ -56,8 +56,9 @@ struct OrgListLayout { | |||
| 56 | struct Item: Identifiable { | 56 | struct Item: Identifiable { |
| 57 | var checkbox: OrgCheckbox? | 57 | var checkbox: OrgCheckbox? |
| 58 | var term: AttributedString? | 58 | var term: AttributedString? |
| 59 | var paragraphs: [AttributedString] | 59 | /// The item's body in source order — usually one paragraph, but an item may hold a |
| 60 | var sublist: OrgListLayout? | 60 | /// nested list, a source block, or a table. |
| 61 | var blocks: [OrgBlock] | ||
| 61 | var id = UUID() | 62 | var id = UUID() |
| 62 | } | 63 | } |
| 63 | } | 64 | } |
Sources/OrgSwiftUI/OrgLayoutBuilder.swift +1 −2
| @@ -184,8 +184,7 @@ struct OrgLayoutBuilder { | |||
| 184 | styled.font = .body.bold() | 184 | styled.font = .body.bold() |
| 185 | return styled | 185 | return styled |
| 186 | }, | 186 | }, |
| 187 | paragraphs: item.content.map { text($0, ¬es) }, | 187 | blocks: item.content.flatMap { block($0, ¬es) } |
| 188 | sublist: item.sublist.map { listLayout($0, ¬es) } | ||
| 189 | )) | 188 | )) |
| 190 | } | 189 | } |
| 191 | return OrgListLayout(kind: list.kind, items: items) | 190 | return OrgListLayout(kind: list.kind, items: items) |
Sources/OrgSwiftUI/OrgListView.swift +13 −25
| @@ -29,20 +29,15 @@ struct OrgListView: View { | |||
| 29 | Text(marker) | 29 | Text(marker) |
| 30 | .monospacedDigit() | 30 | .monospacedDigit() |
| 31 | .foregroundStyle(.secondary) | 31 | .foregroundStyle(.secondary) |
| 32 | VStack(alignment: .leading, spacing: 6) { | 32 | if let checkbox = item.checkbox { |
| 33 | if let checkbox = item.checkbox { | 33 | HStack(alignment: .firstTextBaseline, spacing: 6) { |
| 34 | HStack(alignment: .firstTextBaseline, spacing: 6) { | 34 | Image(systemName: Self.symbol(for: checkbox)) |
| 35 | Image(systemName: Self.symbol(for: checkbox)) | 35 | .foregroundStyle(checkbox == .on ? Color.accentColor : .secondary) |
| 36 | .foregroundStyle(checkbox == .on ? Color.accentColor : .secondary) | 36 | .accessibilityLabel(Self.label(for: checkbox)) |
| 37 | .accessibilityLabel(Self.label(for: checkbox)) | 37 | body(item) |
| 38 | paragraphs(item) | ||
| 39 | } | ||
| 40 | } else { | ||
| 41 | paragraphs(item) | ||
| 42 | } | ||
| 43 | if let sublist = item.sublist { | ||
| 44 | OrgListView(list: sublist) | ||
| 45 | } | 38 | } |
| 39 | } else { | ||
| 40 | body(item) | ||
| 46 | } | 41 | } |
| 47 | } | 42 | } |
| 48 | } | 43 | } |
| @@ -53,21 +48,14 @@ struct OrgListView: View { | |||
| 53 | if let term = item.term { | 48 | if let term = item.term { |
| 54 | Text(term).fixedSize(horizontal: false, vertical: true) | 49 | Text(term).fixedSize(horizontal: false, vertical: true) |
| 55 | } | 50 | } |
| 56 | paragraphs(item).padding(.leading, 16) | 51 | body(item).padding(.leading, 16) |
| 57 | if let sublist = item.sublist { | ||
| 58 | OrgListView(list: sublist).padding(.leading, 16) | ||
| 59 | } | ||
| 60 | } | 52 | } |
| 61 | } | 53 | } |
| 62 | 54 | ||
| 63 | private func paragraphs(_ item: OrgListLayout.Item) -> some View { | 55 | /// The item's blocks, rendered by the same view the document body uses — a nested list, |
| 64 | VStack(alignment: .leading, spacing: 6) { | 56 | /// a source block, and a table all come out looking as they would anywhere else. |
| 65 | ForEach(Array(item.paragraphs.enumerated()), id: \.offset) { _, paragraph in | 57 | private func body(_ item: OrgListLayout.Item) -> some View { |
| 66 | Text(paragraph) | 58 | OrgBlockListView(blocks: item.blocks) |
| 67 | .fixedSize(horizontal: false, vertical: true) | ||
| 68 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 69 | } | ||
| 70 | } | ||
| 71 | } | 59 | } |
| 72 | 60 | ||
| 73 | private static func symbol(for checkbox: OrgCheckbox) -> String { | 61 | private static func symbol(for checkbox: OrgCheckbox) -> String { |
Tests/OrgSwiftTests/ConformanceTests.swift +1
| @@ -56,6 +56,7 @@ private let expectations: [String: Expectation] = [ | |||
| 56 | "footnote": .matches, | 56 | "footnote": .matches, |
| 57 | "headings": .matches, | 57 | "headings": .matches, |
| 58 | "images": .matches, | 58 | "images": .matches, |
| 59 | "listblocks": .matches, | ||
| 59 | "lists": .matches, | 60 | "lists": .matches, |
| 60 | "minimal": .matches, | 61 | "minimal": .matches, |
| 61 | "outofscope": .diverges("out-of-scope constructs; #+INCLUDE and drawers leak — orgo may differ here too"), | 62 | "outofscope": .diverges("out-of-scope constructs; #+INCLUDE and drawers leak — orgo may differ here too"), |
Tests/OrgSwiftTests/OrgRendererTests.swift +31
| @@ -430,6 +430,37 @@ struct OrgRendererTests { | |||
| 430 | #expect(html.contains("<li>a plain sibling</li>")) | 430 | #expect(html.contains("<li>a plain sibling</li>")) |
| 431 | } | 431 | } |
| 432 | 432 | ||
| 433 | @Test | ||
| 434 | func listItemHoldsBlockElements() { | ||
| 435 | let html = render(""" | ||
| 436 | 1. Fork the repository | ||
| 437 | 2. Create a new branch: | ||
| 438 | #+begin_src bash | ||
| 439 | git checkout -b my-feature | ||
| 440 | #+end_src | ||
| 441 | 3. Open a pull request | ||
| 442 | """) | ||
| 443 | #expect(html.contains("<li>Fork the repository</li>")) | ||
| 444 | #expect(html.contains("<li>Create a new branch:\n<pre>")) | ||
| 445 | #expect(html.contains("git checkout -b my-feature")) | ||
| 446 | #expect(!html.contains("begin_src")) | ||
| 447 | } | ||
| 448 | |||
| 449 | /// A sublist and a block in the same item keep their source order. | ||
| 450 | @Test | ||
| 451 | func listItemKeepsSublistAndBlockInOrder() throws { | ||
| 452 | let html = render(""" | ||
| 453 | - outer | ||
| 454 | - inner | ||
| 455 | #+begin_example | ||
| 456 | example text | ||
| 457 | #+end_example | ||
| 458 | """) | ||
| 459 | let sublist = try #require(html.range(of: "<li>inner</li>")) | ||
| 460 | let block = try #require(html.range(of: "example text")) | ||
| 461 | #expect(sublist.lowerBound < block.lowerBound) | ||
| 462 | } | ||
| 463 | |||
| 433 | @Test | 464 | @Test |
| 434 | func exportBlockHtmlPassesThroughLatexDropped() { | 465 | func exportBlockHtmlPassesThroughLatexDropped() { |
| 435 | let html = render(""" | 466 | let html = render(""" |
Tests/OrgSwiftTests/OrgTreeTests.swift +8 −3
| @@ -56,9 +56,14 @@ struct ASTParseTests { | |||
| 56 | Issue.record("expected a list"); return | 56 | Issue.record("expected a list"); return |
| 57 | } | 57 | } |
| 58 | #expect(list.items.count == 2) | 58 | #expect(list.items.count == 2) |
| 59 | let inner = list.items[0].sublist | 59 | guard case .list(let inner)? = list.items[0].content.last else { |
| 60 | #expect(inner != nil) | 60 | Issue.record("expected a nested list"); return |
| 61 | #expect(inner?.items.first?.sublist?.items.count == 1) | 61 | } |
| 62 | #expect(inner.items.count == 1) | ||
| 63 | guard case .list(let deepest)? = inner.items[0].content.last else { | ||
| 64 | Issue.record("expected a twice-nested list"); return | ||
| 65 | } | ||
| 66 | #expect(deepest.items.count == 1) | ||
| 62 | } | 67 | } |
| 63 | 68 | ||
| 64 | @Test | 69 | @Test |
Tests/OrgSwiftUITests/OrgLayoutTests.swift +38 −5
| @@ -23,6 +23,18 @@ struct OrgLayoutTests { | |||
| 23 | 23 | ||
| 24 | private func plain(_ string: AttributedString) -> String { String(string.characters) } | 24 | private func plain(_ string: AttributedString) -> String { String(string.characters) } |
| 25 | 25 | ||
| 26 | /// An item's first paragraph, as plain text. | ||
| 27 | private func firstParagraph(_ item: OrgListLayout.Item) -> String { | ||
| 28 | for case .paragraph(let text) in item.blocks { return plain(text) } | ||
| 29 | return "" | ||
| 30 | } | ||
| 31 | |||
| 32 | /// The nested list an item carries, if any. | ||
| 33 | private func sublist(_ item: OrgListLayout.Item) -> OrgListLayout? { | ||
| 34 | for case .list(let list) in item.blocks { return list } | ||
| 35 | return nil | ||
| 36 | } | ||
| 37 | |||
| 26 | @Test | 38 | @Test |
| 27 | func blocksCoverTheElementTree() { | 39 | func blocksCoverTheElementTree() { |
| 28 | let blocks = layout(""" | 40 | let blocks = layout(""" |
| @@ -90,10 +102,31 @@ struct OrgLayoutTests { | |||
| 90 | Issue.record("expected a list"); return | 102 | Issue.record("expected a list"); return |
| 91 | } | 103 | } |
| 92 | #expect(list.kind == .unordered) | 104 | #expect(list.kind == .unordered) |
| 93 | #expect(plain(list.items[0].paragraphs[0]) == "outer") | 105 | #expect(firstParagraph(list.items[0]) == "outer") |
| 94 | let inner = list.items[0].sublist | 106 | guard let inner = sublist(list.items[0]) else { |
| 95 | #expect(plain(inner?.items[0].paragraphs[0] ?? "") == "inner") | 107 | Issue.record("expected a nested list"); return |
| 96 | #expect(plain(inner?.items[0].sublist?.items[0].paragraphs[0] ?? "") == "deepest") | 108 | } |
| 109 | #expect(firstParagraph(inner.items[0]) == "inner") | ||
| 110 | #expect(sublist(inner.items[0]).map { firstParagraph($0.items[0]) } == "deepest") | ||
| 111 | } | ||
| 112 | |||
| 113 | /// The native path renders an item's blocks as blocks; before, a `#+begin_src` inside a | ||
| 114 | /// list item came out as literal prose. | ||
| 115 | @Test | ||
| 116 | func listItemBlocksBecomeBlocks() { | ||
| 117 | guard case .list(let list) = layout(""" | ||
| 118 | 1. Create a new branch: | ||
| 119 | #+begin_src bash | ||
| 120 | git checkout -b my-feature | ||
| 121 | #+end_src | ||
| 122 | """).blocks.first else { | ||
| 123 | Issue.record("expected a list"); return | ||
| 124 | } | ||
| 125 | #expect(firstParagraph(list.items[0]) == "Create a new branch:") | ||
| 126 | guard case .code(let code)? = list.items[0].blocks.last else { | ||
| 127 | Issue.record("expected a code block in the item"); return | ||
| 128 | } | ||
| 129 | #expect(plain(code) == "git checkout -b my-feature") | ||
| 97 | } | 130 | } |
| 98 | 131 | ||
| 99 | @Test | 132 | @Test |
| @@ -108,7 +141,7 @@ struct OrgLayoutTests { | |||
| 108 | } | 141 | } |
| 109 | #expect(described.kind == .description) | 142 | #expect(described.kind == .description) |
| 110 | #expect(plain(described.items[0].term ?? "") == "term") | 143 | #expect(plain(described.items[0].term ?? "") == "term") |
| 111 | #expect(plain(described.items[0].paragraphs[0]) == "definition") | 144 | #expect(firstParagraph(described.items[0]) == "definition") |
| 112 | } | 145 | } |
| 113 | 146 | ||
| 114 | /// Rule rows are structure, not content: they set the header band and then disappear. | 147 | /// Rule rows are structure, not content: they set the header band and then disappear. |