Commit e0ca3558d1
Verified · cmc
GAPS.md +18 −15
| @@ -5,21 +5,12 @@ goldens come from orgo (validated against Emacs `ox-html`). The renderer is run | ||
| 5 | 5 | orgo-compatible mode — `OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1)` — |
| 6 | 6 | so that only real rendering differences remain. |
| 7 | 7 | |
| 8 | Of the 12 corpus cases, **7 match orgo exactly (`table`, `timestamps`, `footnote`, | |
| 9 | `minimal`, `headings`, `core`, `images`)** and 5 diverge. Each divergence below is a missing | |
| 10 | capability, recorded in the `expectations` map in | |
| 11 | `Tests/OrgSwiftTests/ConformanceTests.swift`. When one is closed, its case flips to matching | |
| 12 | and the test fails until it is moved to `.matches` — that is how this list stays honest. | |
| 13 | ||
| 14 | This is the backlog the shared package exists to work through. Roughly in value order: | |
| 15 | ||
| 16 | | Case | Missing capability | | |
| 17 | |---|---| | |
| 18 | | `elements` | Unknown `#+KEYWORD:` lines (e.g. `#+FILETAGS:`) leak into the body as paragraph text. | | |
| 19 | | `lists` | List nesting deeper than one level is flattened. | | |
| 20 | | `tblfm` | `^` superscript is not rendered (e.g. `N^2` in a table cell). | | |
| 21 | | `blocks` | Example blocks wrap in `<pre><code>`; orgo uses a bare `<pre>`. | | |
| 22 | | `outofscope` | Deliberately unsupported constructs. `scope: out` in the corpus — orgo may also differ, so treat this as documentation, not a target. | | |
| 8 | Of the 12 corpus cases, **11 match orgo exactly** — every case except `outofscope`, which is | |
| 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 | |
| 11 | is recorded in the `expectations` map in `Tests/OrgSwiftTests/ConformanceTests.swift`: a case | |
| 12 | that regresses, or `outofscope` starting to match, fails the test and forces the map to be | |
| 13 | updated — that is how this list stays honest. | |
| 23 | 14 | |
| 24 | 15 | Two behaviors were made configurable during extraction rather than left hardcoded, because |
| 25 | 16 | they are presentation policy, not parser capability: |
| @@ -56,3 +47,15 @@ they are presentation policy, not parser capability: | ||
| 56 | 47 | promotes it to a `<figure>`, with a numbered `<figcaption>` when a caption is present. |
| 57 | 48 | `#+ATTR_HTML` `:key value` pairs (quoted values honored) become `<img>` attributes, and |
| 58 | 49 | a described image link (`[[file:x.png][text]]`) renders as a link, not an inline image. |
| 50 | **`elements`** — any unhandled `#+KEYWORD:` line (`#+FILETAGS`, `#+TBLFM`, …) is consumed | |
| 51 | as document metadata instead of leaking into the body, and `[[id:target]]` links render | |
| 52 | as in-page fragment links (`<a href="#target">`). | |
| 53 | **`lists`** — the list renderer is now recursive: unordered/ordered nesting to any depth, | |
| 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 | |
| 56 | no-description link swallow a later link's `][` was fixed in passing. | |
| 57 | **`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 | |
| 59 | verbatim while other export backends are dropped; verse renders as `<p class="verse">` | |
| 60 | with `<br>`; a `#+BEGIN_<name>` special block becomes `<div class="<name>">` with org | |
| 61 | content; and a source block inside a quote stays nested in the `<blockquote>`. | |
README.md +10 −6
| @@ -10,14 +10,18 @@ shared across apps. | ||
| 10 | 10 | ## Supported syntax |
| 11 | 11 | |
| 12 | 12 | Headings (with trailing `:tags:`), paragraphs, ordered/unordered lists (with |
| 13 | nesting, wrapped lines, and `[ ]`/`[x]` task checkboxes), tables (with `:---:` | |
| 14 | alignment), `#+begin_src` / `example` / `quote` / `center` / `verse` blocks, | |
| 13 | arbitrary-depth nesting, wrapped lines, and `[ ]`/`[x]`/`[-]` task checkboxes), | |
| 14 | description lists (`term :: definition` → `<dl>`), multi-paragraph list items, | |
| 15 | tables (with `:---:` alignment), `#+begin_src` / `example` / `quote` / `center` / | |
| 16 | `verse` / `export` blocks and `#+begin_<name>` special blocks, | |
| 15 | 17 | `#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+ATTR_HTML`/`#+NAME` |
| 16 | 18 | figures, org links and images (`[[file:x.png]]` → `<img>`, `[[dest][label]]` → |
| 17 | link), horizontal rules, comments, timestamps (`<2024-01-15 Mon>`, inactive, | |
| 18 | times, and ranges → `<time>`), footnotes (references, inline, and definitions → | |
| 19 | a `<section class="footnotes">`), and inline markup (`*bold*`, `/italic/`, | |
| 20 | `~code~`, `=verbatim=`, `+strike+`, `_underline_`, bare-URL and email autolinks). | |
| 19 | link, `[[id:target]]` → `#target`), horizontal rules, comments, timestamps | |
| 20 | (`<2024-01-15 Mon>`, inactive, times, and ranges → `<time>`), footnotes | |
| 21 | (references, inline, and definitions → a `<section class="footnotes">`), and | |
| 22 | inline markup (`*bold*`, `/italic/`, `~code~`, `=verbatim=`, `+strike+`, | |
| 23 | `_underline_`, `x^2` superscript, bare-URL and email autolinks). Other | |
| 24 | `#+KEYWORD:` lines are consumed as document metadata. | |
| 21 | 25 | |
| 22 | 26 | Output is sanitized: only `http`/`https`/`mailto` link schemes and |
| 23 | 27 | `http`/`https` image schemes are allowed; everything else is dropped. |
Sources/OrgSwift/Inline.swift +14
| @@ -75,6 +75,20 @@ func processOrgInline( | ||
| 75 | 75 | } |
| 76 | 76 | } |
| 77 | 77 | |
| 78 | // Superscript: `x^2` or `x^{group}`. Must attach to a preceding character, so `3^rd` | |
| 79 | // works but a lone `^` does not. | |
| 80 | result = protectMatches( | |
| 81 | in: result, | |
| 82 | pattern: #"(?<=[A-Za-z0-9])\^(\{[^}]*\}|[A-Za-z0-9]+)"#, | |
| 83 | protectedFragments: &protectedFragments | |
| 84 | ) { match, nsText in | |
| 85 | var inner = nsText.substring(with: match.range(at: 1)) | |
| 86 | if inner.hasPrefix("{") && inner.hasSuffix("}") { | |
| 87 | inner = String(inner.dropFirst().dropLast()) | |
| 88 | } | |
| 89 | return "<sup>\(inner)</sup>" | |
| 90 | } | |
| 91 | ||
| 78 | 92 | // Bare URLs in running text become links. Bracketed `[[…]]` links are already |
| 79 | 93 | // protected above, so this only sees truly bare URLs; trailing sentence punctuation is |
| 80 | 94 | // left outside the link. |
Sources/OrgSwift/Links.swift +16 −2
| @@ -32,8 +32,13 @@ private func parseOrgLink( | ||
| 32 | 32 | guard text[start...].hasPrefix("[[") else { return nil } |
| 33 | 33 | |
| 34 | 34 | var index = text.index(start, offsetBy: 2) |
| 35 | guard let destinationEnd = text[index...].range(of: "][" )?.lowerBound else { | |
| 36 | guard let end = text[index...].range(of: "]]")?.lowerBound else { return nil } | |
| 35 | let descSeparator = text[index...].range(of: "][")?.lowerBound | |
| 36 | let plainClose = text[index...].range(of: "]]")?.lowerBound | |
| 37 | // A `][` only starts a description when it comes before this link's closing `]]`; | |
| 38 | // otherwise it belongs to a later link and this one has no description. | |
| 39 | guard let destinationEnd = descSeparator, | |
| 40 | plainClose == nil || destinationEnd < plainClose! else { | |
| 41 | guard let end = plainClose else { return nil } | |
| 37 | 42 | return (start..<text.index(end, offsetBy: 2), String(text[index..<end]), nil) |
| 38 | 43 | } |
| 39 | 44 | |
| @@ -75,6 +80,15 @@ private func renderOrgLink( | ||
| 75 | 80 | imageURLResolver: ((String) -> String?)? = nil, |
| 76 | 81 | linkURLResolver: ((String) -> String?)? = nil |
| 77 | 82 | ) -> String { |
| 83 | // `id:` links target a heading by its :ID:/:CUSTOM_ID:; org exports them as an | |
| 84 | // in-page fragment link, with the id itself as the text when there is no description. | |
| 85 | if rawDestination.hasPrefix("id:") { | |
| 86 | let id = String(rawDestination.dropFirst(3)) | |
| 87 | let text = label.map { | |
| 88 | processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) | |
| 89 | } ?? escapeHTML(id) | |
| 90 | return ##"<a href="#\##(escapeHTMLAttribute(id))">\##(text)</a>"## | |
| 91 | } | |
| 78 | 92 | let destination = normalizeOrgLinkTarget(rawDestination) |
| 79 | 93 | if let label, label.hasPrefix("[["), label.hasSuffix("]]") { |
| 80 | 94 | let source = String(label.dropFirst(2).dropLast(2)) |
Sources/OrgSwift/Lists.swift +123 −85
| @@ -10,118 +10,156 @@ func orderedListItem(in line: String) -> String? { | ||
| 10 | 10 | return String(match.2) |
| 11 | 11 | } |
| 12 | 12 | |
| 13 | func renderOrgListItemBody( | |
| 14 | _ lines: [String], | |
| 15 | imageURLResolver: ((String) -> String?)? = nil, | |
| 16 | linkURLResolver: ((String) -> String?)? = nil | |
| 17 | ) -> String { | |
| 18 | guard let firstLine = lines.first else { return "" } | |
| 19 | ||
| 20 | var contentLines: [String] = [firstLine.trimmingCharacters(in: .whitespaces)] | |
| 21 | var nestedLines: [String] = [] | |
| 22 | ||
| 23 | for line in lines.dropFirst() { | |
| 24 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 25 | if trimmed.isEmpty { | |
| 26 | continue | |
| 27 | } | |
| 28 | ||
| 29 | if isIndentedListItemLine(line) { | |
| 30 | nestedLines.append(outdentOrgListLine(line)) | |
| 31 | } else { | |
| 32 | contentLines.append(trimmed) | |
| 13 | /// Render a whole list block (all its lines, at any nesting depth) to HTML. The block is a | |
| 14 | /// run of list lines the caller has collected — top-level item markers plus every deeper or | |
| 15 | /// continuation line, including the blank lines between an item's paragraphs. | |
| 16 | func renderOrgList(_ lines: [String], inlineRenderer: (String) -> String) -> String { | |
| 17 | // Outdent the block so this level's markers sit at column 0. | |
| 18 | let base = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } | |
| 19 | .map(leadingSpaces).min() ?? 0 | |
| 20 | let norm = lines.map { dropLeadingSpaces($0, base) } | |
| 21 | ||
| 22 | // Group into items: each begins at a column-0 marker; deeper/continuation/blank lines | |
| 23 | // belong to the item above them. | |
| 24 | var items: [[String]] = [] | |
| 25 | var current: [String] = [] | |
| 26 | for line in norm { | |
| 27 | if isListMarkerLine(line) { | |
| 28 | if !current.isEmpty { items.append(current) } | |
| 29 | current = [line] | |
| 30 | } else if !current.isEmpty { | |
| 31 | current.append(line) | |
| 33 | 32 | } |
| 34 | 33 | } |
| 34 | if !current.isEmpty { items.append(current) } | |
| 35 | guard let firstMarker = items.first?.first else { return "" } | |
| 36 | ||
| 37 | if let dl = renderDescriptionList(items, inlineRenderer: inlineRenderer) { return dl } | |
| 35 | 38 | |
| 36 | var html = renderTaskListItem( | |
| 37 | contentLines.joined(separator: " "), | |
| 38 | inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) } | |
| 39 | ) | |
| 40 | if !nestedLines.isEmpty { | |
| 41 | html += "\n" + renderNestedOrgListHTML(nestedLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) | |
| 39 | let ordered = orderedListItem(in: firstMarker) != nil | |
| 40 | var html = ordered ? "<ol>\n" : "<ul>\n" | |
| 41 | for item in items { | |
| 42 | html += "<li>" + renderListItem(item, inlineRenderer: inlineRenderer) + "</li>\n" | |
| 42 | 43 | } |
| 44 | html += ordered ? "</ol>\n" : "</ul>\n" | |
| 43 | 45 | return html |
| 44 | 46 | } |
| 45 | 47 | |
| 46 | private func renderNestedOrgListHTML( | |
| 47 | _ lines: [String], | |
| 48 | imageURLResolver: ((String) -> String?)? = nil, | |
| 49 | linkURLResolver: ((String) -> String?)? = nil | |
| 50 | ) -> String { | |
| 51 | var html = "" | |
| 52 | var listType: OrgListType? | |
| 53 | var currentItemLines: [String] = [] | |
| 54 | ||
| 55 | func flushNestedItem() { | |
| 56 | guard !currentItemLines.isEmpty else { return } | |
| 57 | html += "<li>" + renderOrgListItemBody(currentItemLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</li>\n" | |
| 58 | currentItemLines = [] | |
| 59 | } | |
| 60 | ||
| 61 | func closeNestedList() { | |
| 62 | flushNestedItem() | |
| 63 | switch listType { | |
| 64 | case .unordered: | |
| 65 | html += "</ul>\n" | |
| 66 | case .ordered: | |
| 67 | html += "</ol>\n" | |
| 68 | case nil: | |
| 69 | break | |
| 70 | } | |
| 71 | listType = nil | |
| 48 | /// A single item's lines (the marker line plus everything under it) → the `<li>` body: | |
| 49 | /// the item's paragraph(s), then any nested list. Text is wrapped in `<p>` only when the | |
| 50 | /// item has more than one paragraph, matching org's exporter. | |
| 51 | private func renderListItem(_ lines: [String], inlineRenderer: (String) -> String) -> String { | |
| 52 | let head = stripListMarker(lines[0]) | |
| 53 | let rest = lines.dropFirst() | |
| 54 | let childIndent = rest.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } | |
| 55 | .map(leadingSpaces).min() ?? 0 | |
| 56 | let outdented = rest.map { dropLeadingSpaces($0, childIndent) } | |
| 57 | ||
| 58 | var paragraphs: [String] = [] | |
| 59 | var currentParagraph: [String] = [head] | |
| 60 | var sublistLines: [String] = [] | |
| 61 | var inSublist = false | |
| 62 | ||
| 63 | func flushParagraph() { | |
| 64 | let joined = currentParagraph.joined(separator: " ").trimmingCharacters(in: .whitespaces) | |
| 65 | if !joined.isEmpty { paragraphs.append(joined) } | |
| 66 | currentParagraph = [] | |
| 72 | 67 | } |
| 73 | 68 | |
| 74 | for line in lines { | |
| 69 | for line in outdented { | |
| 75 | 70 | let trimmed = line.trimmingCharacters(in: .whitespaces) |
| 76 | if trimmed.hasPrefix("- ") { | |
| 77 | if listType != .unordered { | |
| 78 | closeNestedList() | |
| 79 | html += "<ul>\n" | |
| 80 | listType = .unordered | |
| 81 | } | |
| 82 | flushNestedItem() | |
| 83 | currentItemLines = [String(trimmed.dropFirst(2))] | |
| 84 | continue | |
| 71 | if isListMarkerLine(line) || inSublist { | |
| 72 | // The first marker begins a nested list; everything after belongs to it. | |
| 73 | if !inSublist { flushParagraph() } | |
| 74 | inSublist = true | |
| 75 | sublistLines.append(line) | |
| 76 | } else if trimmed.isEmpty { | |
| 77 | flushParagraph() | |
| 78 | } else { | |
| 79 | currentParagraph.append(trimmed) | |
| 85 | 80 | } |
| 81 | } | |
| 82 | flushParagraph() | |
| 83 | ||
| 84 | var body: String | |
| 85 | if paragraphs.count <= 1 { | |
| 86 | body = renderTaskListItem(paragraphs.first ?? "", inlineRenderer: inlineRenderer) | |
| 87 | } else { | |
| 88 | body = paragraphs.enumerated().map { index, para in | |
| 89 | let rendered = index == 0 ? renderTaskListItem(para, inlineRenderer: inlineRenderer) : inlineRenderer(para) | |
| 90 | return "<p>" + rendered + "</p>" | |
| 91 | }.joined(separator: "\n") | |
| 92 | } | |
| 86 | 93 | |
| 87 | if let orderedItem = orderedListItem(in: trimmed) { | |
| 88 | if listType != .ordered { | |
| 89 | closeNestedList() | |
| 90 | html += "<ol>\n" | |
| 91 | listType = .ordered | |
| 92 | } | |
| 93 | flushNestedItem() | |
| 94 | currentItemLines = [orderedItem] | |
| 95 | continue | |
| 96 | } | |
| 94 | if !sublistLines.isEmpty { | |
| 95 | body += "\n" + renderOrgList(sublistLines, inlineRenderer: inlineRenderer) | |
| 96 | } | |
| 97 | return body | |
| 98 | } | |
| 97 | 99 | |
| 98 | if listType != nil { | |
| 99 | currentItemLines.append(line) | |
| 100 | /// If the items are a description list (`term :: definition`), render `<dl>`; otherwise nil. | |
| 101 | private func renderDescriptionList(_ items: [[String]], inlineRenderer: (String) -> String) -> String? { | |
| 102 | guard let first = items.first, stripListMarker(first[0]).contains(" :: ") else { return nil } | |
| 103 | var html = "<dl>\n" | |
| 104 | for item in items { | |
| 105 | let head = stripListMarker(item[0]) | |
| 106 | let continuation = item.dropFirst() | |
| 107 | .map { $0.trimmingCharacters(in: .whitespaces) } | |
| 108 | .filter { !$0.isEmpty } | |
| 109 | let full = ([head] + continuation).joined(separator: " ") | |
| 110 | if let range = full.range(of: " :: ") { | |
| 111 | html += "<dt>" + inlineRenderer(String(full[..<range.lowerBound])) + "</dt>\n" | |
| 112 | html += "<dd>" + inlineRenderer(String(full[range.upperBound...])) + "</dd>\n" | |
| 113 | } else { | |
| 114 | html += "<dt>" + inlineRenderer(full) + "</dt>\n" | |
| 100 | 115 | } |
| 101 | 116 | } |
| 102 | ||
| 103 | closeNestedList() | |
| 117 | html += "</dl>\n" | |
| 104 | 118 | return html |
| 105 | 119 | } |
| 106 | 120 | |
| 121 | /// True if a line (already outdented to its level) starts a list item at column 0. | |
| 122 | func isListMarkerLine(_ line: String) -> Bool { | |
| 123 | line.hasPrefix("- ") || line.hasPrefix("+ ") || orderedListItem(in: line) != nil | |
| 124 | } | |
| 125 | ||
| 126 | private func stripListMarker(_ line: String) -> String { | |
| 127 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 128 | if trimmed.hasPrefix("- ") || trimmed.hasPrefix("+ ") { | |
| 129 | return String(trimmed.dropFirst(2)) | |
| 130 | } | |
| 131 | if let match = trimmed.firstMatch(of: /^\d+[.)]\s+(.*)$/) { | |
| 132 | return String(match.1) | |
| 133 | } | |
| 134 | return trimmed | |
| 135 | } | |
| 136 | ||
| 107 | 137 | func isIndentedContinuationLine(_ line: String) -> Bool { |
| 108 | 138 | guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false } |
| 109 | 139 | guard let first = line.first else { return false } |
| 110 | 140 | return first == " " || first == "\t" |
| 111 | 141 | } |
| 112 | 142 | |
| 113 | private func isIndentedListItemLine(_ line: String) -> Bool { | |
| 114 | guard isIndentedContinuationLine(line) else { return false } | |
| 115 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 116 | return trimmed.hasPrefix("- ") || orderedListItem(in: trimmed) != nil | |
| 143 | private func leadingSpaces(_ line: String) -> Int { | |
| 144 | var count = 0 | |
| 145 | for ch in line { | |
| 146 | if ch == " " { count += 1 } | |
| 147 | else if ch == "\t" { count += 8 } | |
| 148 | else { break } | |
| 149 | } | |
| 150 | return count | |
| 117 | 151 | } |
| 118 | 152 | |
| 119 | private func outdentOrgListLine(_ line: String) -> String { | |
| 120 | var result = line | |
| 121 | while result.first == " " || result.first == "\t" { | |
| 122 | result.removeFirst() | |
| 153 | private func dropLeadingSpaces(_ line: String, _ n: Int) -> String { | |
| 154 | var dropped = 0 | |
| 155 | var index = line.startIndex | |
| 156 | while index < line.endIndex, dropped < n { | |
| 157 | if line[index] == " " { dropped += 1 } | |
| 158 | else if line[index] == "\t" { dropped += 8 } | |
| 159 | else { break } | |
| 160 | index = line.index(after: index) | |
| 123 | 161 | } |
| 124 | return result | |
| 162 | return String(line[index...]) | |
| 125 | 163 | } |
| 126 | 164 | |
| 127 | 165 | func renderTaskListItem( |
Sources/OrgSwift/OrgRenderer.swift +109 −49
| @@ -167,7 +167,13 @@ func orgToHTML( | ||
| 167 | 167 | var inExampleBlock = false |
| 168 | 168 | var inCenterBlock = false |
| 169 | 169 | var inVerseBlock = false |
| 170 | var currentListItemLines: [String] = [] | |
| 170 | var inExportBlock = false | |
| 171 | var exportIsHTML = false | |
| 172 | var exportLines: [String] = [] | |
| 173 | var inSpecialBlock = false | |
| 174 | var specialBlockName = "" | |
| 175 | var listBuffer: [String] = [] | |
| 176 | var pendingListBlanks: [String] = [] | |
| 171 | 177 | var paragraph: [String] = [] |
| 172 | 178 | var tableRows: [[String]] = [] |
| 173 | 179 | var propertyRows: [(String, String)] = [] |
| @@ -210,26 +216,14 @@ func orgToHTML( | ||
| 210 | 216 | } |
| 211 | 217 | } |
| 212 | 218 | |
| 213 | func flushListItem() { | |
| 214 | guard !currentListItemLines.isEmpty else { return } | |
| 215 | html += "<li>" + renderOrgListItemBody( | |
| 216 | currentListItemLines, | |
| 217 | imageURLResolver: imageURLResolver, | |
| 218 | linkURLResolver: linkURLResolver | |
| 219 | ) + "</li>\n" | |
| 220 | currentListItemLines = [] | |
| 221 | } | |
| 222 | ||
| 223 | 219 | func closeList() { |
| 224 | flushListItem() | |
| 225 | switch listType { | |
| 226 | case .unordered: | |
| 227 | html += "</ul>\n" | |
| 228 | case .ordered: | |
| 229 | html += "</ol>\n" | |
| 230 | case nil: | |
| 231 | break | |
| 220 | if !listBuffer.isEmpty { | |
| 221 | html += renderOrgList(listBuffer) { | |
| 222 | processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) | |
| 223 | } | |
| 232 | 224 | } |
| 225 | listBuffer = [] | |
| 226 | pendingListBlanks = [] | |
| 233 | 227 | listType = nil |
| 234 | 228 | } |
| 235 | 229 | |
| @@ -276,7 +270,7 @@ func orgToHTML( | ||
| 276 | 270 | |
| 277 | 271 | func closeExampleBlock() { |
| 278 | 272 | if inExampleBlock { |
| 279 | html += "</code></pre>\n" | |
| 273 | html += "</pre>\n" | |
| 280 | 274 | inExampleBlock = false |
| 281 | 275 | closePendingBlockWrapper() |
| 282 | 276 | } |
| @@ -295,10 +289,10 @@ func orgToHTML( | ||
| 295 | 289 | if inVerseBlock { |
| 296 | 290 | let content = verseLines |
| 297 | 291 | .map { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) } |
| 298 | .joined(separator: "\n") | |
| 299 | html += #"<blockquote class="org-verse">"# + "\n" | |
| 292 | .joined(separator: "<br>\n") | |
| 293 | html += #"<p class="verse">"# + "\n" | |
| 300 | 294 | html += content + "\n" |
| 301 | html += "</blockquote>\n" | |
| 295 | html += "</p>\n" | |
| 302 | 296 | verseLines = [] |
| 303 | 297 | inVerseBlock = false |
| 304 | 298 | closePendingBlockWrapper() |
| @@ -356,6 +350,34 @@ func orgToHTML( | ||
| 356 | 350 | continue |
| 357 | 351 | } |
| 358 | 352 | |
| 353 | if inExportBlock { | |
| 354 | if trimmed.lowercased() == "#+end_export" { | |
| 355 | if exportIsHTML { | |
| 356 | html += exportLines.joined(separator: "\n") + "\n" | |
| 357 | } | |
| 358 | inExportBlock = false | |
| 359 | exportLines = [] | |
| 360 | } else if exportIsHTML { | |
| 361 | // The `html` backend passes through verbatim; any other backend is dropped. | |
| 362 | exportLines.append(line) | |
| 363 | } | |
| 364 | continue | |
| 365 | } | |
| 366 | ||
| 367 | if inSpecialBlock { | |
| 368 | if trimmed.lowercased() == "#+end_\(specialBlockName)" { | |
| 369 | flushParagraph() | |
| 370 | html += "</div>\n" | |
| 371 | inSpecialBlock = false | |
| 372 | specialBlockName = "" | |
| 373 | } else if trimmed.isEmpty { | |
| 374 | flushParagraph() | |
| 375 | } else { | |
| 376 | paragraph.append(line) | |
| 377 | } | |
| 378 | continue | |
| 379 | } | |
| 380 | ||
| 359 | 381 | if inQuoteBlock, trimmed.lowercased() == "#+end_quote" { |
| 360 | 382 | closeQuoteBlock() |
| 361 | 383 | continue |
| @@ -387,10 +409,11 @@ func orgToHTML( | ||
| 387 | 409 | case "attr_html": |
| 388 | 410 | pendingAttrHtml = directive.value |
| 389 | 411 | continue |
| 390 | case "options", "property": | |
| 391 | continue | |
| 392 | 412 | default: |
| 393 | break | |
| 413 | // Any other `#+keyword:` line (OPTIONS, PROPERTY, FILETAGS, TBLFM, RESULTS, | |
| 414 | // …) is document metadata, not body content — org's exporter consumes it. | |
| 415 | // `#+begin_…`/`#+end_…` have no colon, so they are not matched here. | |
| 416 | continue | |
| 394 | 417 | } |
| 395 | 418 | } |
| 396 | 419 | |
| @@ -426,7 +449,8 @@ func orgToHTML( | ||
| 426 | 449 | } |
| 427 | 450 | |
| 428 | 451 | if trimmed.lowercased().hasPrefix("#+begin_src") { |
| 429 | closeQuoteBlock() | |
| 452 | // No closeQuoteBlock(): a source block can sit inside a quote, and org keeps it | |
| 453 | // there. flushBlockState() still ends any open paragraph, list, or table. | |
| 430 | 454 | flushBlockState() |
| 431 | 455 | beginPendingBlockWrapperIfNeeded() |
| 432 | 456 | let language = trimmed |
| @@ -446,7 +470,7 @@ func orgToHTML( | ||
| 446 | 470 | closeQuoteBlock() |
| 447 | 471 | flushBlockState() |
| 448 | 472 | beginPendingBlockWrapperIfNeeded() |
| 449 | html += "<pre><code>" | |
| 473 | html += "<pre>" | |
| 450 | 474 | inExampleBlock = true |
| 451 | 475 | continue |
| 452 | 476 | } |
| @@ -477,6 +501,35 @@ func orgToHTML( | ||
| 477 | 501 | continue |
| 478 | 502 | } |
| 479 | 503 | |
| 504 | if trimmed.lowercased().hasPrefix("#+begin_export") { | |
| 505 | closeQuoteBlock() | |
| 506 | flushBlockState() | |
| 507 | let backend = trimmed | |
| 508 | .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) | |
| 509 | .dropFirst().first.map(String.init)? | |
| 510 | .trimmingCharacters(in: .whitespaces).lowercased() | |
| 511 | inExportBlock = true | |
| 512 | exportIsHTML = backend == "html" | |
| 513 | exportLines = [] | |
| 514 | continue | |
| 515 | } | |
| 516 | ||
| 517 | // Any other `#+begin_<name>` is a special block: org renders it as a `<div>` carrying | |
| 518 | // the name as a class, with the contents parsed as ordinary org. Must stay last so | |
| 519 | // the specific block types above win. | |
| 520 | if trimmed.lowercased().hasPrefix("#+begin_") { | |
| 521 | closeQuoteBlock() | |
| 522 | flushBlockState() | |
| 523 | let name = String(trimmed.lowercased().dropFirst("#+begin_".count)) | |
| 524 | .split(separator: " ").first.map(String.init) ?? "" | |
| 525 | if !name.isEmpty { | |
| 526 | html += #"<div class="\#(escapeHTMLAttribute(name))">"# + "\n" | |
| 527 | inSpecialBlock = true | |
| 528 | specialBlockName = name | |
| 529 | continue | |
| 530 | } | |
| 531 | } | |
| 532 | ||
| 480 | 533 | if trimmed == ":PROPERTIES:" { |
| 481 | 534 | closeQuoteBlock() |
| 482 | 535 | flushBlockState() |
| @@ -543,38 +596,45 @@ func orgToHTML( | ||
| 543 | 596 | continue |
| 544 | 597 | } |
| 545 | 598 | |
| 546 | if listType != nil && isIndentedContinuationLine(line) { | |
| 547 | currentListItemLines.append(line) | |
| 599 | // Inside a list, hold blank lines instead of ending it: an item can have several | |
| 600 | // paragraphs, separated by blanks, before the next item or the list's end. | |
| 601 | if listType != nil, trimmed.isEmpty { | |
| 602 | pendingListBlanks.append(line) | |
| 548 | 603 | continue |
| 549 | 604 | } |
| 550 | 605 | |
| 551 | // List items: - item | |
| 552 | if !isIndentedContinuationLine(line), trimmed.hasPrefix("- ") { | |
| 553 | flushParagraph() | |
| 554 | flushPropertyDrawer() | |
| 555 | if listType != .unordered { | |
| 606 | // A list item marker at column 0 starts or continues a list. A different marker | |
| 607 | // type (ordered vs unordered) at the top level begins a separate list. | |
| 608 | if !isIndentedContinuationLine(line), isListMarkerLine(trimmed) { | |
| 609 | let newType: OrgListType = orderedListItem(in: trimmed) != nil ? .ordered : .unordered | |
| 610 | if listType == nil { | |
| 611 | flushParagraph() | |
| 612 | flushPropertyDrawer() | |
| 613 | } else if listType != newType { | |
| 556 | 614 | closeList() |
| 557 | html += "<ul>\n" | |
| 558 | listType = .unordered | |
| 615 | flushParagraph() | |
| 616 | flushPropertyDrawer() | |
| 559 | 617 | } |
| 560 | flushListItem() | |
| 561 | currentListItemLines = [String(trimmed.dropFirst(2))] | |
| 618 | listType = newType | |
| 619 | listBuffer.append(contentsOf: pendingListBlanks) | |
| 620 | pendingListBlanks = [] | |
| 621 | listBuffer.append(line) | |
| 562 | 622 | continue |
| 563 | 623 | } |
| 564 | 624 | |
| 565 | if !isIndentedContinuationLine(line), let orderedItem = orderedListItem(in: trimmed) { | |
| 566 | flushParagraph() | |
| 567 | flushPropertyDrawer() | |
| 568 | if listType != .ordered { | |
| 569 | closeList() | |
| 570 | html += "<ol>\n" | |
| 571 | listType = .ordered | |
| 572 | } | |
| 573 | flushListItem() | |
| 574 | currentListItemLines = [orderedItem] | |
| 625 | // A line indented under an open list is a continuation or a nested item. | |
| 626 | if listType != nil, isIndentedContinuationLine(line) { | |
| 627 | listBuffer.append(contentsOf: pendingListBlanks) | |
| 628 | pendingListBlanks = [] | |
| 629 | listBuffer.append(line) | |
| 575 | 630 | continue |
| 576 | 631 | } |
| 577 | 632 | |
| 633 | // Any other line ends an open list, then is processed normally below. | |
| 634 | if listType != nil { | |
| 635 | closeList() | |
| 636 | } | |
| 637 | ||
| 578 | 638 | // Blank line |
| 579 | 639 | if trimmed.isEmpty { |
| 580 | 640 | if inQuoteBlock { |
Tests/OrgSwiftTests/ConformanceTests.swift +4 −4
| @@ -50,16 +50,16 @@ private enum Expectation { | ||
| 50 | 50 | |
| 51 | 51 | private let expectations: [String: Expectation] = [ |
| 52 | 52 | "table": .matches, |
| 53 | "blocks": .diverges("example blocks wrap in <pre><code>; orgo uses bare <pre>"), | |
| 53 | "blocks": .matches, | |
| 54 | 54 | "core": .matches, |
| 55 | "elements": .diverges("unknown #+KEYWORD lines (e.g. #+FILETAGS) leak as paragraph text"), | |
| 55 | "elements": .matches, | |
| 56 | 56 | "footnote": .matches, |
| 57 | 57 | "headings": .matches, |
| 58 | 58 | "images": .matches, |
| 59 | "lists": .diverges("list nesting deeper than one level is not represented"), | |
| 59 | "lists": .matches, | |
| 60 | 60 | "minimal": .matches, |
| 61 | 61 | "outofscope": .diverges("out-of-scope constructs; #+INCLUDE and drawers leak — orgo may differ here too"), |
| 62 | "tblfm": .diverges("^ superscript is not rendered in table cells"), | |
| 62 | "tblfm": .matches, | |
| 63 | 63 | "timestamps": .matches, |
| 64 | 64 | ] |
| 65 | 65 | |
Tests/OrgSwiftTests/OrgRendererTests.swift +106 −4
| @@ -34,8 +34,9 @@ struct OrgRendererTests { | ||
| 34 | 34 | func orgExampleBlock() { |
| 35 | 35 | let html = render("#+begin_example\nhello world\n#+end_example") |
| 36 | 36 | |
| 37 | #expect(html.contains("<pre><code>")) | |
| 38 | #expect(html.contains("hello world")) | |
| 37 | // Example blocks are a bare <pre> (org's exporter uses no inner <code>). | |
| 38 | #expect(html.contains("<pre>hello world")) | |
| 39 | #expect(!html.contains("<pre><code>")) | |
| 39 | 40 | } |
| 40 | 41 | |
| 41 | 42 | @Test |
| @@ -82,8 +83,9 @@ struct OrgRendererTests { | ||
| 82 | 83 | #+end_verse |
| 83 | 84 | """) |
| 84 | 85 | |
| 85 | #expect(html.contains(#"<blockquote class="org-verse">"#)) | |
| 86 | #expect(html.contains("There is a line.")) | |
| 86 | // Verse renders as <p class="verse"> with <br> between lines, matching org. | |
| 87 | #expect(html.contains(#"<p class="verse">"#)) | |
| 88 | #expect(html.contains("There is a line.<br>")) | |
| 87 | 89 | #expect(html.contains("And an indented line.")) |
| 88 | 90 | #expect(!html.contains("#+begin_verse")) |
| 89 | 91 | } |
| @@ -359,4 +361,104 @@ struct OrgRendererTests { | ||
| 359 | 361 | #expect(html.contains("Figure 1: </span>First")) |
| 360 | 362 | #expect(html.contains("Figure 2: </span>Second")) |
| 361 | 363 | } |
| 364 | ||
| 365 | @Test | |
| 366 | func threeDeepNestedList() { | |
| 367 | let html = render(""" | |
| 368 | - outer | |
| 369 | - inner | |
| 370 | - deepest | |
| 371 | - second inner | |
| 372 | - second outer | |
| 373 | """) | |
| 374 | #expect(html.contains("<li>outer\n<ul>\n<li>inner\n<ul>\n<li>deepest</li>")) | |
| 375 | #expect(html.contains("<li>second inner</li>")) | |
| 376 | #expect(html.contains("<li>second outer</li>")) | |
| 377 | } | |
| 378 | ||
| 379 | @Test | |
| 380 | func nestedOrderedList() { | |
| 381 | let html = render(""" | |
| 382 | 1. first | |
| 383 | 2. second | |
| 384 | 1. point one | |
| 385 | 2. point two | |
| 386 | 3. third | |
| 387 | """) | |
| 388 | #expect(html.contains("<li>second\n<ol>\n<li>point one</li>")) | |
| 389 | #expect(html.contains("<li>third</li>")) | |
| 390 | } | |
| 391 | ||
| 392 | @Test | |
| 393 | func descriptionList() { | |
| 394 | let html = render(""" | |
| 395 | - term one :: first definition | |
| 396 | - /marked/ term :: holds inline markup | |
| 397 | """) | |
| 398 | #expect(html.contains("<dl>")) | |
| 399 | #expect(html.contains("<dt>term one</dt>")) | |
| 400 | #expect(html.contains("<dd>first definition</dd>")) | |
| 401 | #expect(html.contains("<dt><em>marked</em> term</dt>")) | |
| 402 | } | |
| 403 | ||
| 404 | @Test | |
| 405 | func multiParagraphListItem() { | |
| 406 | let html = render(""" | |
| 407 | - an item with two paragraphs | |
| 408 | ||
| 409 | the second paragraph | |
| 410 | ||
| 411 | - a plain sibling | |
| 412 | """) | |
| 413 | #expect(html.contains("<li><p>an item with two paragraphs</p>\n<p>the second paragraph</p></li>")) | |
| 414 | #expect(html.contains("<li>a plain sibling</li>")) | |
| 415 | } | |
| 416 | ||
| 417 | @Test | |
| 418 | func exportBlockHtmlPassesThroughLatexDropped() { | |
| 419 | let html = render(""" | |
| 420 | #+BEGIN_EXPORT html | |
| 421 | <aside>raw</aside> | |
| 422 | #+END_EXPORT | |
| 423 | ||
| 424 | #+BEGIN_EXPORT latex | |
| 425 | \\emph{dropped} | |
| 426 | #+END_EXPORT | |
| 427 | """) | |
| 428 | #expect(html.contains("<aside>raw</aside>")) | |
| 429 | #expect(!html.contains("dropped")) | |
| 430 | #expect(!html.contains("BEGIN_EXPORT")) | |
| 431 | } | |
| 432 | ||
| 433 | @Test | |
| 434 | func specialBlockRendersAsNamedDiv() { | |
| 435 | let html = render(""" | |
| 436 | #+BEGIN_NOTE | |
| 437 | Contents are *org*, not literal. | |
| 438 | #+END_NOTE | |
| 439 | """) | |
| 440 | #expect(html.contains(#"<div class="note">"#)) | |
| 441 | #expect(html.contains("<p>Contents are <strong>org</strong>, not literal.</p>")) | |
| 442 | } | |
| 443 | ||
| 444 | @Test | |
| 445 | func superscriptRenders() { | |
| 446 | #expect(render("N^2 here.").contains("N<sup>2</sup>")) | |
| 447 | #expect(render("H^{2O} molecule.").contains("H<sup>2O</sup>")) | |
| 448 | } | |
| 449 | ||
| 450 | @Test | |
| 451 | func idLinkBecomesFragment() { | |
| 452 | let html = render("See [[id:abc-123]] and [[id:x][the x]].") | |
| 453 | #expect(html.contains(##"<a href="#abc-123">abc-123</a>"##)) | |
| 454 | #expect(html.contains(##"<a href="#x">the x</a>"##)) | |
| 455 | } | |
| 456 | ||
| 457 | @Test | |
| 458 | func unknownKeywordLinesAreDropped() { | |
| 459 | let html = render("#+FILETAGS: :sample:\n\nBody.") | |
| 460 | #expect(!html.contains("FILETAGS")) | |
| 461 | #expect(!html.contains("sample")) | |
| 462 | #expect(html.contains("<p>Body.</p>")) | |
| 463 | } | |
| 362 | 464 | } |