Commit eb2beb19c9
Unsigned
Layout: unified · split
GAPS.md +18 −15
| @@ -5,21 +5,12 @@ 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, **7 match orgo exactly (`table`, `timestamps`, `footnote`, | 8 | Of the 12 corpus cases, **11 match orgo exactly** — every case except `outofscope`, which is |
| 9 | `minimal`, `headings`, `core`, `images`)** and 5 diverge. Each divergence below is a missing | 9 | `scope: out` in the corpus (deliberately unsupported constructs like `#+INCLUDE`, babel, and |
| 10 | capability, recorded in the `expectations` map in | 10 | LaTeX; orgo may also differ there, so it is documentation, not a target). The per-case state |
| 11 | `Tests/OrgSwiftTests/ConformanceTests.swift`. When one is closed, its case flips to matching | 11 | is recorded in the `expectations` map in `Tests/OrgSwiftTests/ConformanceTests.swift`: a case |
| 12 | and the test fails until it is moved to `.matches` — that is how this list stays honest. | 12 | that regresses, or `outofscope` starting to match, fails the test and forces the map to be |
| 13 | 13 | updated — that is how this list stays honest. | |
| 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. | | ||
| 23 | 14 | ||
| 24 | Two behaviors were made configurable during extraction rather than left hardcoded, because | 15 | Two behaviors were made configurable during extraction rather than left hardcoded, because |
| 25 | they are presentation policy, not parser capability: | 16 | they are presentation policy, not parser capability: |
| @@ -56,3 +47,15 @@ they are presentation policy, not parser capability: | |||
| 56 | promotes it to a `<figure>`, with a numbered `<figcaption>` when a caption is present. | 47 | promotes it to a `<figure>`, with a numbered `<figcaption>` when a caption is present. |
| 57 | `#+ATTR_HTML` `:key value` pairs (quoted values honored) become `<img>` attributes, and | 48 | `#+ATTR_HTML` `:key value` pairs (quoted values honored) become `<img>` attributes, and |
| 58 | a described image link (`[[file:x.png][text]]`) renders as a link, not an inline image. | 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 | ## Supported syntax | 10 | ## Supported syntax |
| 11 | 11 | ||
| 12 | Headings (with trailing `:tags:`), paragraphs, ordered/unordered lists (with | 12 | Headings (with trailing `:tags:`), paragraphs, ordered/unordered lists (with |
| 13 | nesting, wrapped lines, and `[ ]`/`[x]` task checkboxes), tables (with `:---:` | 13 | arbitrary-depth nesting, wrapped lines, and `[ ]`/`[x]`/`[-]` task checkboxes), |
| 14 | alignment), `#+begin_src` / `example` / `quote` / `center` / `verse` blocks, | 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 | `#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+ATTR_HTML`/`#+NAME` | 17 | `#+TITLE`/`#+AUTHOR`/`#+DATE` metadata, `#+CAPTION`/`#+ATTR_HTML`/`#+NAME` |
| 16 | figures, org links and images (`[[file:x.png]]` → `<img>`, `[[dest][label]]` → | 18 | figures, org links and images (`[[file:x.png]]` → `<img>`, `[[dest][label]]` → |
| 17 | link), horizontal rules, comments, timestamps (`<2024-01-15 Mon>`, inactive, | 19 | link, `[[id:target]]` → `#target`), horizontal rules, comments, timestamps |
| 18 | times, and ranges → `<time>`), footnotes (references, inline, and definitions → | 20 | (`<2024-01-15 Mon>`, inactive, times, and ranges → `<time>`), footnotes |
| 19 | a `<section class="footnotes">`), and inline markup (`*bold*`, `/italic/`, | 21 | (references, inline, and definitions → a `<section class="footnotes">`), and |
| 20 | `~code~`, `=verbatim=`, `+strike+`, `_underline_`, bare-URL and email autolinks). | 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 | Output is sanitized: only `http`/`https`/`mailto` link schemes and | 26 | Output is sanitized: only `http`/`https`/`mailto` link schemes and |
| 23 | `http`/`https` image schemes are allowed; everything else is dropped. | 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 | // Bare URLs in running text become links. Bracketed `[[…]]` links are already | 92 | // Bare URLs in running text become links. Bracketed `[[…]]` links are already |
| 79 | // protected above, so this only sees truly bare URLs; trailing sentence punctuation is | 93 | // protected above, so this only sees truly bare URLs; trailing sentence punctuation is |
| 80 | // left outside the link. | 94 | // left outside the link. |
Sources/OrgSwift/Links.swift +16 −2
| @@ -32,8 +32,13 @@ private func parseOrgLink( | |||
| 32 | guard text[start...].hasPrefix("[[") else { return nil } | 32 | guard text[start...].hasPrefix("[[") else { return nil } |
| 33 | 33 | ||
| 34 | var index = text.index(start, offsetBy: 2) | 34 | var index = text.index(start, offsetBy: 2) |
| 35 | guard let destinationEnd = text[index...].range(of: "][" )?.lowerBound else { | 35 | let descSeparator = text[index...].range(of: "][")?.lowerBound |
| 36 | guard let end = text[index...].range(of: "]]")?.lowerBound else { return nil } | 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 | return (start..<text.index(end, offsetBy: 2), String(text[index..<end]), nil) | 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 | imageURLResolver: ((String) -> String?)? = nil, | 80 | imageURLResolver: ((String) -> String?)? = nil, |
| 76 | linkURLResolver: ((String) -> String?)? = nil | 81 | linkURLResolver: ((String) -> String?)? = nil |
| 77 | ) -> String { | 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 | let destination = normalizeOrgLinkTarget(rawDestination) | 92 | let destination = normalizeOrgLinkTarget(rawDestination) |
| 79 | if let label, label.hasPrefix("[["), label.hasSuffix("]]") { | 93 | if let label, label.hasPrefix("[["), label.hasSuffix("]]") { |
| 80 | let source = String(label.dropFirst(2).dropLast(2)) | 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 | return String(match.2) | 10 | return String(match.2) |
| 11 | } | 11 | } |
| 12 | 12 | ||
| 13 | func renderOrgListItemBody( | 13 | /// Render a whole list block (all its lines, at any nesting depth) to HTML. The block is a |
| 14 | _ lines: [String], | 14 | /// run of list lines the caller has collected — top-level item markers plus every deeper or |
| 15 | imageURLResolver: ((String) -> String?)? = nil, | 15 | /// continuation line, including the blank lines between an item's paragraphs. |
| 16 | linkURLResolver: ((String) -> String?)? = nil | 16 | func renderOrgList(_ lines: [String], inlineRenderer: (String) -> String) -> String { |
| 17 | ) -> String { | 17 | // Outdent the block so this level's markers sit at column 0. |
| 18 | guard let firstLine = lines.first else { return "" } | 18 | let base = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } |
| 19 | 19 | .map(leadingSpaces).min() ?? 0 | |
| 20 | var contentLines: [String] = [firstLine.trimmingCharacters(in: .whitespaces)] | 20 | let norm = lines.map { dropLeadingSpaces($0, base) } |
| 21 | var nestedLines: [String] = [] | 21 | |
| 22 | 22 | // Group into items: each begins at a column-0 marker; deeper/continuation/blank lines | |
| 23 | for line in lines.dropFirst() { | 23 | // belong to the item above them. |
| 24 | let trimmed = line.trimmingCharacters(in: .whitespaces) | 24 | var items: [[String]] = [] |
| 25 | if trimmed.isEmpty { | 25 | var current: [String] = [] |
| 26 | continue | 26 | for line in norm { |
| 27 | } | 27 | if isListMarkerLine(line) { |
| 28 | 28 | if !current.isEmpty { items.append(current) } | |
| 29 | if isIndentedListItemLine(line) { | 29 | current = [line] |
| 30 | nestedLines.append(outdentOrgListLine(line)) | 30 | } else if !current.isEmpty { |
| 31 | } else { | 31 | current.append(line) |
| 32 | contentLines.append(trimmed) | ||
| 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( | 39 | let ordered = orderedListItem(in: firstMarker) != nil |
| 37 | contentLines.joined(separator: " "), | 40 | var html = ordered ? "<ol>\n" : "<ul>\n" |
| 38 | inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) } | 41 | for item in items { |
| 39 | ) | 42 | html += "<li>" + renderListItem(item, inlineRenderer: inlineRenderer) + "</li>\n" |
| 40 | if !nestedLines.isEmpty { | ||
| 41 | html += "\n" + renderNestedOrgListHTML(nestedLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) | ||
| 42 | } | 43 | } |
| 44 | html += ordered ? "</ol>\n" : "</ul>\n" | ||
| 43 | return html | 45 | return html |
| 44 | } | 46 | } |
| 45 | 47 | ||
| 46 | private func renderNestedOrgListHTML( | 48 | /// A single item's lines (the marker line plus everything under it) → the `<li>` body: |
| 47 | _ lines: [String], | 49 | /// the item's paragraph(s), then any nested list. Text is wrapped in `<p>` only when the |
| 48 | imageURLResolver: ((String) -> String?)? = nil, | 50 | /// item has more than one paragraph, matching org's exporter. |
| 49 | linkURLResolver: ((String) -> String?)? = nil | 51 | private func renderListItem(_ lines: [String], inlineRenderer: (String) -> String) -> String { |
| 50 | ) -> String { | 52 | let head = stripListMarker(lines[0]) |
| 51 | var html = "" | 53 | let rest = lines.dropFirst() |
| 52 | var listType: OrgListType? | 54 | let childIndent = rest.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } |
| 53 | var currentItemLines: [String] = [] | 55 | .map(leadingSpaces).min() ?? 0 |
| 54 | 56 | let outdented = rest.map { dropLeadingSpaces($0, childIndent) } | |
| 55 | func flushNestedItem() { | 57 | |
| 56 | guard !currentItemLines.isEmpty else { return } | 58 | var paragraphs: [String] = [] |
| 57 | html += "<li>" + renderOrgListItemBody(currentItemLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</li>\n" | 59 | var currentParagraph: [String] = [head] |
| 58 | currentItemLines = [] | 60 | var sublistLines: [String] = [] |
| 59 | } | 61 | var inSublist = false |
| 60 | 62 | ||
| 61 | func closeNestedList() { | 63 | func flushParagraph() { |
| 62 | flushNestedItem() | 64 | let joined = currentParagraph.joined(separator: " ").trimmingCharacters(in: .whitespaces) |
| 63 | switch listType { | 65 | if !joined.isEmpty { paragraphs.append(joined) } |
| 64 | case .unordered: | 66 | currentParagraph = [] |
| 65 | html += "</ul>\n" | ||
| 66 | case .ordered: | ||
| 67 | html += "</ol>\n" | ||
| 68 | case nil: | ||
| 69 | break | ||
| 70 | } | ||
| 71 | listType = nil | ||
| 72 | } | 67 | } |
| 73 | 68 | ||
| 74 | for line in lines { | 69 | for line in outdented { |
| 75 | let trimmed = line.trimmingCharacters(in: .whitespaces) | 70 | let trimmed = line.trimmingCharacters(in: .whitespaces) |
| 76 | if trimmed.hasPrefix("- ") { | 71 | if isListMarkerLine(line) || inSublist { |
| 77 | if listType != .unordered { | 72 | // The first marker begins a nested list; everything after belongs to it. |
| 78 | closeNestedList() | 73 | if !inSublist { flushParagraph() } |
| 79 | html += "<ul>\n" | 74 | inSublist = true |
| 80 | listType = .unordered | 75 | sublistLines.append(line) |
| 81 | } | 76 | } else if trimmed.isEmpty { |
| 82 | flushNestedItem() | 77 | flushParagraph() |
| 83 | currentItemLines = [String(trimmed.dropFirst(2))] | 78 | } else { |
| 84 | continue | 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) { | 94 | if !sublistLines.isEmpty { |
| 88 | if listType != .ordered { | 95 | body += "\n" + renderOrgList(sublistLines, inlineRenderer: inlineRenderer) |
| 89 | closeNestedList() | 96 | } |
| 90 | html += "<ol>\n" | 97 | return body |
| 91 | listType = .ordered | 98 | } |
| 92 | } | ||
| 93 | flushNestedItem() | ||
| 94 | currentItemLines = [orderedItem] | ||
| 95 | continue | ||
| 96 | } | ||
| 97 | 99 | ||
| 98 | if listType != nil { | 100 | /// If the items are a description list (`term :: definition`), render `<dl>`; otherwise nil. |
| 99 | currentItemLines.append(line) | 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 | 117 | html += "</dl>\n" | |
| 103 | closeNestedList() | ||
| 104 | return html | 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 | func isIndentedContinuationLine(_ line: String) -> Bool { | 137 | func isIndentedContinuationLine(_ line: String) -> Bool { |
| 108 | guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false } | 138 | guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false } |
| 109 | guard let first = line.first else { return false } | 139 | guard let first = line.first else { return false } |
| 110 | return first == " " || first == "\t" | 140 | return first == " " || first == "\t" |
| 111 | } | 141 | } |
| 112 | 142 | ||
| 113 | private func isIndentedListItemLine(_ line: String) -> Bool { | 143 | private func leadingSpaces(_ line: String) -> Int { |
| 114 | guard isIndentedContinuationLine(line) else { return false } | 144 | var count = 0 |
| 115 | let trimmed = line.trimmingCharacters(in: .whitespaces) | 145 | for ch in line { |
| 116 | return trimmed.hasPrefix("- ") || orderedListItem(in: trimmed) != nil | 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 { | 153 | private func dropLeadingSpaces(_ line: String, _ n: Int) -> String { |
| 120 | var result = line | 154 | var dropped = 0 |
| 121 | while result.first == " " || result.first == "\t" { | 155 | var index = line.startIndex |
| 122 | result.removeFirst() | 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 | func renderTaskListItem( | 165 | func renderTaskListItem( |
Sources/OrgSwift/OrgRenderer.swift +109 −49
| @@ -167,7 +167,13 @@ func orgToHTML( | |||
| 167 | var inExampleBlock = false | 167 | var inExampleBlock = false |
| 168 | var inCenterBlock = false | 168 | var inCenterBlock = false |
| 169 | var inVerseBlock = false | 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 | var paragraph: [String] = [] | 177 | var paragraph: [String] = [] |
| 172 | var tableRows: [[String]] = [] | 178 | var tableRows: [[String]] = [] |
| 173 | var propertyRows: [(String, String)] = [] | 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 | func closeList() { | 219 | func closeList() { |
| 224 | flushListItem() | 220 | if !listBuffer.isEmpty { |
| 225 | switch listType { | 221 | html += renderOrgList(listBuffer) { |
| 226 | case .unordered: | 222 | processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) |
| 227 | html += "</ul>\n" | 223 | } |
| 228 | case .ordered: | ||
| 229 | html += "</ol>\n" | ||
| 230 | case nil: | ||
| 231 | break | ||
| 232 | } | 224 | } |
| 225 | listBuffer = [] | ||
| 226 | pendingListBlanks = [] | ||
| 233 | listType = nil | 227 | listType = nil |
| 234 | } | 228 | } |
| 235 | 229 | ||
| @@ -276,7 +270,7 @@ func orgToHTML( | |||
| 276 | 270 | ||
| 277 | func closeExampleBlock() { | 271 | func closeExampleBlock() { |
| 278 | if inExampleBlock { | 272 | if inExampleBlock { |
| 279 | html += "</code></pre>\n" | 273 | html += "</pre>\n" |
| 280 | inExampleBlock = false | 274 | inExampleBlock = false |
| 281 | closePendingBlockWrapper() | 275 | closePendingBlockWrapper() |
| 282 | } | 276 | } |
| @@ -295,10 +289,10 @@ func orgToHTML( | |||
| 295 | if inVerseBlock { | 289 | if inVerseBlock { |
| 296 | let content = verseLines | 290 | let content = verseLines |
| 297 | .map { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) } | 291 | .map { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) } |
| 298 | .joined(separator: "\n") | 292 | .joined(separator: "<br>\n") |
| 299 | html += #"<blockquote class="org-verse">"# + "\n" | 293 | html += #"<p class="verse">"# + "\n" |
| 300 | html += content + "\n" | 294 | html += content + "\n" |
| 301 | html += "</blockquote>\n" | 295 | html += "</p>\n" |
| 302 | verseLines = [] | 296 | verseLines = [] |
| 303 | inVerseBlock = false | 297 | inVerseBlock = false |
| 304 | closePendingBlockWrapper() | 298 | closePendingBlockWrapper() |
| @@ -356,6 +350,34 @@ func orgToHTML( | |||
| 356 | continue | 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 | if inQuoteBlock, trimmed.lowercased() == "#+end_quote" { | 381 | if inQuoteBlock, trimmed.lowercased() == "#+end_quote" { |
| 360 | closeQuoteBlock() | 382 | closeQuoteBlock() |
| 361 | continue | 383 | continue |
| @@ -387,10 +409,11 @@ func orgToHTML( | |||
| 387 | case "attr_html": | 409 | case "attr_html": |
| 388 | pendingAttrHtml = directive.value | 410 | pendingAttrHtml = directive.value |
| 389 | continue | 411 | continue |
| 390 | case "options", "property": | ||
| 391 | continue | ||
| 392 | default: | 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 | if trimmed.lowercased().hasPrefix("#+begin_src") { | 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 | flushBlockState() | 454 | flushBlockState() |
| 431 | beginPendingBlockWrapperIfNeeded() | 455 | beginPendingBlockWrapperIfNeeded() |
| 432 | let language = trimmed | 456 | let language = trimmed |
| @@ -446,7 +470,7 @@ func orgToHTML( | |||
| 446 | closeQuoteBlock() | 470 | closeQuoteBlock() |
| 447 | flushBlockState() | 471 | flushBlockState() |
| 448 | beginPendingBlockWrapperIfNeeded() | 472 | beginPendingBlockWrapperIfNeeded() |
| 449 | html += "<pre><code>" | 473 | html += "<pre>" |
| 450 | inExampleBlock = true | 474 | inExampleBlock = true |
| 451 | continue | 475 | continue |
| 452 | } | 476 | } |
| @@ -477,6 +501,35 @@ func orgToHTML( | |||
| 477 | continue | 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 | if trimmed == ":PROPERTIES:" { | 533 | if trimmed == ":PROPERTIES:" { |
| 481 | closeQuoteBlock() | 534 | closeQuoteBlock() |
| 482 | flushBlockState() | 535 | flushBlockState() |
| @@ -543,38 +596,45 @@ func orgToHTML( | |||
| 543 | continue | 596 | continue |
| 544 | } | 597 | } |
| 545 | 598 | ||
| 546 | if listType != nil && isIndentedContinuationLine(line) { | 599 | // Inside a list, hold blank lines instead of ending it: an item can have several |
| 547 | currentListItemLines.append(line) | 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 | continue | 603 | continue |
| 549 | } | 604 | } |
| 550 | 605 | ||
| 551 | // List items: - item | 606 | // A list item marker at column 0 starts or continues a list. A different marker |
| 552 | if !isIndentedContinuationLine(line), trimmed.hasPrefix("- ") { | 607 | // type (ordered vs unordered) at the top level begins a separate list. |
| 553 | flushParagraph() | 608 | if !isIndentedContinuationLine(line), isListMarkerLine(trimmed) { |
| 554 | flushPropertyDrawer() | 609 | let newType: OrgListType = orderedListItem(in: trimmed) != nil ? .ordered : .unordered |
| 555 | if listType != .unordered { | 610 | if listType == nil { |
| 611 | flushParagraph() | ||
| 612 | flushPropertyDrawer() | ||
| 613 | } else if listType != newType { | ||
| 556 | closeList() | 614 | closeList() |
| 557 | html += "<ul>\n" | 615 | flushParagraph() |
| 558 | listType = .unordered | 616 | flushPropertyDrawer() |
| 559 | } | 617 | } |
| 560 | flushListItem() | 618 | listType = newType |
| 561 | currentListItemLines = [String(trimmed.dropFirst(2))] | 619 | listBuffer.append(contentsOf: pendingListBlanks) |
| 620 | pendingListBlanks = [] | ||
| 621 | listBuffer.append(line) | ||
| 562 | continue | 622 | continue |
| 563 | } | 623 | } |
| 564 | 624 | ||
| 565 | if !isIndentedContinuationLine(line), let orderedItem = orderedListItem(in: trimmed) { | 625 | // A line indented under an open list is a continuation or a nested item. |
| 566 | flushParagraph() | 626 | if listType != nil, isIndentedContinuationLine(line) { |
| 567 | flushPropertyDrawer() | 627 | listBuffer.append(contentsOf: pendingListBlanks) |
| 568 | if listType != .ordered { | 628 | pendingListBlanks = [] |
| 569 | closeList() | 629 | listBuffer.append(line) |
| 570 | html += "<ol>\n" | ||
| 571 | listType = .ordered | ||
| 572 | } | ||
| 573 | flushListItem() | ||
| 574 | currentListItemLines = [orderedItem] | ||
| 575 | continue | 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 | // Blank line | 638 | // Blank line |
| 579 | if trimmed.isEmpty { | 639 | if trimmed.isEmpty { |
| 580 | if inQuoteBlock { | 640 | if inQuoteBlock { |
Tests/OrgSwiftTests/ConformanceTests.swift +4 −4
| @@ -50,16 +50,16 @@ private enum Expectation { | |||
| 50 | 50 | ||
| 51 | private let expectations: [String: Expectation] = [ | 51 | private let expectations: [String: Expectation] = [ |
| 52 | "table": .matches, | 52 | "table": .matches, |
| 53 | "blocks": .diverges("example blocks wrap in <pre><code>; orgo uses bare <pre>"), | 53 | "blocks": .matches, |
| 54 | "core": .matches, | 54 | "core": .matches, |
| 55 | "elements": .diverges("unknown #+KEYWORD lines (e.g. #+FILETAGS) leak as paragraph text"), | 55 | "elements": .matches, |
| 56 | "footnote": .matches, | 56 | "footnote": .matches, |
| 57 | "headings": .matches, | 57 | "headings": .matches, |
| 58 | "images": .matches, | 58 | "images": .matches, |
| 59 | "lists": .diverges("list nesting deeper than one level is not represented"), | 59 | "lists": .matches, |
| 60 | "minimal": .matches, | 60 | "minimal": .matches, |
| 61 | "outofscope": .diverges("out-of-scope constructs; #+INCLUDE and drawers leak — orgo may differ here too"), | 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 | "timestamps": .matches, | 63 | "timestamps": .matches, |
| 64 | ] | 64 | ] |
| 65 | 65 | ||
Tests/OrgSwiftTests/OrgRendererTests.swift +106 −4
| @@ -34,8 +34,9 @@ struct OrgRendererTests { | |||
| 34 | func orgExampleBlock() { | 34 | func orgExampleBlock() { |
| 35 | let html = render("#+begin_example\nhello world\n#+end_example") | 35 | let html = render("#+begin_example\nhello world\n#+end_example") |
| 36 | 36 | ||
| 37 | #expect(html.contains("<pre><code>")) | 37 | // Example blocks are a bare <pre> (org's exporter uses no inner <code>). |
| 38 | #expect(html.contains("hello world")) | 38 | #expect(html.contains("<pre>hello world")) |
| 39 | #expect(!html.contains("<pre><code>")) | ||
| 39 | } | 40 | } |
| 40 | 41 | ||
| 41 | @Test | 42 | @Test |
| @@ -82,8 +83,9 @@ struct OrgRendererTests { | |||
| 82 | #+end_verse | 83 | #+end_verse |
| 83 | """) | 84 | """) |
| 84 | 85 | ||
| 85 | #expect(html.contains(#"<blockquote class="org-verse">"#)) | 86 | // Verse renders as <p class="verse"> with <br> between lines, matching org. |
| 86 | #expect(html.contains("There is a line.")) | 87 | #expect(html.contains(#"<p class="verse">"#)) |
| 88 | #expect(html.contains("There is a line.<br>")) | ||
| 87 | #expect(html.contains("And an indented line.")) | 89 | #expect(html.contains("And an indented line.")) |
| 88 | #expect(!html.contains("#+begin_verse")) | 90 | #expect(!html.contains("#+begin_verse")) |
| 89 | } | 91 | } |
| @@ -359,4 +361,104 @@ struct OrgRendererTests { | |||
| 359 | #expect(html.contains("Figure 1: </span>First")) | 361 | #expect(html.contains("Figure 1: </span>First")) |
| 360 | #expect(html.contains("Figure 2: </span>Second")) | 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 | } |