Commit 120040d8c7
Verified · cmc
ARCHITECTURE.md added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | # Architecture | |
| 2 | ||
| 3 | OrgSwift parses org into an element tree and renders that tree. Parsing happens once; each | |
| 4 | output format is a walk over the result. | |
| 5 | ||
| 6 | ``` | |
| 7 | Sources/OrgSwift/ | |
| 8 | AST/ | |
| 9 | OrgDocument.swift the element tree | |
| 10 | OrgParser.swift source → OrgDocument (blocks) | |
| 11 | OrgInlineParser.swift text → [OrgObject] (inlines) | |
| 12 | OrgHTMLTreeRenderer.swift OrgDocument → HTML | |
| 13 | OrgAttributedStringRenderer.swift [OrgObject] → AttributedString | |
| 14 | OrgRenderer.swift the public entry point, plus OrgRenderOptions | |
| 15 | Escaping.swift, Links.swift, … shared helpers: escaping, URL sanitising and | |
| 16 | repository-relative resolution, line predicates | |
| 17 | Skeleton.swift the HTML → semantic-skeleton reduction used by tests | |
| 18 | ``` | |
| 19 | ||
| 20 | `OrgRenderer.renderToHTML` is a thin façade over `OrgParser.parse` + `OrgHTMLTreeRenderer`. | |
| 21 | It, `OrgRenderOptions`, and `CodeHighlighter` are the whole public API most callers need. | |
| 22 | ||
| 23 | ## Why a tree | |
| 24 | ||
| 25 | The renderer this replaced went from source straight to an HTML string in a single pass, | |
| 26 | doing inline work by regex-substituting markup into escaped text and protecting the results | |
| 27 | with placeholder tokens. That worked, but it baked HTML into the parse: any second output | |
| 28 | format would have meant re-deriving the parse rather than reusing it. | |
| 29 | ||
| 30 | With a tree, `OrgAttributedStringRenderer` is roughly 140 lines and shares the parse | |
| 31 | entirely. It is **Foundation-only** — no SwiftUI — so it works server-side, in a CLI, | |
| 32 | anywhere; a SwiftUI layer would sit on top of it for the inline runs inside each block. | |
| 33 | ||
| 34 | The types mirror orgo's `model.rs` — `OrgElement`/`OrgObject` against orgo's | |
| 35 | `Element`/`Object`, `OrgTableRow.{cells,rule}` against `TableRow::{Cells,Rule}`, the same | |
| 36 | `ListKind`/`Checkbox` vocabulary. That makes future *tree-level* conformance possible: today | |
| 37 | the corpus compares rendered HTML reduced to a skeleton, which is a string-level proxy for | |
| 38 | "do these two agree on structure", and matching trees would let that question be asked | |
| 39 | directly. | |
| 40 | ||
| 41 | Resolution is simpler on a tree, too. Link targets stay typed — `.external`, `.file`, `.id` | |
| 42 | — so repository-relative resolution applies to the `.file` case in the renderer instead of a | |
| 43 | resolver closure threaded through the parse. An unsafe or unresolvable target degrades to its | |
| 44 | text rather than becoming a bad anchor. | |
| 45 | ||
| 46 | ## Conformance | |
| 47 | ||
| 48 | Measured against the [org-conformance](../org-conformance) corpus, whose goldens come from | |
| 49 | orgo (itself diffed against Emacs `ox-html`), reduced by the shared skeleton algorithm: | |
| 50 | ||
| 51 | **11 / 12** — every case except `outofscope`, which is `scope: out` in the corpus | |
| 52 | (deliberately unsupported constructs, where orgo itself may differ). | |
| 53 | ||
| 54 | `ConformanceTests` gates this: a regression fails, and `outofscope` starting to match fails | |
| 55 | too, forcing the record in its expectations map to be updated. `GAPS.md` carries the detail. | |
| 56 | ||
| 57 | ## Notes from the migration | |
| 58 | ||
| 59 | The tree renderer replaced the single-pass one only after it was shown to be equivalent: | |
| 60 | every corpus case, each option combination, identical URL resolution, unsafe-scheme | |
| 61 | rejection, and a 28-construct battery drawn from the old renderer's own test inputs. That | |
| 62 | battery earned its keep — it caught three behaviours the corpus never reaches | |
| 63 | (`#+CAPTION:`/`#+NAME:` wrapping a non-image block in `<figure class="org-block">`, the | |
| 64 | nested `[[dest][[img]]]` badge form, and bare email autolinks), each of which would otherwise | |
| 65 | have regressed a consumer. | |
| 66 | ||
| 67 | One behaviour changed on purpose. A range whose halves are *inactive* timestamps, `[a]--[b]`, | |
| 68 | is now joined into one range; the old renderer joined active ranges only and left `--` as | |
| 69 | text. orgo applies the `--` rule to both bracket kinds, requiring only that the halves agree | |
| 70 | on activeness, so the new behaviour is the more correct one. It is asserted directly in | |
| 71 | `joinsInactiveTimestampRanges`. | |
| 72 | ||
| 73 | ## Next | |
| 74 | ||
| 75 | A SwiftUI renderer belongs in a **separate product** depending on this one, so the parser and | |
| 76 | HTML renderer stay Foundation-only and callers who want HTML never import SwiftUI. Tables are | |
| 77 | the interesting part: `Grid`/`GridRow` with `.gridColumnAlignment()`, wrapped in a horizontal | |
| 78 | `ScrollView` for phone-width overflow — the approach MarkdownUI uses. `OrgTable` already | |
| 79 | carries the rows, the rule position, and the per-column alignments that needs. | |
AST-PROTOTYPE.md deleted −127
| @@ -1,127 +0,0 @@ | ||
| 1 | # AST split — prototype | |
| 2 | ||
| 3 | A working prototype of `source → OrgDocument → {renderers}`, on the `ast-prototype` branch. | |
| 4 | Nothing shipped changes: `OrgRenderer` (the single-pass source→HTML renderer hutch and | |
| 5 | gitbay-ios use) is untouched, and its 11/12 corpus conformance is unaffected. This is a | |
| 6 | parallel pipeline built to answer one question — *is a second output format a walk over a | |
| 7 | tree, or a second parser?* | |
| 8 | ||
| 9 | ## What's here | |
| 10 | ||
| 11 | ``` | |
| 12 | Sources/OrgSwift/AST/ | |
| 13 | OrgDocument.swift the element tree | |
| 14 | OrgParser.swift source → OrgDocument (blocks) | |
| 15 | OrgInlineParser.swift text → [OrgObject] (inlines) | |
| 16 | OrgHTMLTreeRenderer.swift OrgDocument → HTML | |
| 17 | OrgAttributedStringRenderer.swift [OrgObject] → AttributedString | |
| 18 | ``` | |
| 19 | ||
| 20 | The types deliberately mirror orgo's `model.rs` — `OrgElement`/`OrgObject` against orgo's | |
| 21 | `Element`/`Object`, `OrgTableRow.{cells,rule}` against `TableRow::{Cells,Rule}`, the same | |
| 22 | `ListKind`/`Checkbox` vocabulary. That is what makes future *tree-level* conformance possible: | |
| 23 | today the corpus compares rendered HTML reduced to a skeleton, which is a string-level proxy | |
| 24 | for "do these two agree on structure". With matching trees, that question can be asked | |
| 25 | directly. | |
| 26 | ||
| 27 | ## The result | |
| 28 | ||
| 29 | Two renderers, one parse. `OrgAttributedStringRenderer` is **Foundation-only** — no SwiftUI — | |
| 30 | so it works server-side, in a CLI, anywhere, and a SwiftUI block renderer would sit on top of | |
| 31 | it for the inline runs inside each block. | |
| 32 | ||
| 33 | ```swift | |
| 34 | let document = OrgParser.parse(source) | |
| 35 | let html = OrgHTMLTreeRenderer().render(document) // markup | |
| 36 | let text = OrgAttributedStringRenderer().inline(objects) // native, real attributes | |
| 37 | ``` | |
| 38 | ||
| 39 | The AttributedString path carries `inlinePresentationIntent` (`.stronglyEmphasized`, | |
| 40 | `.emphasized`, `.code`) and real `link` attributes — no markup in the string. Constructs | |
| 41 | `AttributedString` has no portable attribute for (superscript, timestamps, footnote refs, | |
| 42 | images, underline, strikethrough — the last two live only in the UIKit/AppKit scopes) travel | |
| 43 | as an `OrgRole` custom attribute the UI layer reads to decide presentation, so the renderer | |
| 44 | never needs to know about fonts or colors. | |
| 45 | ||
| 46 | ## Conformance scorecard | |
| 47 | ||
| 48 | The tree-based HTML renderer, measured against the same `org-conformance` corpus and the same | |
| 49 | skeleton reduction the shipped renderer is held to: | |
| 50 | ||
| 51 | **11 / 12 — parity with the shipped renderer.** Everything matches except `outofscope`, which | |
| 52 | is `scope: out` in the corpus (deliberately unsupported constructs, where orgo itself may | |
| 53 | differ); the shipped renderer diverges there too. | |
| 54 | ||
| 55 | Because it is at parity, `ASTConformanceTests` now **asserts** rather than reports: a | |
| 56 | regression fails, and `outofscope` starting to match fails as well, forcing the record to be | |
| 57 | updated. Run with `ORG_DUMP=1` to print the first divergence per case. | |
| 58 | ||
| 59 | Getting from the first draft (8/12) to parity took three fixes, all small parser work rather | |
| 60 | than anything architectural — which is the useful signal about where the remaining effort | |
| 61 | sits: | |
| 62 | ||
| 63 | | Case | Was | Fix | | |
| 64 | |---|---|---| | |
| 65 | | `core` | an ordered list and the bullet list after it merged into one list | stop a list at a top-level marker of the other kind | | |
| 66 | | `footnote` | inline footnotes (`[fn:x:text]`) dropped their text | carry it on the reference; the notes section renders inline definitions without the `<p>` a reference-style definition gets | | |
| 67 | | `timestamps` | ranges rendered one `<time>`, losing the end | model an end (`endTime` same-day, `endDate` multi-day) and emit two `<time>` joined by an en-dash, as org exports it | | |
| 68 | ||
| 69 | Two more were closed earlier, each ~10 lines: property drawers (`:PROPERTIES:` … `:END:`) are | |
| 70 | dropped as heading metadata, and a bare image renders as `<p><img></p>` while a caption or | |
| 71 | `#+ATTR_HTML` promotes it to `<figure>`. | |
| 72 | ||
| 73 | Two model refinements came out of this, both making the tree more faithful to org rather than | |
| 74 | to a particular output: | |
| 75 | ||
| 76 | - `OrgObject.footnoteRef` carries `inline: [OrgObject]?` instead of a placeholder `number`. | |
| 77 | Numbering is document-wide, so it belongs to the renderer, not the parse. | |
| 78 | - `OrgTimestamp` carries `endTime` and `endDate`, so a range is *one* timestamp with an end — | |
| 79 | matching how orgo models it — rather than two stamps with punctuation between them. | |
| 80 | ||
| 81 | ## Options are reconciled — the swap is ready | |
| 82 | ||
| 83 | `OrgHTMLTreeRenderer` now takes the same `OrgRenderOptions` the shipped renderer takes, and | |
| 84 | implements all of it: `metadataHeader`, `headingLevelOffset`, repository-relative resolution | |
| 85 | (`host`, `owner`, `repositoryName`, `ref`, `readmePath`, `imagePathSegment`/`linkPathSegment`) | |
| 86 | and URL sanitising. | |
| 87 | ||
| 88 | Resolution came out *simpler* on the tree. Because targets stay typed — `.external`, `.file`, | |
| 89 | `.id` — it applies to the `.file` case in the renderer, so no resolver closure has to be | |
| 90 | threaded through the parse. An unsafe or unresolvable target degrades to its text instead of | |
| 91 | becoming a bad anchor, as before. | |
| 92 | ||
| 93 | `ASTShippedEquivalenceTests` is the evidence, comparing shipped against tree by semantic | |
| 94 | skeleton (which keeps `href`/`src`, so resolved URLs are genuinely checked): | |
| 95 | ||
| 96 | - every corpus case, in the corpus configuration; | |
| 97 | - a source exercising the options, under repository options, with metadata off, and with no | |
| 98 | repository context at all; | |
| 99 | - identical URL resolution (image → `raw`, link → `blob`); | |
| 100 | - unsafe schemes rejected by both; | |
| 101 | - a 28-construct battery drawn from the shipped renderer's own test inputs. | |
| 102 | ||
| 103 | That battery found three real gaps, since closed: `#+CAPTION:`/`#+NAME:` on a non-image block | |
| 104 | now wraps it in `<figure class="org-block">` (a new `.captioned` element), the nested | |
| 105 | `[[dest][[img]]]` badge form parses again, and bare email addresses autolink. | |
| 106 | ||
| 107 | **One intentional difference remains.** A range whose halves are *inactive* timestamps, | |
| 108 | `[a]--[b]`, is joined by the tree and left as `--` by the shipped renderer. orgo joins both | |
| 109 | bracket kinds, so the tree is the more correct of the two; it is asserted directly in | |
| 110 | `joinsInactiveTimestampRangesWhereShippedDoesNot` rather than degraded to match. It shows up | |
| 111 | in the corpus only inside `outofscope`'s LOGBOOK drawer. | |
| 112 | ||
| 113 | ## Remaining steps | |
| 114 | ||
| 115 | 1. ~~Close the gaps until the tree renderer also scores 11/12.~~ **Done.** | |
| 116 | 2. ~~Reconcile the render options.~~ **Done.** | |
| 117 | 3. Point `OrgRenderer.renderToHTML` at `parse` + `OrgHTMLTreeRenderer` internally, keeping the | |
| 118 | public API identical — a one-line body change, with the equivalence tests as the proof it | |
| 119 | is safe. Consumers do not change. | |
| 120 | 4. Delete the single-pass renderer. | |
| 121 | 5. Add `OrgSwiftUI` as a **separate product** depending on the core, so the parser and HTML | |
| 122 | renderer stay Foundation-only and consumers who want HTML never import SwiftUI. | |
| 123 | ||
| 124 | Step 5 is where tables get built: `Grid`/`GridRow` with `.gridColumnAlignment()`, wrapped in a | |
| 125 | horizontal `ScrollView` for phone-width overflow — the approach MarkdownUI uses, and the | |
| 126 | `OrgTable` node already carries the rows, the rule position, and per-column alignments it | |
| 127 | needs. | |
README.md +4 −2
| @@ -4,8 +4,10 @@ A dependency-free Swift library that renders a practical subset of | ||
| 4 | 4 | [org-mode](https://orgmode.org) to sanitized HTML. Pure Foundation — no |
| 5 | 5 | SwiftUI, WebKit, UIKit, or third-party packages. |
| 6 | 6 | |
| 7 | Extracted from the hand-rolled renderer in the Hutch iOS client so it can be | |
| 8 | shared across apps. | |
| 7 | Originally extracted from the hand-rolled renderer in the Hutch iOS client so it | |
| 8 | could be shared across apps, and since rebuilt around an element tree: org is | |
| 9 | parsed once into a document model, and each output format walks it. See | |
| 10 | [ARCHITECTURE.md](ARCHITECTURE.md). | |
| 9 | 11 | |
| 10 | 12 | ## Supported syntax |
| 11 | 13 | |
Sources/OrgSwift/Escaping.swift −46
| @@ -80,49 +80,3 @@ func matchesRegex(_ text: String, pattern: String) -> Bool { | ||
| 80 | 80 | let range = NSRange(location: 0, length: (text as NSString).length) |
| 81 | 81 | return regex.firstMatch(in: text, range: range) != nil |
| 82 | 82 | } |
| 83 | ||
| 84 | func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bool { | |
| 85 | guard range.location != NSNotFound else { return false } | |
| 86 | let prefix = text.substring(to: range.location) | |
| 87 | guard let lastOpen = prefix.lastIndex(of: "<") else { return false } | |
| 88 | guard let lastClose = prefix.lastIndex(of: ">") else { return true } | |
| 89 | return lastOpen > lastClose | |
| 90 | } | |
| 91 | ||
| 92 | func protectMatches( | |
| 93 | in text: String, | |
| 94 | pattern: String, | |
| 95 | protectedFragments: inout [String: String], | |
| 96 | transform: (NSTextCheckingResult, NSString) -> String | |
| 97 | ) -> String { | |
| 98 | guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } | |
| 99 | var result = text | |
| 100 | let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length)) | |
| 101 | ||
| 102 | for match in matches.reversed() { | |
| 103 | let token = "ZZPROTECTED\(protectedFragments.count)ZZ" | |
| 104 | let nsText = result as NSString | |
| 105 | protectedFragments[token] = transform(match, nsText) | |
| 106 | result = nsText.replacingCharacters(in: match.range, with: token) | |
| 107 | } | |
| 108 | ||
| 109 | return result | |
| 110 | } | |
| 111 | ||
| 112 | func replaceMatches( | |
| 113 | in text: String, | |
| 114 | pattern: String, | |
| 115 | transform: (NSTextCheckingResult, NSString) -> String | |
| 116 | ) -> String { | |
| 117 | guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } | |
| 118 | var result = text | |
| 119 | let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length)) | |
| 120 | ||
| 121 | for match in matches.reversed() { | |
| 122 | let nsText = result as NSString | |
| 123 | let replacement = transform(match, nsText) | |
| 124 | result = nsText.replacingCharacters(in: match.range, with: replacement) | |
| 125 | } | |
| 126 | ||
| 127 | return result | |
| 128 | } | |
Sources/OrgSwift/Footnotes.swift −66
| @@ -1,71 +1,5 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | |
| 3 | // Org footnotes: references `[fn:1]` in running text, inline footnotes that define at the | |
| 4 | // point of use `[fn:label:text]`, and reference-style definitions on their own line | |
| 5 | // `[fn:1] the definition`. References become `<sup>` anchors; the definitions are | |
| 6 | // collected and emitted as a `<section class="footnotes">` at the end of the document, in | |
| 7 | // order of first reference — matching orgo's `ox-html` shape. | |
| 8 | ||
| 9 | /// Accumulates footnote references and definitions across a document render. A single | |
| 10 | /// instance lives for one `orgToHTML` call; references register as paragraphs flush, and | |
| 11 | /// the section is rendered once at the end. | |
| 12 | final class FootnoteCollector { | |
| 13 | private(set) var order: [String] = [] | |
| 14 | private var numbers: [String: Int] = [:] | |
| 15 | private var inlineText: [String: String] = [:] | |
| 16 | private var definitions: [String: String] = [:] | |
| 17 | ||
| 18 | var hasEntries: Bool { !order.isEmpty } | |
| 19 | ||
| 20 | /// Register a reference and return its display number. `inline` is the text of an | |
| 21 | /// inline footnote (`[fn:label:inline]`), nil for a plain reference. | |
| 22 | func reference(label: String, inline: String?) -> Int { | |
| 23 | let number: Int | |
| 24 | if let existing = numbers[label] { | |
| 25 | number = existing | |
| 26 | } else { | |
| 27 | number = order.count + 1 | |
| 28 | numbers[label] = number | |
| 29 | order.append(label) | |
| 30 | } | |
| 31 | if let inline, inlineText[label] == nil { | |
| 32 | inlineText[label] = inline | |
| 33 | } | |
| 34 | return number | |
| 35 | } | |
| 36 | ||
| 37 | /// Record a reference-style definition line. A definition can arrive before or after | |
| 38 | /// its reference; the number is assigned by reference order regardless. | |
| 39 | func define(label: String, text: String) { | |
| 40 | if numbers[label] == nil { | |
| 41 | numbers[label] = order.count + 1 | |
| 42 | order.append(label) | |
| 43 | } | |
| 44 | definitions[label] = text | |
| 45 | } | |
| 46 | ||
| 47 | /// Render the footnotes section, or "" if there are none. `inlineRenderer` renders a | |
| 48 | /// block definition's own inline markup (it must not itself collect footnotes). Inline | |
| 49 | /// footnote text was captured mid-inline and is already escaped, so it is emitted | |
| 50 | /// directly — running it through `inlineRenderer` again would double-escape it. | |
| 51 | func renderSection(inlineRenderer: (String) -> String) -> String { | |
| 52 | guard hasEntries else { return "" } | |
| 53 | var html = "<section class=\"footnotes\" aria-label=\"Footnotes\">\n<hr>\n<ol>\n" | |
| 54 | for label in order { | |
| 55 | let n = numbers[label] ?? 0 | |
| 56 | let back = ##"<a class="footnote-back" href="#fnr-\##(n)" aria-label="Back to reference \##(n)">↩</a>"## | |
| 57 | if let inline = inlineText[label] { | |
| 58 | html += "<li id=\"fn-\(n)\">\(inline) \(back)</li>\n" | |
| 59 | } else { | |
| 60 | let body = definitions[label].map(inlineRenderer) ?? "" | |
| 61 | html += "<li id=\"fn-\(n)\"><p>\(body)</p>\n \(back)</li>\n" | |
| 62 | } | |
| 63 | } | |
| 64 | html += "</ol>\n</section>\n" | |
| 65 | return html | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | 3 | /// Detect a reference-style footnote definition line: `[fn:label] text…`. Returns the |
| 70 | 4 | /// label and the definition text, or nil if the line is not one. |
| 71 | 5 | func orgFootnoteDefinition(in line: String) -> (label: String, text: String)? { |
Sources/OrgSwift/Inline.swift deleted −171
| @@ -1,171 +0,0 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | func processOrgInline( | |
| 4 | _ text: String, | |
| 5 | imageURLResolver: ((String) -> String?)? = nil, | |
| 6 | linkURLResolver: ((String) -> String?)? = nil, | |
| 7 | footnotes: FootnoteCollector? = nil | |
| 8 | ) -> String { | |
| 9 | var result = escapeHTML(text) | |
| 10 | var protectedFragments: [String: String] = [:] | |
| 11 | ||
| 12 | result = protectMatches( | |
| 13 | in: result, | |
| 14 | pattern: #"\[\[([^\]]+)\]\[\[([^\]]+)\]\]\]"#, | |
| 15 | protectedFragments: &protectedFragments | |
| 16 | ) { match, nsText in | |
| 17 | let destination = nsText.substring(with: match.range(at: 1)) | |
| 18 | let source = nsText.substring(with: match.range(at: 2)) | |
| 19 | guard let imageHTML = makeOrgImageHTML( | |
| 20 | source: source, | |
| 21 | alt: nil, | |
| 22 | imageURLResolver: imageURLResolver | |
| 23 | ) else { | |
| 24 | return source | |
| 25 | } | |
| 26 | let resolvedDestination = linkURLResolver?(destination) ?? destination | |
| 27 | guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else { | |
| 28 | return imageHTML | |
| 29 | } | |
| 30 | return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"# | |
| 31 | } | |
| 32 | ||
| 33 | result = protectOrgLinks( | |
| 34 | in: result, | |
| 35 | protectedFragments: &protectedFragments, | |
| 36 | imageURLResolver: imageURLResolver, | |
| 37 | linkURLResolver: linkURLResolver | |
| 38 | ) | |
| 39 | ||
| 40 | result = protectTimestamps(in: result, protectedFragments: &protectedFragments) | |
| 41 | ||
| 42 | if let footnotes { | |
| 43 | // Assign footnote numbers in textual (forward) order first. The protect passes | |
| 44 | // below replace right-to-left, so registering there would number references | |
| 45 | // backwards and reverse the definitions section; this pre-scan fixes the order, | |
| 46 | // and the passes then just look up the already-assigned number. | |
| 47 | if let scan = try? NSRegularExpression(pattern: #"\[fn:([A-Za-z0-9_-]+)(?::([^\]]*))?\]"#) { | |
| 48 | let ns = result as NSString | |
| 49 | for m in scan.matches(in: result, range: NSRange(location: 0, length: ns.length)) { | |
| 50 | let label = ns.substring(with: m.range(at: 1)) | |
| 51 | let textRange = m.range(at: 2) | |
| 52 | let inline = textRange.location == NSNotFound ? nil : ns.substring(with: textRange) | |
| 53 | _ = footnotes.reference(label: label, inline: inline) | |
| 54 | } | |
| 55 | } | |
| 56 | // Inline footnote `[fn:label:text]` first (more specific), then a plain reference. | |
| 57 | result = protectMatches( | |
| 58 | in: result, | |
| 59 | pattern: #"\[fn:([A-Za-z0-9_-]+):([^\]]*)\]"#, | |
| 60 | protectedFragments: &protectedFragments | |
| 61 | ) { match, nsText in | |
| 62 | let label = nsText.substring(with: match.range(at: 1)) | |
| 63 | let inline = nsText.substring(with: match.range(at: 2)) | |
| 64 | let n = footnotes.reference(label: label, inline: inline) | |
| 65 | return ##"<sup class="footnote-ref"><a id="fnr-\##(n)" href="#fn-\##(n)">\##(n)</a></sup>"## | |
| 66 | } | |
| 67 | result = protectMatches( | |
| 68 | in: result, | |
| 69 | pattern: #"\[fn:([A-Za-z0-9_-]+)\]"#, | |
| 70 | protectedFragments: &protectedFragments | |
| 71 | ) { match, nsText in | |
| 72 | let label = nsText.substring(with: match.range(at: 1)) | |
| 73 | let n = footnotes.reference(label: label, inline: nil) | |
| 74 | return ##"<sup class="footnote-ref"><a id="fnr-\##(n)" href="#fn-\##(n)">\##(n)</a></sup>"## | |
| 75 | } | |
| 76 | } | |
| 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 | ||
| 92 | // Bare URLs in running text become links. Bracketed `[[…]]` links are already | |
| 93 | // protected above, so this only sees truly bare URLs; trailing sentence punctuation is | |
| 94 | // left outside the link. | |
| 95 | result = protectMatches( | |
| 96 | in: result, | |
| 97 | pattern: #"https?://[^\s<>()\[\]]+"#, | |
| 98 | protectedFragments: &protectedFragments | |
| 99 | ) { match, nsText in | |
| 100 | var url = nsText.substring(with: match.range) | |
| 101 | var trailing = "" | |
| 102 | while let last = url.last, ".,;:!?".contains(last) { | |
| 103 | trailing = String(last) + trailing | |
| 104 | url.removeLast() | |
| 105 | } | |
| 106 | guard let safe = sanitizedReadmeLinkURLString(url) else { return url + trailing } | |
| 107 | return #"<a href="\#(safe)">\#(url)</a>"# + trailing | |
| 108 | } | |
| 109 | ||
| 110 | result = protectMatches( | |
| 111 | in: result, | |
| 112 | pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#, | |
| 113 | protectedFragments: &protectedFragments | |
| 114 | ) { match, nsText in | |
| 115 | let tildeRange = match.range(at: 1) | |
| 116 | let equalsRange = match.range(at: 2) | |
| 117 | let codeText: String | |
| 118 | if tildeRange.location != NSNotFound { | |
| 119 | codeText = nsText.substring(with: tildeRange) | |
| 120 | } else { | |
| 121 | codeText = nsText.substring(with: equalsRange) | |
| 122 | } | |
| 123 | return "<code>\(codeText)</code>" | |
| 124 | } | |
| 125 | result = protectMatches( | |
| 126 | in: result, | |
| 127 | pattern: #"(?<!\S)\+(.+?)\+(?=\s|$|[.,;:!?])"#, | |
| 128 | protectedFragments: &protectedFragments | |
| 129 | ) { match, nsText in | |
| 130 | let value = nsText.substring(with: match.range(at: 1)) | |
| 131 | return "<del>\(value)</del>" | |
| 132 | } | |
| 133 | result = protectMatches( | |
| 134 | in: result, | |
| 135 | pattern: #"(?<!\S)_(.+?)_(?=\s|$|[.,;:!?])"#, | |
| 136 | protectedFragments: &protectedFragments | |
| 137 | ) { match, nsText in | |
| 138 | let value = nsText.substring(with: match.range(at: 1)) | |
| 139 | return "<u>\(value)</u>" | |
| 140 | } | |
| 141 | ||
| 142 | // Bold: *text* | |
| 143 | result = result.replacingOccurrences( | |
| 144 | of: #"(?<!\S)\*(.+?)\*(?=\s|$|[.,;:!?])"#, | |
| 145 | with: "<strong>$1</strong>", | |
| 146 | options: .regularExpression | |
| 147 | ) | |
| 148 | // Italic: /text/ | |
| 149 | result = result.replacingOccurrences( | |
| 150 | of: #"(?<!\S)/(.+?)/(?=\s|$|[.,;:!?])"#, | |
| 151 | with: "<em>$1</em>", | |
| 152 | options: .regularExpression | |
| 153 | ) | |
| 154 | result = replaceMatches( | |
| 155 | in: result, | |
| 156 | pattern: #"(?i)(?<![\w.%+\-])([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})(?![\w\-])"# | |
| 157 | ) { match, nsText in | |
| 158 | guard !isInsideHTMLTag(nsText, range: match.range) else { | |
| 159 | return nsText.substring(with: match.range) | |
| 160 | } | |
| 161 | let email = nsText.substring(with: match.range(at: 1)) | |
| 162 | let href = escapeHTMLAttribute("mailto:\(email)") | |
| 163 | return #"<a href="\#(href)">\#(email)</a>"# | |
| 164 | } | |
| 165 | ||
| 166 | for (token, fragment) in protectedFragments { | |
| 167 | result = result.replacingOccurrences(of: token, with: fragment) | |
| 168 | } | |
| 169 | ||
| 170 | return result | |
| 171 | } | |
Sources/OrgSwift/Links.swift −210
| @@ -1,154 +1,11 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | |
| 3 | func protectOrgLinks( | |
| 4 | in text: String, | |
| 5 | protectedFragments: inout [String: String], | |
| 6 | imageURLResolver: ((String) -> String?)? = nil, | |
| 7 | linkURLResolver: ((String) -> String?)? = nil | |
| 8 | ) -> String { | |
| 9 | var result = text | |
| 10 | ||
| 11 | while let range = result.range(of: "[[") { | |
| 12 | guard let parsed = parseOrgLink(in: result, from: range.lowerBound) else { | |
| 13 | break | |
| 14 | } | |
| 15 | let token = "ZZPROTECTED\(protectedFragments.count)ZZ" | |
| 16 | protectedFragments[token] = renderOrgLink( | |
| 17 | destination: parsed.destination, | |
| 18 | label: parsed.label, | |
| 19 | imageURLResolver: imageURLResolver, | |
| 20 | linkURLResolver: linkURLResolver | |
| 21 | ) | |
| 22 | result.replaceSubrange(parsed.range, with: token) | |
| 23 | } | |
| 24 | ||
| 25 | return result | |
| 26 | } | |
| 27 | ||
| 28 | private func parseOrgLink( | |
| 29 | in text: String, | |
| 30 | from start: String.Index | |
| 31 | ) -> (range: Range<String.Index>, destination: String, label: String?)? { | |
| 32 | guard text[start...].hasPrefix("[[") else { return nil } | |
| 33 | ||
| 34 | var index = text.index(start, offsetBy: 2) | |
| 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 } | |
| 42 | return (start..<text.index(end, offsetBy: 2), String(text[index..<end]), nil) | |
| 43 | } | |
| 44 | ||
| 45 | let destination = String(text[index..<destinationEnd]) | |
| 46 | index = text.index(destinationEnd, offsetBy: 2) | |
| 47 | let labelStart = index | |
| 48 | var depth = 0 | |
| 49 | ||
| 50 | while index < text.endIndex { | |
| 51 | if text[index...].hasPrefix("[[") { | |
| 52 | depth += 1 | |
| 53 | index = text.index(index, offsetBy: 2) | |
| 54 | continue | |
| 55 | } | |
| 56 | if text[index...].hasPrefix("]]") { | |
| 57 | if depth == 0 { | |
| 58 | let end = text.index(index, offsetBy: 2) | |
| 59 | return (start..<end, destination, String(text[labelStart..<index])) | |
| 60 | } | |
| 61 | depth -= 1 | |
| 62 | index = text.index(index, offsetBy: 2) | |
| 63 | continue | |
| 64 | } | |
| 65 | index = text.index(after: index) | |
| 66 | } | |
| 67 | ||
| 68 | return nil | |
| 69 | } | |
| 70 | ||
| 71 | 3 | /// Strip org's `file:` link prefix. `[[file:diagram.png]]` targets a local path the same |
| 72 | 4 | /// way `[[diagram.png]]` does; the scheme is org bookkeeping, not part of the URL. |
| 73 | 5 | func normalizeOrgLinkTarget(_ target: String) -> String { |
| 74 | 6 | target.hasPrefix("file:") ? String(target.dropFirst(5)) : target |
| 75 | 7 | } |
| 76 | 8 | |
| 77 | /// If a link *description* is itself an image reference — `[[img]]`, a `file:` image, or an | |
| 78 | /// image URL — return its source, so the image becomes the link's content. A bare relative | |
| 79 | /// string (`img.png`) is a plain text description, not an image. | |
| 80 | private func descriptionImageSource(_ label: String) -> String? { | |
| 81 | if label.hasPrefix("[["), label.hasSuffix("]]") { | |
| 82 | return String(label.dropFirst(2).dropLast(2)) | |
| 83 | } | |
| 84 | if label.hasPrefix("file:") || label.hasPrefix("http://") || label.hasPrefix("https://") { | |
| 85 | return label | |
| 86 | } | |
| 87 | return nil | |
| 88 | } | |
| 89 | ||
| 90 | private func renderOrgLink( | |
| 91 | destination rawDestination: String, | |
| 92 | label: String?, | |
| 93 | imageURLResolver: ((String) -> String?)? = nil, | |
| 94 | linkURLResolver: ((String) -> String?)? = nil | |
| 95 | ) -> String { | |
| 96 | // `id:` links target a heading by its :ID:/:CUSTOM_ID:; org exports them as an | |
| 97 | // in-page fragment link, with the id itself as the text when there is no description. | |
| 98 | if rawDestination.hasPrefix("id:") { | |
| 99 | let id = String(rawDestination.dropFirst(3)) | |
| 100 | let text = label.map { | |
| 101 | processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) | |
| 102 | } ?? escapeHTML(id) | |
| 103 | return ##"<a href="#\##(escapeHTMLAttribute(id))">\##(text)</a>"## | |
| 104 | } | |
| 105 | let destination = normalizeOrgLinkTarget(rawDestination) | |
| 106 | // A description that is itself an image link (`[[url][file:badge.svg]]`, the common | |
| 107 | // build-badge form, or the double-bracketed `[[url][[badge.svg]]]`) makes the image the | |
| 108 | // clickable content of the link. A plain-text description like `img.png` stays text. | |
| 109 | if let label, let source = descriptionImageSource(label), | |
| 110 | let imageHTML = makeOrgImageHTML(source: source, alt: nil, imageURLResolver: imageURLResolver) { | |
| 111 | let resolvedDestination = linkURLResolver?(destination) ?? destination | |
| 112 | guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else { | |
| 113 | return imageHTML | |
| 114 | } | |
| 115 | return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"# | |
| 116 | } | |
| 117 | ||
| 118 | // A bare `[[image]]` with no description is an inline image. `[[image][text]]` is a | |
| 119 | // link whose text happens to point at an image — org renders it as a link, not an | |
| 120 | // image, so only take the image path when there is no description. | |
| 121 | if label == nil, let imageHTML = makeOrgImageHTML( | |
| 122 | source: destination, | |
| 123 | alt: nil, | |
| 124 | imageURLResolver: imageURLResolver | |
| 125 | ) { | |
| 126 | return imageHTML | |
| 127 | } | |
| 128 | ||
| 129 | let resolvedDestination = linkURLResolver?(destination) ?? destination | |
| 130 | guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else { | |
| 131 | return label ?? destination | |
| 132 | } | |
| 133 | ||
| 134 | let renderedLabel = label.map { | |
| 135 | processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) | |
| 136 | } ?? destination | |
| 137 | return #"<a href="\#(sanitizedURL)">\#(renderedLabel)</a>"# | |
| 138 | } | |
| 139 | ||
| 140 | func makeOrgImageHTML( | |
| 141 | source rawSource: String, | |
| 142 | alt: String?, | |
| 143 | imageURLResolver: ((String) -> String?)? | |
| 144 | ) -> String? { | |
| 145 | let source = normalizeOrgLinkTarget(rawSource) | |
| 146 | guard isRenderableImageSource(source) else { return nil } | |
| 147 | let resolvedSource = imageURLResolver?(source) ?? source | |
| 148 | guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { return nil } | |
| 149 | let altText = escapeHTMLAttribute(alt ?? "") | |
| 150 | return #"<img src="\#(sanitizedSource)" alt="\#(altText)">"# | |
| 151 | } | |
| 152 | 9 | |
| 153 | 10 | private func isRenderableImageSource(_ source: String) -> Bool { |
| 154 | 11 | let lowercased = source.lowercased() |
| @@ -168,73 +25,6 @@ func standaloneOrgImage(in line: String) -> String? { | ||
| 168 | 25 | return path |
| 169 | 26 | } |
| 170 | 27 | |
| 171 | /// Build the `<img>` for a figure or bare image. `alt` comes from the caption (markup | |
| 172 | /// stripped) when present, else from an `:alt` in `#+ATTR_HTML`, else empty; the remaining | |
| 173 | /// `#+ATTR_HTML` pairs become attributes. | |
| 174 | func makeFigureImageHTML( | |
| 175 | path: String, | |
| 176 | caption: String?, | |
| 177 | attrHtml: String?, | |
| 178 | imageURLResolver: ((String) -> String?)? | |
| 179 | ) -> String { | |
| 180 | let resolved = imageURLResolver?(path) ?? path | |
| 181 | let src = sanitizedReadmeImageURLString(resolved) ?? escapeHTMLAttribute(path) | |
| 182 | let attrs = attrHtml.map(parseAttrHtml) ?? [] | |
| 183 | ||
| 184 | let alt: String | |
| 185 | if let caption { | |
| 186 | alt = stripOrgEmphasis(caption) | |
| 187 | } else if let attrAlt = attrs.first(where: { $0.key == "alt" })?.value { | |
| 188 | alt = attrAlt | |
| 189 | } else { | |
| 190 | alt = "" | |
| 191 | } | |
| 192 | ||
| 193 | var html = #"<img src="\#(src)" alt="\#(escapeHTMLAttribute(alt))""# | |
| 194 | for (key, value) in attrs where key != "alt" { | |
| 195 | html += " \(escapeHTMLAttribute(key))=\"\(escapeHTMLAttribute(value))\"" | |
| 196 | } | |
| 197 | html += ">" | |
| 198 | return html | |
| 199 | } | |
| 200 | ||
| 201 | /// Parse `#+ATTR_HTML` `:key value` pairs, honoring quoted values (`:alt "a cat, sitting"`). | |
| 202 | private func parseAttrHtml(_ value: String) -> [(key: String, value: String)] { | |
| 203 | guard let regex = try? NSRegularExpression(pattern: #":([A-Za-z_][A-Za-z0-9_-]*)\s+("[^"]*"|\S+)"#) else { | |
| 204 | return [] | |
| 205 | } | |
| 206 | let ns = value as NSString | |
| 207 | var result: [(String, String)] = [] | |
| 208 | for m in regex.matches(in: value, range: NSRange(location: 0, length: ns.length)) { | |
| 209 | let key = ns.substring(with: m.range(at: 1)).lowercased() | |
| 210 | var v = ns.substring(with: m.range(at: 2)) | |
| 211 | if v.count >= 2, v.hasPrefix("\""), v.hasSuffix("\"") { | |
| 212 | v = String(v.dropFirst().dropLast()) | |
| 213 | } | |
| 214 | result.append((key, v)) | |
| 215 | } | |
| 216 | return result | |
| 217 | } | |
| 218 | ||
| 219 | /// Strip paired org emphasis markers for plain-text uses like an image `alt`. | |
| 220 | private func stripOrgEmphasis(_ s: String) -> String { | |
| 221 | guard let regex = try? NSRegularExpression(pattern: #"(?<!\S)([/*_+=~])(.+?)\1(?=\s|$|[.,;:!?])"#) else { | |
| 222 | return s | |
| 223 | } | |
| 224 | var result = s | |
| 225 | for _ in 0..<3 { | |
| 226 | let ns = result as NSString | |
| 227 | let matches = regex.matches(in: result, range: NSRange(location: 0, length: ns.length)) | |
| 228 | if matches.isEmpty { break } | |
| 229 | for m in matches.reversed() { | |
| 230 | let inner = ns.substring(with: m.range(at: 2)) | |
| 231 | result = (result as NSString).replacingCharacters(in: m.range, with: inner) | |
| 232 | } | |
| 233 | } | |
| 234 | return result | |
| 235 | } | |
| 236 | ||
| 237 | // MARK: - Relative link/image resolution | |
| 238 | 28 | |
| 239 | 29 | func resolveRepositoryLinkURL( |
| 240 | 30 | _ source: String, |
Sources/OrgSwift/Lists.swift +1 −172
| @@ -1,188 +1,17 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | |
| 3 | enum OrgListType: Equatable { | |
| 4 | case unordered | |
| 5 | case ordered | |
| 6 | } | |
| 7 | ||
| 8 | 3 | func orderedListItem(in line: String) -> String? { |
| 9 | 4 | guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil } |
| 10 | 5 | return String(match.2) |
| 11 | 6 | } |
| 12 | 7 | |
| 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) | |
| 32 | } | |
| 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 } | |
| 38 | ||
| 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" | |
| 43 | } | |
| 44 | html += ordered ? "</ol>\n" : "</ul>\n" | |
| 45 | return html | |
| 46 | } | |
| 47 | ||
| 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 = [] | |
| 67 | } | |
| 68 | ||
| 69 | for line in outdented { | |
| 70 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 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) | |
| 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 | } | |
| 93 | ||
| 94 | if !sublistLines.isEmpty { | |
| 95 | body += "\n" + renderOrgList(sublistLines, inlineRenderer: inlineRenderer) | |
| 96 | } | |
| 97 | return body | |
| 98 | } | |
| 99 | ||
| 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" | |
| 115 | } | |
| 116 | } | |
| 117 | html += "</dl>\n" | |
| 118 | return html | |
| 119 | } | |
| 120 | ||
| 121 | 8 | /// True if a line (already outdented to its level) starts a list item at column 0. |
| 122 | 9 | func isListMarkerLine(_ line: String) -> Bool { |
| 123 | 10 | line.hasPrefix("- ") || line.hasPrefix("+ ") || orderedListItem(in: line) != nil |
| 124 | 11 | } |
| 125 | 12 | |
| 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 | ||
| 137 | 13 | func isIndentedContinuationLine(_ line: String) -> Bool { |
| 138 | 14 | guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false } |
| 139 | 15 | guard let first = line.first else { return false } |
| 140 | 16 | return first == " " || first == "\t" |
| 141 | } | |
| 142 | ||
| 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 | |
| 151 | } | |
| 152 | ||
| 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) | |
| 161 | } | |
| 162 | return String(line[index...]) | |
| 163 | } | |
| 164 | ||
| 165 | func renderTaskListItem( | |
| 166 | _ text: String, | |
| 167 | inlineRenderer: (String) -> String | |
| 168 | ) -> String { | |
| 169 | let trimmed = text.trimmingCharacters(in: .whitespaces) | |
| 170 | guard trimmed.count >= 4 else { | |
| 171 | return inlineRenderer(text) | |
| 172 | } | |
| 173 | ||
| 174 | let prefix = String(trimmed.prefix(4)) | |
| 175 | let remainder = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespaces) | |
| 176 | ||
| 177 | // Rendered as org's HTML exporter does: the bracket state in a <code>, not an <input>. | |
| 178 | switch prefix { | |
| 179 | case "[ ] ": | |
| 180 | return #"<code>[ ]</code> \#(inlineRenderer(remainder))"# | |
| 181 | case "[x] ", "[X] ": | |
| 182 | return #"<code>[X]</code> \#(inlineRenderer(remainder))"# | |
| 183 | case "[-] ": | |
| 184 | return #"<code>[-]</code> \#(inlineRenderer(remainder))"# | |
| 185 | default: | |
| 186 | return inlineRenderer(text) | |
| 187 | } | |
| 188 | } | |
| 17 | } | |
| \ No newline at end of file | ||
Sources/OrgSwift/OrgRenderer.swift +2 −552
| @@ -115,14 +115,8 @@ public enum OrgRenderer { | ||
| 115 | 115 | options: OrgRenderOptions = .init(), |
| 116 | 116 | highlighter: CodeHighlighter = PlainCodeHighlighter() |
| 117 | 117 | ) -> String { |
| 118 | orgToHTML( | |
| 119 | source, | |
| 120 | highlighter: highlighter, | |
| 121 | imageURLResolver: options.imageURLResolver(), | |
| 122 | linkURLResolver: options.linkURLResolver(), | |
| 123 | metadataHeader: options.metadataHeader, | |
| 124 | headingLevelOffset: options.headingLevelOffset | |
| 125 | ) | |
| 118 | OrgHTMLTreeRenderer(options: options, highlighter: highlighter) | |
| 119 | .render(OrgParser.parse(source)) | |
| 126 | 120 | } |
| 127 | 121 | } |
| 128 | 122 | |
| @@ -138,547 +132,3 @@ func splitHeadingTags(_ heading: String) -> (title: String, tags: [String]) { | ||
| 138 | 132 | let tags = String(match.2).split(separator: ":").map(String.init).filter { !$0.isEmpty } |
| 139 | 133 | return (String(match.1).trimmingCharacters(in: .whitespaces), tags) |
| 140 | 134 | } |
| 141 | ||
| 142 | func orgToHTML( | |
| 143 | _ text: String, | |
| 144 | highlighter: CodeHighlighter, | |
| 145 | imageURLResolver: ((String) -> String?)? = nil, | |
| 146 | linkURLResolver: ((String) -> String?)? = nil, | |
| 147 | metadataHeader: Bool = true, | |
| 148 | headingLevelOffset: Int = 0 | |
| 149 | ) -> String { | |
| 150 | let normalizedText = text | |
| 151 | .replacingOccurrences(of: "\r\n", with: "\n") | |
| 152 | .replacingOccurrences(of: "\r", with: "\n") | |
| 153 | let rawLines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) | |
| 154 | var title: String? | |
| 155 | var author: String? | |
| 156 | var date: String? | |
| 157 | let lines = rawLines.filter { line in | |
| 158 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 159 | guard let directive = orgKeywordDirective(in: trimmed) else { | |
| 160 | return true | |
| 161 | } | |
| 162 | switch directive.keyword { | |
| 163 | case "title": | |
| 164 | title = directive.value | |
| 165 | return false | |
| 166 | case "author": | |
| 167 | author = directive.value | |
| 168 | return false | |
| 169 | case "date": | |
| 170 | date = directive.value | |
| 171 | return false | |
| 172 | default: | |
| 173 | return true | |
| 174 | } | |
| 175 | } | |
| 176 | var html = "" | |
| 177 | var listType: OrgListType? | |
| 178 | var inQuoteBlock = false | |
| 179 | var inPropertyDrawer = false | |
| 180 | var srcLanguage: String? | |
| 181 | var srcLines: [String] = [] | |
| 182 | var inExampleBlock = false | |
| 183 | var inCenterBlock = false | |
| 184 | var inVerseBlock = false | |
| 185 | var inExportBlock = false | |
| 186 | var exportIsHTML = false | |
| 187 | var exportLines: [String] = [] | |
| 188 | var inSpecialBlock = false | |
| 189 | var specialBlockName = "" | |
| 190 | var listBuffer: [String] = [] | |
| 191 | var pendingListBlanks: [String] = [] | |
| 192 | var paragraph: [String] = [] | |
| 193 | var tableRows: [[String]] = [] | |
| 194 | var propertyRows: [(String, String)] = [] | |
| 195 | var verseLines: [String] = [] | |
| 196 | var pendingBlockName: String? | |
| 197 | var pendingBlockCaption: String? | |
| 198 | var pendingAttrHtml: String? | |
| 199 | var activeBlockCaption: String? | |
| 200 | var isWrappingBlockFigure = false | |
| 201 | var figureNumber = 0 | |
| 202 | let footnotes = FootnoteCollector() | |
| 203 | ||
| 204 | func beginPendingBlockWrapperIfNeeded() { | |
| 205 | guard pendingBlockName != nil || pendingBlockCaption != nil else { return } | |
| 206 | let idAttribute = pendingBlockName.map { #" id="\#(escapeHTMLAttribute($0))""# } ?? "" | |
| 207 | html += #"<figure class="org-block"\#(idAttribute)>"# + "\n" | |
| 208 | activeBlockCaption = pendingBlockCaption | |
| 209 | isWrappingBlockFigure = true | |
| 210 | pendingBlockName = nil | |
| 211 | pendingBlockCaption = nil | |
| 212 | } | |
| 213 | ||
| 214 | func closePendingBlockWrapper() { | |
| 215 | guard isWrappingBlockFigure else { return } | |
| 216 | if let activeBlockCaption { | |
| 217 | html += "<figcaption>" + processOrgInline(activeBlockCaption, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</figcaption>\n" | |
| 218 | } | |
| 219 | html += "</figure>\n" | |
| 220 | activeBlockCaption = nil | |
| 221 | isWrappingBlockFigure = false | |
| 222 | } | |
| 223 | ||
| 224 | func flushParagraph() { | |
| 225 | if !paragraph.isEmpty { | |
| 226 | let normalizedParagraph = paragraph | |
| 227 | .map { $0.trimmingCharacters(in: .whitespaces) } | |
| 228 | .joined(separator: " ") | |
| 229 | html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver, footnotes: footnotes) + "</p>\n" | |
| 230 | paragraph = [] | |
| 231 | } | |
| 232 | } | |
| 233 | ||
| 234 | func closeList() { | |
| 235 | if !listBuffer.isEmpty { | |
| 236 | html += renderOrgList(listBuffer) { | |
| 237 | processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) | |
| 238 | } | |
| 239 | } | |
| 240 | listBuffer = [] | |
| 241 | pendingListBlanks = [] | |
| 242 | listType = nil | |
| 243 | } | |
| 244 | ||
| 245 | func flushTable() { | |
| 246 | guard !tableRows.isEmpty else { return } | |
| 247 | beginPendingBlockWrapperIfNeeded() | |
| 248 | html += renderHTMLTable( | |
| 249 | rows: tableRows, | |
| 250 | inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) } | |
| 251 | ) | |
| 252 | closePendingBlockWrapper() | |
| 253 | tableRows = [] | |
| 254 | } | |
| 255 | ||
| 256 | func flushPropertyDrawer() { | |
| 257 | // Property drawers are heading metadata, not body content. org's HTML exporter | |
| 258 | // drops them (CUSTOM_ID becomes the heading's anchor); we drop them too rather | |
| 259 | // than render a stray <dl>. The lines were still consumed above, so they never | |
| 260 | // fall through to become a paragraph. | |
| 261 | propertyRows = [] | |
| 262 | } | |
| 263 | ||
| 264 | func closeQuoteBlock() { | |
| 265 | if inQuoteBlock { | |
| 266 | flushParagraph() | |
| 267 | html += "</blockquote>\n" | |
| 268 | inQuoteBlock = false | |
| 269 | } | |
| 270 | } | |
| 271 | ||
| 272 | func closeSourceBlock() { | |
| 273 | if let language = srcLanguage { | |
| 274 | let code = srcLines.joined(separator: "\n") | |
| 275 | if !code.isEmpty { | |
| 276 | let highlighted = highlighter.highlightedHTML(code: code, language: language.isEmpty ? nil : language) | |
| 277 | html += (highlighted ?? escapeHTML(code)) + "\n" | |
| 278 | } | |
| 279 | html += "</code></pre>\n" | |
| 280 | srcLanguage = nil | |
| 281 | srcLines = [] | |
| 282 | closePendingBlockWrapper() | |
| 283 | } | |
| 284 | } | |
| 285 | ||
| 286 | func closeExampleBlock() { | |
| 287 | if inExampleBlock { | |
| 288 | html += "</pre>\n" | |
| 289 | inExampleBlock = false | |
| 290 | closePendingBlockWrapper() | |
| 291 | } | |
| 292 | } | |
| 293 | ||
| 294 | func closeCenterBlock() { | |
| 295 | if inCenterBlock { | |
| 296 | flushParagraph() | |
| 297 | html += "</div>\n" | |
| 298 | inCenterBlock = false | |
| 299 | closePendingBlockWrapper() | |
| 300 | } | |
| 301 | } | |
| 302 | ||
| 303 | func closeVerseBlock() { | |
| 304 | if inVerseBlock { | |
| 305 | let content = verseLines | |
| 306 | .map { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) } | |
| 307 | .joined(separator: "<br>\n") | |
| 308 | html += #"<p class="verse">"# + "\n" | |
| 309 | html += content + "\n" | |
| 310 | html += "</p>\n" | |
| 311 | verseLines = [] | |
| 312 | inVerseBlock = false | |
| 313 | closePendingBlockWrapper() | |
| 314 | } | |
| 315 | } | |
| 316 | ||
| 317 | func flushBlockState() { | |
| 318 | flushParagraph() | |
| 319 | closeList() | |
| 320 | flushTable() | |
| 321 | flushPropertyDrawer() | |
| 322 | } | |
| 323 | ||
| 324 | if metadataHeader, title != nil || author != nil || date != nil { | |
| 325 | html += "<div class=\"org-metadata\">\n" | |
| 326 | if let title { | |
| 327 | html += "<h1 class=\"org-title\">" + escapeHTML(title) + "</h1>\n" | |
| 328 | } | |
| 329 | if let author { | |
| 330 | html += "<p class=\"org-author\">" + escapeHTML(author) + "</p>\n" | |
| 331 | } | |
| 332 | if let date { | |
| 333 | html += "<p class=\"org-date\">" + escapeHTML(date) + "</p>\n" | |
| 334 | } | |
| 335 | html += "</div>\n" | |
| 336 | } | |
| 337 | ||
| 338 | for line in lines { | |
| 339 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 340 | ||
| 341 | if srcLanguage != nil { | |
| 342 | if trimmed.lowercased() == "#+end_src" { | |
| 343 | closeSourceBlock() | |
| 344 | } else { | |
| 345 | srcLines.append(line) | |
| 346 | } | |
| 347 | continue | |
| 348 | } | |
| 349 | ||
| 350 | if inExampleBlock { | |
| 351 | if trimmed.lowercased() == "#+end_example" { | |
| 352 | closeExampleBlock() | |
| 353 | } else { | |
| 354 | html += escapeHTML(line) + "\n" | |
| 355 | } | |
| 356 | continue | |
| 357 | } | |
| 358 | ||
| 359 | if inVerseBlock { | |
| 360 | if trimmed.lowercased() == "#+end_verse" { | |
| 361 | closeVerseBlock() | |
| 362 | } else { | |
| 363 | verseLines.append(line) | |
| 364 | } | |
| 365 | continue | |
| 366 | } | |
| 367 | ||
| 368 | if inExportBlock { | |
| 369 | if trimmed.lowercased() == "#+end_export" { | |
| 370 | if exportIsHTML { | |
| 371 | html += exportLines.joined(separator: "\n") + "\n" | |
| 372 | } | |
| 373 | inExportBlock = false | |
| 374 | exportLines = [] | |
| 375 | } else if exportIsHTML { | |
| 376 | // The `html` backend passes through verbatim; any other backend is dropped. | |
| 377 | exportLines.append(line) | |
| 378 | } | |
| 379 | continue | |
| 380 | } | |
| 381 | ||
| 382 | if inSpecialBlock { | |
| 383 | if trimmed.lowercased() == "#+end_\(specialBlockName)" { | |
| 384 | flushParagraph() | |
| 385 | html += "</div>\n" | |
| 386 | inSpecialBlock = false | |
| 387 | specialBlockName = "" | |
| 388 | } else if trimmed.isEmpty { | |
| 389 | flushParagraph() | |
| 390 | } else { | |
| 391 | paragraph.append(line) | |
| 392 | } | |
| 393 | continue | |
| 394 | } | |
| 395 | ||
| 396 | if inQuoteBlock, trimmed.lowercased() == "#+end_quote" { | |
| 397 | closeQuoteBlock() | |
| 398 | continue | |
| 399 | } | |
| 400 | ||
| 401 | if inCenterBlock { | |
| 402 | if trimmed.lowercased() == "#+end_center" { | |
| 403 | closeCenterBlock() | |
| 404 | } else if trimmed.isEmpty { | |
| 405 | flushParagraph() | |
| 406 | } else { | |
| 407 | paragraph.append(line) | |
| 408 | } | |
| 409 | continue | |
| 410 | } | |
| 411 | ||
| 412 | if trimmed == "#" || trimmed.hasPrefix("# ") { | |
| 413 | continue | |
| 414 | } | |
| 415 | ||
| 416 | if let directive = orgKeywordDirective(in: trimmed) { | |
| 417 | switch directive.keyword { | |
| 418 | case "caption": | |
| 419 | pendingBlockCaption = directive.value | |
| 420 | continue | |
| 421 | case "name": | |
| 422 | pendingBlockName = directive.value | |
| 423 | continue | |
| 424 | case "attr_html": | |
| 425 | pendingAttrHtml = directive.value | |
| 426 | continue | |
| 427 | default: | |
| 428 | // Any other `#+keyword:` line (OPTIONS, PROPERTY, FILETAGS, TBLFM, RESULTS, | |
| 429 | // …) is document metadata, not body content — org's exporter consumes it. | |
| 430 | // `#+begin_…`/`#+end_…` have no colon, so they are not matched here. | |
| 431 | continue | |
| 432 | } | |
| 433 | } | |
| 434 | ||
| 435 | // A standalone image link on its own line. An affiliated #+CAPTION or #+ATTR_HTML | |
| 436 | // promotes it to a <figure>; otherwise it is a plain <p><img>. A link *with* a | |
| 437 | // description ([[file:x][label]]) is an ordinary link and falls through to the | |
| 438 | // paragraph path instead. | |
| 439 | if let imagePath = standaloneOrgImage(in: trimmed) { | |
| 440 | closeQuoteBlock() | |
| 441 | flushBlockState() | |
| 442 | let img = makeFigureImageHTML( | |
| 443 | path: imagePath, | |
| 444 | caption: pendingBlockCaption, | |
| 445 | attrHtml: pendingAttrHtml, | |
| 446 | imageURLResolver: imageURLResolver | |
| 447 | ) | |
| 448 | if pendingBlockCaption != nil || pendingAttrHtml != nil { | |
| 449 | html += "<figure>" + img | |
| 450 | if let caption = pendingBlockCaption { | |
| 451 | figureNumber += 1 | |
| 452 | html += #"<figcaption><span class="figure-number">Figure \#(figureNumber): </span>"# | |
| 453 | + processOrgInline(caption, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) | |
| 454 | + "</figcaption>" | |
| 455 | } | |
| 456 | html += "</figure>\n" | |
| 457 | } else { | |
| 458 | html += "<p>" + img + "</p>\n" | |
| 459 | } | |
| 460 | pendingBlockCaption = nil | |
| 461 | pendingBlockName = nil | |
| 462 | pendingAttrHtml = nil | |
| 463 | continue | |
| 464 | } | |
| 465 | ||
| 466 | if trimmed.lowercased().hasPrefix("#+begin_src") { | |
| 467 | // No closeQuoteBlock(): a source block can sit inside a quote, and org keeps it | |
| 468 | // there. flushBlockState() still ends any open paragraph, list, or table. | |
| 469 | flushBlockState() | |
| 470 | beginPendingBlockWrapperIfNeeded() | |
| 471 | let language = trimmed | |
| 472 | .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) | |
| 473 | .dropFirst() | |
| 474 | .first | |
| 475 | .map(String.init)? | |
| 476 | .trimmingCharacters(in: .whitespacesAndNewlines) | |
| 477 | let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? "" | |
| 478 | html += "<pre><code\(classAttribute)>" | |
| 479 | srcLanguage = language ?? "" | |
| 480 | srcLines = [] | |
| 481 | continue | |
| 482 | } | |
| 483 | ||
| 484 | if trimmed.lowercased() == "#+begin_example" { | |
| 485 | closeQuoteBlock() | |
| 486 | flushBlockState() | |
| 487 | beginPendingBlockWrapperIfNeeded() | |
| 488 | html += "<pre>" | |
| 489 | inExampleBlock = true | |
| 490 | continue | |
| 491 | } | |
| 492 | ||
| 493 | if trimmed.lowercased() == "#+begin_quote" { | |
| 494 | flushBlockState() | |
| 495 | beginPendingBlockWrapperIfNeeded() | |
| 496 | html += "<blockquote>\n" | |
| 497 | inQuoteBlock = true | |
| 498 | continue | |
| 499 | } | |
| 500 | ||
| 501 | if trimmed.lowercased() == "#+begin_center" { | |
| 502 | closeQuoteBlock() | |
| 503 | flushBlockState() | |
| 504 | beginPendingBlockWrapperIfNeeded() | |
| 505 | html += "<div style=\"text-align:center\">\n" | |
| 506 | inCenterBlock = true | |
| 507 | continue | |
| 508 | } | |
| 509 | ||
| 510 | if trimmed.lowercased() == "#+begin_verse" { | |
| 511 | closeQuoteBlock() | |
| 512 | flushBlockState() | |
| 513 | beginPendingBlockWrapperIfNeeded() | |
| 514 | verseLines = [] | |
| 515 | inVerseBlock = true | |
| 516 | continue | |
| 517 | } | |
| 518 | ||
| 519 | if trimmed.lowercased().hasPrefix("#+begin_export") { | |
| 520 | closeQuoteBlock() | |
| 521 | flushBlockState() | |
| 522 | let backend = trimmed | |
| 523 | .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) | |
| 524 | .dropFirst().first.map(String.init)? | |
| 525 | .trimmingCharacters(in: .whitespaces).lowercased() | |
| 526 | inExportBlock = true | |
| 527 | exportIsHTML = backend == "html" | |
| 528 | exportLines = [] | |
| 529 | continue | |
| 530 | } | |
| 531 | ||
| 532 | // Any other `#+begin_<name>` is a special block: org renders it as a `<div>` carrying | |
| 533 | // the name as a class, with the contents parsed as ordinary org. Must stay last so | |
| 534 | // the specific block types above win. | |
| 535 | if trimmed.lowercased().hasPrefix("#+begin_") { | |
| 536 | closeQuoteBlock() | |
| 537 | flushBlockState() | |
| 538 | let name = String(trimmed.lowercased().dropFirst("#+begin_".count)) | |
| 539 | .split(separator: " ").first.map(String.init) ?? "" | |
| 540 | if !name.isEmpty { | |
| 541 | html += #"<div class="\#(escapeHTMLAttribute(name))">"# + "\n" | |
| 542 | inSpecialBlock = true | |
| 543 | specialBlockName = name | |
| 544 | continue | |
| 545 | } | |
| 546 | } | |
| 547 | ||
| 548 | if trimmed == ":PROPERTIES:" { | |
| 549 | closeQuoteBlock() | |
| 550 | flushBlockState() | |
| 551 | inPropertyDrawer = true | |
| 552 | continue | |
| 553 | } | |
| 554 | ||
| 555 | if trimmed == ":END:", inPropertyDrawer { | |
| 556 | flushPropertyDrawer() | |
| 557 | inPropertyDrawer = false | |
| 558 | continue | |
| 559 | } | |
| 560 | ||
| 561 | if inPropertyDrawer, | |
| 562 | trimmed.hasPrefix(":"), | |
| 563 | let secondColonIndex = trimmed.dropFirst().firstIndex(of: ":") { | |
| 564 | let keyStart = trimmed.index(after: trimmed.startIndex) | |
| 565 | let key = String(trimmed[keyStart..<secondColonIndex]).trimmingCharacters(in: .whitespaces) | |
| 566 | let valueStart = trimmed.index(after: secondColonIndex) | |
| 567 | let value = String(trimmed[valueStart...]).trimmingCharacters(in: .whitespaces) | |
| 568 | if !key.isEmpty { | |
| 569 | propertyRows.append((key, value)) | |
| 570 | continue | |
| 571 | } | |
| 572 | } | |
| 573 | ||
| 574 | if isTableLine(trimmed) { | |
| 575 | closeQuoteBlock() | |
| 576 | flushParagraph() | |
| 577 | closeList() | |
| 578 | tableRows.append(parseTableRow(trimmed)) | |
| 579 | continue | |
| 580 | } else { | |
| 581 | flushTable() | |
| 582 | } | |
| 583 | ||
| 584 | if isOrgHorizontalRule(trimmed) { | |
| 585 | closeQuoteBlock() | |
| 586 | flushBlockState() | |
| 587 | html += "<hr>\n" | |
| 588 | continue | |
| 589 | } | |
| 590 | ||
| 591 | // Reference-style footnote definition: [fn:label] text. Collected out of the body | |
| 592 | // flow and emitted in the footnotes section at the end. | |
| 593 | if let definition = orgFootnoteDefinition(in: trimmed) { | |
| 594 | closeQuoteBlock() | |
| 595 | flushBlockState() | |
| 596 | footnotes.define(label: definition.label, text: definition.text) | |
| 597 | continue | |
| 598 | } | |
| 599 | ||
| 600 | // Org headings: * heading, ** heading, *** heading | |
| 601 | if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) { | |
| 602 | closeQuoteBlock() | |
| 603 | flushBlockState() | |
| 604 | let level = min(6, max(1, match.1.count + headingLevelOffset)) | |
| 605 | let (titleText, tags) = splitHeadingTags(String(match.2)) | |
| 606 | var content = processOrgInline(titleText, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver, footnotes: footnotes) | |
| 607 | if !tags.isEmpty { | |
| 608 | content += " " + tags.map { #"<span class="tag">\#(escapeHTML($0))</span>"# }.joined(separator: " ") | |
| 609 | } | |
| 610 | html += "<h\(level)>" + content + "</h\(level)>\n" | |
| 611 | continue | |
| 612 | } | |
| 613 | ||
| 614 | // Inside a list, hold blank lines instead of ending it: an item can have several | |
| 615 | // paragraphs, separated by blanks, before the next item or the list's end. | |
| 616 | if listType != nil, trimmed.isEmpty { | |
| 617 | pendingListBlanks.append(line) | |
| 618 | continue | |
| 619 | } | |
| 620 | ||
| 621 | // A list item marker at column 0 starts or continues a list. A different marker | |
| 622 | // type (ordered vs unordered) at the top level begins a separate list. | |
| 623 | if !isIndentedContinuationLine(line), isListMarkerLine(trimmed) { | |
| 624 | let newType: OrgListType = orderedListItem(in: trimmed) != nil ? .ordered : .unordered | |
| 625 | if listType == nil { | |
| 626 | flushParagraph() | |
| 627 | flushPropertyDrawer() | |
| 628 | } else if listType != newType { | |
| 629 | closeList() | |
| 630 | flushParagraph() | |
| 631 | flushPropertyDrawer() | |
| 632 | } | |
| 633 | listType = newType | |
| 634 | listBuffer.append(contentsOf: pendingListBlanks) | |
| 635 | pendingListBlanks = [] | |
| 636 | listBuffer.append(line) | |
| 637 | continue | |
| 638 | } | |
| 639 | ||
| 640 | // A line indented under an open list is a continuation or a nested item. | |
| 641 | if listType != nil, isIndentedContinuationLine(line) { | |
| 642 | listBuffer.append(contentsOf: pendingListBlanks) | |
| 643 | pendingListBlanks = [] | |
| 644 | listBuffer.append(line) | |
| 645 | continue | |
| 646 | } | |
| 647 | ||
| 648 | // Any other line ends an open list, then is processed normally below. | |
| 649 | if listType != nil { | |
| 650 | closeList() | |
| 651 | } | |
| 652 | ||
| 653 | // Blank line | |
| 654 | if trimmed.isEmpty { | |
| 655 | if inQuoteBlock { | |
| 656 | flushParagraph() | |
| 657 | } else { | |
| 658 | flushBlockState() | |
| 659 | } | |
| 660 | continue | |
| 661 | } | |
| 662 | ||
| 663 | // Regular text | |
| 664 | if pendingBlockName != nil || pendingBlockCaption != nil || pendingAttrHtml != nil { | |
| 665 | pendingBlockName = nil | |
| 666 | pendingBlockCaption = nil | |
| 667 | pendingAttrHtml = nil | |
| 668 | } | |
| 669 | paragraph.append(line) | |
| 670 | } | |
| 671 | ||
| 672 | closeSourceBlock() | |
| 673 | closeExampleBlock() | |
| 674 | closeCenterBlock() | |
| 675 | closeVerseBlock() | |
| 676 | closeQuoteBlock() | |
| 677 | flushBlockState() | |
| 678 | ||
| 679 | html += footnotes.renderSection { text in | |
| 680 | processOrgInline(text, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) | |
| 681 | } | |
| 682 | ||
| 683 | return html | |
| 684 | } | |
Sources/OrgSwift/Tables.swift −48
| @@ -51,51 +51,3 @@ func tableAlignment(for cell: String) -> String? { | ||
| 51 | 51 | return "" |
| 52 | 52 | } |
| 53 | 53 | } |
| 54 | ||
| 55 | func renderHTMLTable( | |
| 56 | rows: [[String]], | |
| 57 | inlineRenderer: (String) -> String | |
| 58 | ) -> String { | |
| 59 | guard !rows.isEmpty else { return "" } | |
| 60 | let separatorCells: [String] | |
| 61 | if rows.count > 1, rows[1].count == 1 { | |
| 62 | separatorCells = parseOrgTableSeparatorRow(rows[1][0]) | |
| 63 | } else { | |
| 64 | separatorCells = rows.count > 1 ? rows[1] : [] | |
| 65 | } | |
| 66 | let hasHeaderSeparator = rows.count > 1 && !separatorCells.isEmpty && separatorCells.allSatisfy(isTableSeparatorCell) | |
| 67 | let headerRow = rows.first ?? [] | |
| 68 | let bodyRows = hasHeaderSeparator ? Array(rows.dropFirst(2)) : rows | |
| 69 | let columnAlignments = hasHeaderSeparator ? separatorCells.map(tableAlignment) : [] | |
| 70 | var html = "<table>\n" | |
| 71 | ||
| 72 | if hasHeaderSeparator { | |
| 73 | html += "<thead><tr>" | |
| 74 | for (index, cell) in headerRow.enumerated() { | |
| 75 | html += "<th" + tableAlignmentStyleAttribute(columnAlignment(at: index, in: columnAlignments)) + ">" + inlineRenderer(cell) + "</th>" | |
| 76 | } | |
| 77 | html += "</tr></thead>\n" | |
| 78 | } | |
| 79 | ||
| 80 | html += "<tbody>\n" | |
| 81 | for row in bodyRows { | |
| 82 | html += "<tr>" | |
| 83 | for (index, cell) in row.enumerated() { | |
| 84 | html += "<td" + tableAlignmentStyleAttribute(columnAlignment(at: index, in: columnAlignments)) + ">" + inlineRenderer(cell) + "</td>" | |
| 85 | } | |
| 86 | html += "</tr>\n" | |
| 87 | } | |
| 88 | html += "</tbody>\n" | |
| 89 | html += "</table>\n" | |
| 90 | return html | |
| 91 | } | |
| 92 | ||
| 93 | private func columnAlignment(at index: Int, in alignments: [String?]) -> String? { | |
| 94 | guard alignments.indices.contains(index) else { return nil } | |
| 95 | return alignments[index] | |
| 96 | } | |
| 97 | ||
| 98 | private func tableAlignmentStyleAttribute(_ alignment: String?) -> String { | |
| 99 | guard let alignment, !alignment.isEmpty else { return "" } | |
| 100 | return #" style="text-align: \#(alignment);""# | |
| 101 | } | |
Sources/OrgSwift/Timestamps.swift deleted −89
| @@ -1,89 +0,0 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // Org timestamps: active `<2024-01-15 Mon>`, inactive `[2024-01-15 Mon]`, with a time | |
| 4 | // `<… 10:30>`, a same-day time range `<… 10:00-11:45>`, and a multi-day range | |
| 5 | // `<…>--<…>`. Rendered as `<time>` elements the way orgo does: the date is data a | |
| 6 | // browser can act on, day names and repeater/warning cookies are dropped, and a range | |
| 7 | // becomes two `<time>`s joined by an en-dash. The interior arrives already | |
| 8 | // HTML-escaped, so these operate on `<…>` / `[…]`. | |
| 9 | ||
| 10 | /// Render one timestamp interior (no surrounding brackets) as one or two `<time>` | |
| 11 | /// elements. `interior` is e.g. "2024-01-15 Mon", "2024-01-15 Mon 10:30", | |
| 12 | /// "2024-01-15 Mon 10:00-11:45". A day name and any repeater/warning cookie are ignored. | |
| 13 | func renderTimestamp(interior: String, inactive: Bool) -> String { | |
| 14 | let ns = interior as NSString | |
| 15 | let full = NSRange(location: 0, length: ns.length) | |
| 16 | let cssClass = inactive ? "timestamp inactive" : "timestamp" | |
| 17 | ||
| 18 | guard let dateRegex = try? NSRegularExpression(pattern: #"\d{4}-\d{2}-\d{2}"#), | |
| 19 | let dateMatch = dateRegex.firstMatch(in: interior, range: full) else { | |
| 20 | // No date — not really a timestamp; hand the text back untouched. | |
| 21 | return interior | |
| 22 | } | |
| 23 | let date = ns.substring(with: dateMatch.range) | |
| 24 | ||
| 25 | var startTime: String? | |
| 26 | var endTime: String? | |
| 27 | if let timeRegex = try? NSRegularExpression(pattern: #"(\d{2}:\d{2})(?:-(\d{2}:\d{2}))?"#), | |
| 28 | let timeMatch = timeRegex.firstMatch(in: interior, range: full) { | |
| 29 | startTime = ns.substring(with: timeMatch.range(at: 1)) | |
| 30 | let endRange = timeMatch.range(at: 2) | |
| 31 | if endRange.location != NSNotFound { | |
| 32 | endTime = ns.substring(with: endRange) | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 36 | func time(_ datetime: String, _ text: String) -> String { | |
| 37 | #"<time class="\#(cssClass)" datetime="\#(datetime)">\#(text)</time>"# | |
| 38 | } | |
| 39 | ||
| 40 | if let startTime, let endTime { | |
| 41 | return time("\(date)T\(startTime)", "\(date) \(startTime)") | |
| 42 | + "–" | |
| 43 | + time("\(date)T\(endTime)", endTime) | |
| 44 | } | |
| 45 | if let startTime { | |
| 46 | return time("\(date)T\(startTime)", "\(date) \(startTime)") | |
| 47 | } | |
| 48 | return time(date, date) | |
| 49 | } | |
| 50 | ||
| 51 | /// Extract every timestamp in already-escaped inline text into protected fragments, | |
| 52 | /// leaving placeholders behind so later emphasis passes cannot touch a `<time>`. | |
| 53 | func protectTimestamps(in text: String, protectedFragments: inout [String: String]) -> String { | |
| 54 | var result = text | |
| 55 | ||
| 56 | // Multi-day range first, so its two `<…>` are consumed as one unit before the | |
| 57 | // single-timestamp pass sees them. `--` between them becomes the en-dash. | |
| 58 | result = protectMatches( | |
| 59 | in: result, | |
| 60 | pattern: #"<(\d{4}-\d{2}-\d{2}[^&]*?)>--<(\d{4}-\d{2}-\d{2}[^&]*?)>"#, | |
| 61 | protectedFragments: &protectedFragments | |
| 62 | ) { match, nsText in | |
| 63 | let a = nsText.substring(with: match.range(at: 1)) | |
| 64 | let b = nsText.substring(with: match.range(at: 2)) | |
| 65 | return renderTimestamp(interior: a, inactive: false) | |
| 66 | + "–" | |
| 67 | + renderTimestamp(interior: b, inactive: false) | |
| 68 | } | |
| 69 | ||
| 70 | // Active single. | |
| 71 | result = protectMatches( | |
| 72 | in: result, | |
| 73 | pattern: #"<(\d{4}-\d{2}-\d{2}[^&]*?)>"#, | |
| 74 | protectedFragments: &protectedFragments | |
| 75 | ) { match, nsText in | |
| 76 | renderTimestamp(interior: nsText.substring(with: match.range(at: 1)), inactive: false) | |
| 77 | } | |
| 78 | ||
| 79 | // Inactive. Requires a date inside, so `[not a stamp]` is left alone. | |
| 80 | result = protectMatches( | |
| 81 | in: result, | |
| 82 | pattern: #"\[(\d{4}-\d{2}-\d{2}[^\]]*?)\]"#, | |
| 83 | protectedFragments: &protectedFragments | |
| 84 | ) { match, nsText in | |
| 85 | renderTimestamp(interior: nsText.substring(with: match.range(at: 1)), inactive: true) | |
| 86 | } | |
| 87 | ||
| 88 | return result | |
| 89 | } | |
Tests/OrgSwiftTests/ASTTests.swift deleted −489
| @@ -1,489 +0,0 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgSwift | |
| 4 | ||
| 5 | /// Tests for the AST prototype: `source → OrgDocument → {HTML, AttributedString}`. | |
| 6 | /// | |
| 7 | /// Two things are being proven. First, that the tree carries the structure faithfully (the | |
| 8 | /// parse tests). Second — the actual argument for the split — that a second output format is | |
| 9 | /// a walk over the same tree rather than a second parser (the AttributedString tests), and | |
| 10 | /// that the tree-based HTML renderer can be held to the same conformance corpus as the | |
| 11 | /// shipped single-pass renderer. | |
| 12 | struct ASTParseTests { | |
| 13 | ||
| 14 | @Test | |
| 15 | func headingCarriesTodoPriorityAndTags() { | |
| 16 | let doc = OrgParser.parse("* TODO [#A] Write the parser :work:rust:") | |
| 17 | guard case .heading(let heading) = doc.elements.first else { | |
| 18 | Issue.record("expected a heading"); return | |
| 19 | } | |
| 20 | #expect(heading.level == 1) | |
| 21 | #expect(heading.todo == "TODO") | |
| 22 | #expect(heading.priority == "A") | |
| 23 | #expect(heading.tags == ["work", "rust"]) | |
| 24 | #expect(OrgParser.plain(heading.title) == "Write the parser") | |
| 25 | } | |
| 26 | ||
| 27 | @Test | |
| 28 | func emphasisNestsRatherThanFlattening() { | |
| 29 | // The point of a tree: bold containing italic is structure, not a markup string. | |
| 30 | let objects = OrgParser.parseInline("*bold /inner/ rest*") | |
| 31 | guard case .bold(let children) = objects.first else { | |
| 32 | Issue.record("expected bold"); return | |
| 33 | } | |
| 34 | #expect(children.contains { if case .italic = $0 { return true } else { return false } }) | |
| 35 | } | |
| 36 | ||
| 37 | @Test | |
| 38 | func documentKeywordsAreMetadataNotContent() { | |
| 39 | let doc = OrgParser.parse("#+TITLE: My Doc\n#+AUTHOR: Someone\n\nBody.") | |
| 40 | #expect(doc.keyword("title") == "My Doc") | |
| 41 | #expect(doc.keyword("author") == "Someone") | |
| 42 | // Metadata does not appear as a body element. | |
| 43 | #expect(doc.elements.count == 1) | |
| 44 | guard case .paragraph = doc.elements.first else { | |
| 45 | Issue.record("expected a single paragraph"); return | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 49 | @Test | |
| 50 | func nestedListsBecomeNestedItems() { | |
| 51 | let doc = OrgParser.parse(""" | |
| 52 | - outer | |
| 53 | - inner | |
| 54 | - deepest | |
| 55 | - second | |
| 56 | """) | |
| 57 | guard case .list(let list) = doc.elements.first else { | |
| 58 | Issue.record("expected a list"); return | |
| 59 | } | |
| 60 | #expect(list.items.count == 2) | |
| 61 | let inner = list.items[0].sublist | |
| 62 | #expect(inner != nil) | |
| 63 | #expect(inner?.items.first?.sublist?.items.count == 1) | |
| 64 | } | |
| 65 | ||
| 66 | @Test | |
| 67 | func tableKeepsRuleRowAndAlignments() { | |
| 68 | let doc = OrgParser.parse(""" | |
| 69 | | Name | Score | | |
| 70 | |:------+------:| | |
| 71 | | alpha | 10 | | |
| 72 | """) | |
| 73 | guard case .table(let table) = doc.elements.first else { | |
| 74 | Issue.record("expected a table"); return | |
| 75 | } | |
| 76 | #expect(table.rows.count == 3) | |
| 77 | #expect(table.headerRowCount == 1) | |
| 78 | #expect(table.alignments == [.left, .right]) | |
| 79 | if case .rule = table.rows[1] {} else { Issue.record("row 1 should be the rule") } | |
| 80 | } | |
| 81 | ||
| 82 | @Test | |
| 83 | func timestampsAndLinksBecomeTypedObjects() { | |
| 84 | let objects = OrgParser.parseInline("due <2024-01-15 Mon 10:30> see [[id:abc][the thing]]") | |
| 85 | let hasTimestamp = objects.contains { | |
| 86 | if case .timestamp(let stamp) = $0 { return stamp.machineValue == "2024-01-15T10:30" } | |
| 87 | return false | |
| 88 | } | |
| 89 | #expect(hasTimestamp) | |
| 90 | let hasIDLink = objects.contains { | |
| 91 | if case .link(let link) = $0, case .id(let identifier) = link.target { return identifier == "abc" } | |
| 92 | return false | |
| 93 | } | |
| 94 | #expect(hasIDLink) | |
| 95 | } | |
| 96 | } | |
| 97 | ||
| 98 | struct ASTRendererTests { | |
| 99 | ||
| 100 | /// The payoff: one parse, two output formats, neither re-deriving the other's work. | |
| 101 | @Test | |
| 102 | func oneParseFeedsTwoRenderers() { | |
| 103 | let document = OrgParser.parse("A *bold* claim with ~code~ and a [[https://example.com][link]].") | |
| 104 | ||
| 105 | let html = OrgHTMLTreeRenderer().render(document) | |
| 106 | #expect(html.contains("<strong>bold</strong>")) | |
| 107 | #expect(html.contains("<code>code</code>")) | |
| 108 | #expect(html.contains(#"<a href="https://example.com">link</a>"#)) | |
| 109 | ||
| 110 | let attributed = OrgAttributedStringRenderer().inline({ | |
| 111 | if case .paragraph(let objects) = document.elements[0] { return objects } | |
| 112 | return [] | |
| 113 | }()) | |
| 114 | // Same content, native representation: no markup, real attributes. | |
| 115 | let plain = String(attributed.characters) | |
| 116 | #expect(plain == "A bold claim with code and a link.") | |
| 117 | #expect(!plain.contains("<")) | |
| 118 | ||
| 119 | let boldRun = attributed.runs.first { $0.inlinePresentationIntent == .stronglyEmphasized } | |
| 120 | #expect(boldRun != nil) | |
| 121 | let codeRun = attributed.runs.first { $0.inlinePresentationIntent == .code } | |
| 122 | #expect(codeRun != nil) | |
| 123 | let linkRun = attributed.runs.first { $0.link != nil } | |
| 124 | #expect(linkRun?.link?.absoluteString == "https://example.com") | |
| 125 | } | |
| 126 | ||
| 127 | @Test | |
| 128 | func attributedStringCarriesRolesForNonStandardIntents() { | |
| 129 | let objects = OrgParser.parseInline("x^2 and <2024-01-15 Mon>") | |
| 130 | let attributed = OrgAttributedStringRenderer().inline(objects) | |
| 131 | let roles: [OrgRole] = attributed.runs.compactMap { $0[OrgRoleAttribute.self] } | |
| 132 | #expect(roles.contains(.superscript)) | |
| 133 | #expect(roles.contains(.timestamp)) | |
| 134 | } | |
| 135 | ||
| 136 | @Test | |
| 137 | func consecutiveListsOfDifferentKindsStaySeparate() { | |
| 138 | // An ordered list followed by a bullet list is two lists, not one with mixed items. | |
| 139 | let doc = OrgParser.parse(""" | |
| 140 | 1. first | |
| 141 | 2. second | |
| 142 | ||
| 143 | - [ ] todo | |
| 144 | - [X] done | |
| 145 | """) | |
| 146 | let lists = doc.elements.compactMap { element -> OrgList? in | |
| 147 | if case .list(let list) = element { return list } else { return nil } | |
| 148 | } | |
| 149 | #expect(lists.count == 2) | |
| 150 | #expect(lists.first?.kind == .ordered) | |
| 151 | #expect(lists.last?.kind == .unordered) | |
| 152 | #expect(lists.last?.items.first?.checkbox == .off) | |
| 153 | ||
| 154 | let html = OrgHTMLTreeRenderer().render(doc) | |
| 155 | #expect(html.contains("</ol>")) | |
| 156 | #expect(html.contains("<ul>")) | |
| 157 | } | |
| 158 | ||
| 159 | @Test | |
| 160 | func inlineFootnoteDefinesItsNoteAtTheReference() { | |
| 161 | let doc = OrgParser.parse("A claim.[fn:x:defined right here]") | |
| 162 | let html = OrgHTMLTreeRenderer().render(doc) | |
| 163 | #expect(html.contains(##"href="#fn-1">1</a>"##)) | |
| 164 | // Inline note text sits directly in the item; only reference-style notes get a <p>. | |
| 165 | #expect(html.contains(#"<li id="fn-1">defined right here "#)) | |
| 166 | #expect(!html.contains(#"<li id="fn-1"><p>"#)) | |
| 167 | } | |
| 168 | ||
| 169 | @Test | |
| 170 | func referenceStyleFootnoteKeepsItsParagraph() { | |
| 171 | let doc = OrgParser.parse("A claim.[fn:1]\n\n[fn:1] The definition.") | |
| 172 | let html = OrgHTMLTreeRenderer().render(doc) | |
| 173 | #expect(html.contains(#"<li id="fn-1"><p>The definition.</p>"#)) | |
| 174 | } | |
| 175 | ||
| 176 | @Test | |
| 177 | func timestampRangesRenderAsTwoTimeElements() { | |
| 178 | // Same-day: the end shows only its time, since the start carries the date. | |
| 179 | let sameDay = OrgHTMLTreeRenderer().render(OrgParser.parse("Range <2024-01-15 Mon 10:00-11:45>.")) | |
| 180 | #expect(sameDay.contains(#"datetime="2024-01-15T10:00">2024-01-15 10:00</time>"#)) | |
| 181 | #expect(sameDay.contains("–")) | |
| 182 | #expect(sameDay.contains(#"datetime="2024-01-15T11:45">11:45</time>"#)) | |
| 183 | ||
| 184 | // Multi-day: one timestamp carrying an end date, rendered as two stamps. | |
| 185 | let multiDay = OrgHTMLTreeRenderer().render(OrgParser.parse("Span <2024-01-15 Mon>--<2024-01-20 Sat>.")) | |
| 186 | #expect(multiDay.contains(#"datetime="2024-01-15">2024-01-15</time>"#)) | |
| 187 | #expect(multiDay.contains("–")) | |
| 188 | #expect(multiDay.contains(#"datetime="2024-01-20">2024-01-20</time>"#)) | |
| 189 | // The `--` join is consumed, not left as stray text. | |
| 190 | #expect(!multiDay.contains("--")) | |
| 191 | } | |
| 192 | ||
| 193 | @Test | |
| 194 | func treeRendererProducesStructuralHTML() { | |
| 195 | let document = OrgParser.parse(""" | |
| 196 | * Heading | |
| 197 | ||
| 198 | | a | b | | |
| 199 | |---+---| | |
| 200 | | 1 | 2 | | |
| 201 | ||
| 202 | - [ ] todo | |
| 203 | - [X] done | |
| 204 | """) | |
| 205 | let html = OrgHTMLTreeRenderer().render(document) | |
| 206 | #expect(html.contains("<h1>Heading</h1>")) | |
| 207 | #expect(html.contains("<thead>")) | |
| 208 | #expect(html.contains("<th>a</th>")) | |
| 209 | #expect(html.contains("<td>1</td>")) | |
| 210 | #expect(html.contains("<code>[ ]</code> todo")) | |
| 211 | #expect(html.contains("<code>[X]</code> done")) | |
| 212 | } | |
| 213 | } | |
| 214 | ||
| 215 | /// The tree-based HTML renderer against the same corpus and the same skeleton reduction the | |
| 216 | /// shipped renderer is held to. It now matches on every case the shipped renderer matches, so | |
| 217 | /// this asserts rather than merely reports: a regression fails, and `outofscope` starting to | |
| 218 | /// match fails too, forcing this record to be updated. | |
| 219 | struct ASTConformanceTests { | |
| 220 | ||
| 221 | /// Cases the tree renderer must match. `outofscope` is `scope: out` in the corpus — | |
| 222 | /// deliberately unsupported constructs, where orgo itself may differ — so it is expected | |
| 223 | /// to diverge, exactly as it does for the shipped renderer. | |
| 224 | private static let expectedToMatch: Set<String> = [ | |
| 225 | "blocks", "core", "elements", "footnote", "headings", "images", | |
| 226 | "lists", "minimal", "table", "tblfm", "timestamps", | |
| 227 | ] | |
| 228 | ||
| 229 | @Test | |
| 230 | func matchesTheCorpusWhereTheShippedRendererDoes() throws { | |
| 231 | guard let dir = astCorpusCasesDir() else { | |
| 232 | print("org-conformance corpus not found — skipping") | |
| 233 | return | |
| 234 | } | |
| 235 | // The same configuration the shipped renderer is measured in: body content only, | |
| 236 | // with `*` rendering as <h2> because orgo's title owns <h1>. | |
| 237 | let renderer = OrgHTMLTreeRenderer( | |
| 238 | options: OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1) | |
| 239 | ) | |
| 240 | var matched: [String] = [] | |
| 241 | var diverged: [String] = [] | |
| 242 | ||
| 243 | for name in astCaseNames(dir) { | |
| 244 | let source = (try? String(contentsOf: dir.appendingPathComponent("\(name).org"), encoding: .utf8)) ?? "" | |
| 245 | let goldenRaw = (try? String(contentsOf: dir.appendingPathComponent("\(name).skeleton"), encoding: .utf8)) ?? "" | |
| 246 | let trimmed = goldenRaw.hasSuffix("\n") ? String(goldenRaw.dropLast()) : goldenRaw | |
| 247 | let golden = trimmed.isEmpty ? [] : trimmed.components(separatedBy: "\n") | |
| 248 | ||
| 249 | let html = renderer.render(OrgParser.parse(source)) | |
| 250 | let got = OrgSkeleton.skeleton(html) | |
| 251 | if got == golden { | |
| 252 | matched.append(name) | |
| 253 | } else { | |
| 254 | diverged.append(name) | |
| 255 | if ProcessInfo.processInfo.environment["ORG_DUMP"] != nil { | |
| 256 | let firstDiff = (0..<max(got.count, golden.count)).first { | |
| 257 | ($0 < golden.count ? golden[$0] : "∅") != ($0 < got.count ? got[$0] : "∅") | |
| 258 | } | |
| 259 | if let k = firstDiff { | |
| 260 | print("AST-DIFF \(name) @\(k): orgo=\(k < golden.count ? golden[k] : "∅") ours=\(k < got.count ? got[k] : "∅")") | |
| 261 | } | |
| 262 | } | |
| 263 | } | |
| 264 | } | |
| 265 | print("AST-CONFORMANCE matched=\(matched.count)/\(matched.count + diverged.count) \(matched.sorted())") | |
| 266 | print("AST-CONFORMANCE diverged=\(diverged.sorted())") | |
| 267 | ||
| 268 | #expect(Set(matched) == Self.expectedToMatch, | |
| 269 | "tree renderer conformance changed — matched \(matched.sorted()), expected \(Self.expectedToMatch.sorted())") | |
| 270 | } | |
| 271 | } | |
| 272 | ||
| 273 | /// Equivalence with the shipped renderer under the render options, which the corpus does not | |
| 274 | /// exercise (it renders with no repository context). This is the evidence that pointing | |
| 275 | /// `OrgRenderer.renderToHTML` at the tree would be a no-op for callers. | |
| 276 | /// | |
| 277 | /// Comparison is by semantic skeleton, which keeps `href` and `src` — so resolved URLs are | |
| 278 | /// actually checked — while ignoring the class-name and whitespace differences that carry no | |
| 279 | /// meaning. | |
| 280 | struct ASTShippedEquivalenceTests { | |
| 281 | ||
| 282 | /// gitbay's configuration: images resolve against `raw`, links against `blob`. | |
| 283 | private static let repositoryOptions = OrgRenderOptions( | |
| 284 | host: "gitbay.org", | |
| 285 | owner: "krz", | |
| 286 | repositoryName: "gitbay", | |
| 287 | ref: "HEAD", | |
| 288 | readmePath: "README.org", | |
| 289 | metadataHeader: true, | |
| 290 | headingLevelOffset: 0, | |
| 291 | imagePathSegment: "raw", | |
| 292 | linkPathSegment: "blob" | |
| 293 | ) | |
| 294 | ||
| 295 | private static let source = """ | |
| 296 | #+TITLE: My Doc | |
| 297 | #+AUTHOR: Someone | |
| 298 | #+DATE: 2026-08-28 | |
| 299 | ||
| 300 | * Heading | |
| 301 | ||
| 302 | A paragraph with *bold*, ~code~, and a bare URL https://example.com here. | |
| 303 | ||
| 304 | A relative link to [[docs/DESIGN.org][the design]] and an absolute one to | |
| 305 | [[https://example.org][elsewhere]], plus an in-page [[id:anchor]] link. | |
| 306 | ||
| 307 | [[file:docs/logo.png]] | |
| 308 | ||
| 309 | | Name | Score | | |
| 310 | |:------+------:| | |
| 311 | | alpha | 10 | | |
| 312 | """ | |
| 313 | ||
| 314 | private func skeletons(_ options: OrgRenderOptions) -> (shipped: [String], tree: [String]) { | |
| 315 | let shipped = OrgRenderer.renderToHTML(Self.source, options: options) | |
| 316 | let tree = OrgHTMLTreeRenderer(options: options).render(OrgParser.parse(Self.source)) | |
| 317 | return (OrgSkeleton.skeleton(shipped), OrgSkeleton.skeleton(tree)) | |
| 318 | } | |
| 319 | ||
| 320 | @Test | |
| 321 | func agreesWithShippedRendererUnderRepositoryOptions() { | |
| 322 | let (shipped, tree) = skeletons(Self.repositoryOptions) | |
| 323 | #expect(tree == shipped, firstDifference(shipped, tree)) | |
| 324 | } | |
| 325 | ||
| 326 | @Test | |
| 327 | func agreesWithShippedRendererWithMetadataOff() { | |
| 328 | var options = Self.repositoryOptions | |
| 329 | options.metadataHeader = false | |
| 330 | options.headingLevelOffset = 1 | |
| 331 | let (shipped, tree) = skeletons(options) | |
| 332 | #expect(tree == shipped, firstDifference(shipped, tree)) | |
| 333 | } | |
| 334 | ||
| 335 | @Test | |
| 336 | func agreesWithShippedRendererWithNoRepositoryContext() { | |
| 337 | // owner/repo nil disables resolution: relative targets must survive untouched in both. | |
| 338 | let (shipped, tree) = skeletons(OrgRenderOptions()) | |
| 339 | #expect(tree == shipped, firstDifference(shipped, tree)) | |
| 340 | } | |
| 341 | ||
| 342 | /// Both renderers must resolve repository-relative targets to the same URLs, against the | |
| 343 | /// segment each kind belongs to. | |
| 344 | @Test | |
| 345 | func resolvesRelativeURLsIdenticallyToShipped() { | |
| 346 | let shipped = OrgRenderer.renderToHTML(Self.source, options: Self.repositoryOptions) | |
| 347 | let tree = OrgHTMLTreeRenderer(options: Self.repositoryOptions).render(OrgParser.parse(Self.source)) | |
| 348 | for expected in [ | |
| 349 | "https://gitbay.org/krz/gitbay/raw/HEAD/docs/logo.png", // image → raw | |
| 350 | "https://gitbay.org/krz/gitbay/blob/HEAD/docs/DESIGN.org", // link → blob | |
| 351 | "https://example.org", | |
| 352 | ] { | |
| 353 | #expect(shipped.contains(expected), "shipped lost \(expected)") | |
| 354 | #expect(tree.contains(expected), "tree lost \(expected)") | |
| 355 | } | |
| 356 | } | |
| 357 | ||
| 358 | /// An unsafe scheme must not become an anchor in either renderer. | |
| 359 | @Test | |
| 360 | func rejectsUnsafeSchemesLikeShipped() { | |
| 361 | let hostile = "[[javascript:alert(1)][click]]" | |
| 362 | let shipped = OrgRenderer.renderToHTML(hostile) | |
| 363 | let tree = OrgHTMLTreeRenderer().render(OrgParser.parse(hostile)) | |
| 364 | #expect(!shipped.lowercased().contains("javascript:")) | |
| 365 | #expect(!tree.lowercased().contains("javascript:")) | |
| 366 | #expect(tree.contains("click")) | |
| 367 | } | |
| 368 | ||
| 369 | /// Shipped and tree must agree on every corpus case, so swapping does not change what a | |
| 370 | /// consumer renders — including `outofscope`, where both diverge from orgo but must still | |
| 371 | /// diverge the same way. | |
| 372 | /// | |
| 373 | /// One documented exception: `outofscope` contains a LOGBOOK clock entry whose halves are | |
| 374 | /// *inactive* timestamps, `[…]--[…]`. The tree joins that into one range (orgo does the | |
| 375 | /// same — `try_timestamp` applies the `--` rule to both bracket kinds, requiring only that | |
| 376 | /// the halves agree on activeness), while the shipped renderer joins active ranges only | |
| 377 | /// and leaves `--` as text. The tree is the more correct of the two, so this is recorded | |
| 378 | /// as the single intentional behaviour change a swap would introduce rather than degraded | |
| 379 | /// to match. | |
| 380 | @Test | |
| 381 | func agreesWithShippedRendererAcrossEveryCorpusCase() throws { | |
| 382 | guard let dir = astCorpusCasesDir() else { | |
| 383 | print("org-conformance corpus not found — skipping") | |
| 384 | return | |
| 385 | } | |
| 386 | let knownDifferences = ["outofscope"] | |
| 387 | let options = OrgRenderOptions(metadataHeader: false, headingLevelOffset: 1) | |
| 388 | var mismatched: [String] = [] | |
| 389 | for name in astCaseNames(dir) where !knownDifferences.contains(name) { | |
| 390 | let source = (try? String(contentsOf: dir.appendingPathComponent("\(name).org"), encoding: .utf8)) ?? "" | |
| 391 | let shipped = OrgSkeleton.skeleton(OrgRenderer.renderToHTML(source, options: options)) | |
| 392 | let tree = OrgSkeleton.skeleton(OrgHTMLTreeRenderer(options: options).render(OrgParser.parse(source))) | |
| 393 | if shipped != tree { | |
| 394 | mismatched.append("\(name) [\(firstDifference(shipped, tree))]") | |
| 395 | } | |
| 396 | } | |
| 397 | #expect(mismatched.isEmpty, "shipped and tree disagree on: \(mismatched.joined(separator: "; "))") | |
| 398 | } | |
| 399 | ||
| 400 | /// The inactive-range difference above, asserted directly so it cannot change unnoticed. | |
| 401 | @Test | |
| 402 | func joinsInactiveTimestampRangesWhereShippedDoesNot() { | |
| 403 | let source = "CLOCK: [2024-01-15 Mon 09:00]--[2024-01-15 Mon 10:00]" | |
| 404 | let shipped = OrgRenderer.renderToHTML(source) | |
| 405 | let tree = OrgHTMLTreeRenderer().render(OrgParser.parse(source)) | |
| 406 | #expect(shipped.contains("--")) | |
| 407 | #expect(!tree.contains("--")) | |
| 408 | #expect(tree.contains("–")) | |
| 409 | } | |
| 410 | ||
| 411 | /// A battery drawn from the shipped renderer's own test inputs, to catch behaviour the | |
| 412 | /// corpus does not reach. Every case must render identically, or the swap would regress a | |
| 413 | /// consumer. | |
| 414 | @Test | |
| 415 | func agreesWithShippedRendererAcrossConstructBattery() { | |
| 416 | let cases: [(name: String, source: String)] = [ | |
| 417 | ("comment", "# This is a comment\nNormal text"), | |
| 418 | ("strike", "+deleted text+"), | |
| 419 | ("example", "#+begin_example\nhello world\n#+end_example"), | |
| 420 | ("bare image link", "[[https://example.com/path/to/file.png]]"), | |
| 421 | ("wrapped bullet", "- First line\n continues here"), | |
| 422 | ("directives", "#+OPTIONS: toc:nil\n#+PROPERTY: header-args :results output\nBody text"), | |
| 423 | ("verse", "#+begin_verse\nThere is a line.\n And an indented line.\n#+end_verse"), | |
| 424 | ("named block", "#+CAPTION: Build output\n#+NAME: build-log\n#+begin_example\nhello world\n#+end_example"), | |
| 425 | ("linked image", "[[https://example.com][[https://img.example.net/a.jpg]]]"), | |
| 426 | ("table alignment", "| Left | Center | Right |\n|:-----+:-----:+------:|\n| a | b | c |"), | |
| 427 | ("nested list", "- Bullet with nested list\n - Nested child one\n - Nested child two"), | |
| 428 | ("timestamps", "An active <2024-01-15 Mon> and inactive [2024-01-15 Mon]."), | |
| 429 | ("time", "At <2024-01-15 Mon 10:30>."), | |
| 430 | ("same-day range", "Range <2024-01-15 Mon 10:00-11:45>."), | |
| 431 | ("multi-day range", "Span <2024-01-15 Mon>--<2024-01-20 Sat>."), | |
| 432 | ("repeater", "Repeats <2024-01-15 Mon +1w>."), | |
| 433 | ("non-timestamp", "Compare 3 < 4 and [not a stamp]."), | |
| 434 | ("inline footnote", "See here.[fn:x:defined inline]"), | |
| 435 | ("footnotes", "A claim.[fn:1] Another.[fn:2]\n\n[fn:1] First note.\n[fn:2] Second with /emphasis/."), | |
| 436 | ("heading tags", "* Write the parser :work:rust:"), | |
| 437 | ("bare url", "See https://example.com now."), | |
| 438 | ("email", "Mail someone@example.com today."), | |
| 439 | ("deep heading", "****** Deep"), | |
| 440 | ("checkboxes", "- [ ] todo\n- [X] done\n- [-] partial"), | |
| 441 | ("description list", "- term one :: the first definition"), | |
| 442 | ("quote", "#+begin_quote\nA quoted paragraph.\n#+end_quote"), | |
| 443 | ("src", "#+begin_src swift\nlet x = 1\n#+end_src"), | |
| 444 | ("superscript", "N^2 and H^{2O}."), | |
| 445 | ] | |
| 446 | ||
| 447 | var mismatched: [String] = [] | |
| 448 | for (name, source) in cases { | |
| 449 | let shipped = OrgSkeleton.skeleton(OrgRenderer.renderToHTML(source)) | |
| 450 | let tree = OrgSkeleton.skeleton(OrgHTMLTreeRenderer().render(OrgParser.parse(source))) | |
| 451 | if shipped != tree { | |
| 452 | mismatched.append(name) | |
| 453 | if ProcessInfo.processInfo.environment["ORG_DUMP"] != nil { | |
| 454 | print("BATTERY-DIFF \(name): \(firstDifference(shipped, tree))") | |
| 455 | } | |
| 456 | } | |
| 457 | } | |
| 458 | #expect(mismatched.isEmpty, "tree diverges from shipped on: \(mismatched.joined(separator: ", "))") | |
| 459 | } | |
| 460 | ||
| 461 | private func firstDifference(_ shipped: [String], _ tree: [String]) -> Comment { | |
| 462 | let index = (0..<max(shipped.count, tree.count)).first { | |
| 463 | ($0 < shipped.count ? shipped[$0] : "∅") != ($0 < tree.count ? tree[$0] : "∅") | |
| 464 | } | |
| 465 | guard let index else { return "identical" } | |
| 466 | return """ | |
| 467 | diverges at \(index): \ | |
| 468 | shipped=\(index < shipped.count ? shipped[index] : "∅") \ | |
| 469 | tree=\(index < tree.count ? tree[index] : "∅") | |
| 470 | """ | |
| 471 | } | |
| 472 | } | |
| 473 | ||
| 474 | private func astCorpusCasesDir() -> URL? { | |
| 475 | if let env = ProcessInfo.processInfo.environment["ORG_CONFORMANCE_DIR"] { | |
| 476 | let cases = URL(fileURLWithPath: env).appendingPathComponent("cases") | |
| 477 | if FileManager.default.fileExists(atPath: cases.path) { return cases } | |
| 478 | } | |
| 479 | let pkgRoot = URL(fileURLWithPath: #filePath) | |
| 480 | .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() | |
| 481 | let sibling = pkgRoot.deletingLastPathComponent() | |
| 482 | .appendingPathComponent("org-conformance").appendingPathComponent("cases") | |
| 483 | return FileManager.default.fileExists(atPath: sibling.path) ? sibling : nil | |
| 484 | } | |
| 485 | ||
| 486 | private func astCaseNames(_ dir: URL) -> [String] { | |
| 487 | let items = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? [] | |
| 488 | return items.filter { $0.hasSuffix(".org") }.map { String($0.dropLast(4)) }.sorted() | |
| 489 | } | |
Tests/OrgSwiftTests/OrgTreeTests.swift added +300
| @@ -0,0 +1,300 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgSwift | |
| 4 | ||
| 5 | /// Tests for the element tree: `source → OrgDocument → {HTML, AttributedString}`. | |
| 6 | /// | |
| 7 | /// The parse tests assert the tree carries structure faithfully. The renderer tests assert | |
| 8 | /// the point of the split — that a second output format is a walk over the same tree rather | |
| 9 | /// than a second parser. Corpus conformance is gated separately, in `ConformanceTests`. | |
| 10 | struct ASTParseTests { | |
| 11 | ||
| 12 | @Test | |
| 13 | func headingCarriesTodoPriorityAndTags() { | |
| 14 | let doc = OrgParser.parse("* TODO [#A] Write the parser :work:rust:") | |
| 15 | guard case .heading(let heading) = doc.elements.first else { | |
| 16 | Issue.record("expected a heading"); return | |
| 17 | } | |
| 18 | #expect(heading.level == 1) | |
| 19 | #expect(heading.todo == "TODO") | |
| 20 | #expect(heading.priority == "A") | |
| 21 | #expect(heading.tags == ["work", "rust"]) | |
| 22 | #expect(OrgParser.plain(heading.title) == "Write the parser") | |
| 23 | } | |
| 24 | ||
| 25 | @Test | |
| 26 | func emphasisNestsRatherThanFlattening() { | |
| 27 | // The point of a tree: bold containing italic is structure, not a markup string. | |
| 28 | let objects = OrgParser.parseInline("*bold /inner/ rest*") | |
| 29 | guard case .bold(let children) = objects.first else { | |
| 30 | Issue.record("expected bold"); return | |
| 31 | } | |
| 32 | #expect(children.contains { if case .italic = $0 { return true } else { return false } }) | |
| 33 | } | |
| 34 | ||
| 35 | @Test | |
| 36 | func documentKeywordsAreMetadataNotContent() { | |
| 37 | let doc = OrgParser.parse("#+TITLE: My Doc\n#+AUTHOR: Someone\n\nBody.") | |
| 38 | #expect(doc.keyword("title") == "My Doc") | |
| 39 | #expect(doc.keyword("author") == "Someone") | |
| 40 | // Metadata does not appear as a body element. | |
| 41 | #expect(doc.elements.count == 1) | |
| 42 | guard case .paragraph = doc.elements.first else { | |
| 43 | Issue.record("expected a single paragraph"); return | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | @Test | |
| 48 | func nestedListsBecomeNestedItems() { | |
| 49 | let doc = OrgParser.parse(""" | |
| 50 | - outer | |
| 51 | - inner | |
| 52 | - deepest | |
| 53 | - second | |
| 54 | """) | |
| 55 | guard case .list(let list) = doc.elements.first else { | |
| 56 | Issue.record("expected a list"); return | |
| 57 | } | |
| 58 | #expect(list.items.count == 2) | |
| 59 | let inner = list.items[0].sublist | |
| 60 | #expect(inner != nil) | |
| 61 | #expect(inner?.items.first?.sublist?.items.count == 1) | |
| 62 | } | |
| 63 | ||
| 64 | @Test | |
| 65 | func tableKeepsRuleRowAndAlignments() { | |
| 66 | let doc = OrgParser.parse(""" | |
| 67 | | Name | Score | | |
| 68 | |:------+------:| | |
| 69 | | alpha | 10 | | |
| 70 | """) | |
| 71 | guard case .table(let table) = doc.elements.first else { | |
| 72 | Issue.record("expected a table"); return | |
| 73 | } | |
| 74 | #expect(table.rows.count == 3) | |
| 75 | #expect(table.headerRowCount == 1) | |
| 76 | #expect(table.alignments == [.left, .right]) | |
| 77 | if case .rule = table.rows[1] {} else { Issue.record("row 1 should be the rule") } | |
| 78 | } | |
| 79 | ||
| 80 | @Test | |
| 81 | func timestampsAndLinksBecomeTypedObjects() { | |
| 82 | let objects = OrgParser.parseInline("due <2024-01-15 Mon 10:30> see [[id:abc][the thing]]") | |
| 83 | let hasTimestamp = objects.contains { | |
| 84 | if case .timestamp(let stamp) = $0 { return stamp.machineValue == "2024-01-15T10:30" } | |
| 85 | return false | |
| 86 | } | |
| 87 | #expect(hasTimestamp) | |
| 88 | let hasIDLink = objects.contains { | |
| 89 | if case .link(let link) = $0, case .id(let identifier) = link.target { return identifier == "abc" } | |
| 90 | return false | |
| 91 | } | |
| 92 | #expect(hasIDLink) | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | struct ASTRendererTests { | |
| 97 | ||
| 98 | /// The payoff: one parse, two output formats, neither re-deriving the other's work. | |
| 99 | @Test | |
| 100 | func oneParseFeedsTwoRenderers() { | |
| 101 | let document = OrgParser.parse("A *bold* claim with ~code~ and a [[https://example.com][link]].") | |
| 102 | ||
| 103 | let html = OrgHTMLTreeRenderer().render(document) | |
| 104 | #expect(html.contains("<strong>bold</strong>")) | |
| 105 | #expect(html.contains("<code>code</code>")) | |
| 106 | #expect(html.contains(#"<a href="https://example.com">link</a>"#)) | |
| 107 | ||
| 108 | let attributed = OrgAttributedStringRenderer().inline({ | |
| 109 | if case .paragraph(let objects) = document.elements[0] { return objects } | |
| 110 | return [] | |
| 111 | }()) | |
| 112 | // Same content, native representation: no markup, real attributes. | |
| 113 | let plain = String(attributed.characters) | |
| 114 | #expect(plain == "A bold claim with code and a link.") | |
| 115 | #expect(!plain.contains("<")) | |
| 116 | ||
| 117 | let boldRun = attributed.runs.first { $0.inlinePresentationIntent == .stronglyEmphasized } | |
| 118 | #expect(boldRun != nil) | |
| 119 | let codeRun = attributed.runs.first { $0.inlinePresentationIntent == .code } | |
| 120 | #expect(codeRun != nil) | |
| 121 | let linkRun = attributed.runs.first { $0.link != nil } | |
| 122 | #expect(linkRun?.link?.absoluteString == "https://example.com") | |
| 123 | } | |
| 124 | ||
| 125 | @Test | |
| 126 | func attributedStringCarriesRolesForNonStandardIntents() { | |
| 127 | let objects = OrgParser.parseInline("x^2 and <2024-01-15 Mon>") | |
| 128 | let attributed = OrgAttributedStringRenderer().inline(objects) | |
| 129 | let roles: [OrgRole] = attributed.runs.compactMap { $0[OrgRoleAttribute.self] } | |
| 130 | #expect(roles.contains(.superscript)) | |
| 131 | #expect(roles.contains(.timestamp)) | |
| 132 | } | |
| 133 | ||
| 134 | @Test | |
| 135 | func consecutiveListsOfDifferentKindsStaySeparate() { | |
| 136 | // An ordered list followed by a bullet list is two lists, not one with mixed items. | |
| 137 | let doc = OrgParser.parse(""" | |
| 138 | 1. first | |
| 139 | 2. second | |
| 140 | ||
| 141 | - [ ] todo | |
| 142 | - [X] done | |
| 143 | """) | |
| 144 | let lists = doc.elements.compactMap { element -> OrgList? in | |
| 145 | if case .list(let list) = element { return list } else { return nil } | |
| 146 | } | |
| 147 | #expect(lists.count == 2) | |
| 148 | #expect(lists.first?.kind == .ordered) | |
| 149 | #expect(lists.last?.kind == .unordered) | |
| 150 | #expect(lists.last?.items.first?.checkbox == .off) | |
| 151 | ||
| 152 | let html = OrgHTMLTreeRenderer().render(doc) | |
| 153 | #expect(html.contains("</ol>")) | |
| 154 | #expect(html.contains("<ul>")) | |
| 155 | } | |
| 156 | ||
| 157 | @Test | |
| 158 | func inlineFootnoteDefinesItsNoteAtTheReference() { | |
| 159 | let doc = OrgParser.parse("A claim.[fn:x:defined right here]") | |
| 160 | let html = OrgHTMLTreeRenderer().render(doc) | |
| 161 | #expect(html.contains(##"href="#fn-1">1</a>"##)) | |
| 162 | // Inline note text sits directly in the item; only reference-style notes get a <p>. | |
| 163 | #expect(html.contains(#"<li id="fn-1">defined right here "#)) | |
| 164 | #expect(!html.contains(#"<li id="fn-1"><p>"#)) | |
| 165 | } | |
| 166 | ||
| 167 | @Test | |
| 168 | func referenceStyleFootnoteKeepsItsParagraph() { | |
| 169 | let doc = OrgParser.parse("A claim.[fn:1]\n\n[fn:1] The definition.") | |
| 170 | let html = OrgHTMLTreeRenderer().render(doc) | |
| 171 | #expect(html.contains(#"<li id="fn-1"><p>The definition.</p>"#)) | |
| 172 | } | |
| 173 | ||
| 174 | @Test | |
| 175 | func timestampRangesRenderAsTwoTimeElements() { | |
| 176 | // Same-day: the end shows only its time, since the start carries the date. | |
| 177 | let sameDay = OrgHTMLTreeRenderer().render(OrgParser.parse("Range <2024-01-15 Mon 10:00-11:45>.")) | |
| 178 | #expect(sameDay.contains(#"datetime="2024-01-15T10:00">2024-01-15 10:00</time>"#)) | |
| 179 | #expect(sameDay.contains("–")) | |
| 180 | #expect(sameDay.contains(#"datetime="2024-01-15T11:45">11:45</time>"#)) | |
| 181 | ||
| 182 | // Multi-day: one timestamp carrying an end date, rendered as two stamps. | |
| 183 | let multiDay = OrgHTMLTreeRenderer().render(OrgParser.parse("Span <2024-01-15 Mon>--<2024-01-20 Sat>.")) | |
| 184 | #expect(multiDay.contains(#"datetime="2024-01-15">2024-01-15</time>"#)) | |
| 185 | #expect(multiDay.contains("–")) | |
| 186 | #expect(multiDay.contains(#"datetime="2024-01-20">2024-01-20</time>"#)) | |
| 187 | // The `--` join is consumed, not left as stray text. | |
| 188 | #expect(!multiDay.contains("--")) | |
| 189 | } | |
| 190 | ||
| 191 | @Test | |
| 192 | func treeRendererProducesStructuralHTML() { | |
| 193 | let document = OrgParser.parse(""" | |
| 194 | * Heading | |
| 195 | ||
| 196 | | a | b | | |
| 197 | |---+---| | |
| 198 | | 1 | 2 | | |
| 199 | ||
| 200 | - [ ] todo | |
| 201 | - [X] done | |
| 202 | """) | |
| 203 | let html = OrgHTMLTreeRenderer().render(document) | |
| 204 | #expect(html.contains("<h1>Heading</h1>")) | |
| 205 | #expect(html.contains("<thead>")) | |
| 206 | #expect(html.contains("<th>a</th>")) | |
| 207 | #expect(html.contains("<td>1</td>")) | |
| 208 | #expect(html.contains("<code>[ ]</code> todo")) | |
| 209 | #expect(html.contains("<code>[X]</code> done")) | |
| 210 | } | |
| 211 | } | |
| 212 | ||
| 213 | /// The render options, which the conformance corpus does not exercise because it renders with | |
| 214 | /// no repository context. These were written as shipped-vs-tree equivalence checks during the | |
| 215 | /// migration; now that the tree *is* the renderer, they assert the behaviour directly. | |
| 216 | struct OrgRenderOptionsTests { | |
| 217 | ||
| 218 | /// gitbay's configuration: images resolve against `raw`, links against `blob`. | |
| 219 | private static let repositoryOptions = OrgRenderOptions( | |
| 220 | host: "gitbay.org", | |
| 221 | owner: "krz", | |
| 222 | repositoryName: "gitbay", | |
| 223 | ref: "HEAD", | |
| 224 | readmePath: "README.org", | |
| 225 | imagePathSegment: "raw", | |
| 226 | linkPathSegment: "blob" | |
| 227 | ) | |
| 228 | ||
| 229 | @Test | |
| 230 | func resolvesRepositoryRelativeURLsAgainstTheirOwnSegment() { | |
| 231 | let html = OrgRenderer.renderToHTML(""" | |
| 232 | A relative link to [[docs/DESIGN.org][the design]] and an absolute one to | |
| 233 | [[https://example.org][elsewhere]]. | |
| 234 | ||
| 235 | [[file:docs/logo.png]] | |
| 236 | """, options: Self.repositoryOptions) | |
| 237 | ||
| 238 | #expect(html.contains("https://gitbay.org/krz/gitbay/raw/HEAD/docs/logo.png")) | |
| 239 | #expect(html.contains("https://gitbay.org/krz/gitbay/blob/HEAD/docs/DESIGN.org")) | |
| 240 | #expect(html.contains("https://example.org")) | |
| 241 | } | |
| 242 | ||
| 243 | @Test | |
| 244 | func leavesRelativeTargetsAloneWithoutRepositoryContext() { | |
| 245 | let html = OrgRenderer.renderToHTML("[[docs/DESIGN.org][the design]]\n\n[[file:logo.png]]") | |
| 246 | #expect(html.contains(#"href="docs/DESIGN.org""#)) | |
| 247 | #expect(html.contains(#"src="logo.png""#)) | |
| 248 | } | |
| 249 | ||
| 250 | @Test | |
| 251 | func emitsTheMetadataHeaderOnlyWhenAsked() { | |
| 252 | let source = "#+TITLE: My Doc\n#+AUTHOR: Someone\n\nBody." | |
| 253 | ||
| 254 | let withHeader = OrgRenderer.renderToHTML(source) | |
| 255 | #expect(withHeader.contains(#"<h1 class="org-title">My Doc</h1>"#)) | |
| 256 | #expect(withHeader.contains(#"<p class="org-author">Someone</p>"#)) | |
| 257 | ||
| 258 | let without = OrgRenderer.renderToHTML(source, options: OrgRenderOptions(metadataHeader: false)) | |
| 259 | #expect(!without.contains("org-title")) | |
| 260 | #expect(!without.contains("My Doc")) | |
| 261 | #expect(without.contains("<p>Body.</p>")) | |
| 262 | } | |
| 263 | ||
| 264 | @Test | |
| 265 | func rejectsUnsafeSchemes() { | |
| 266 | let html = OrgRenderer.renderToHTML("[[javascript:alert(1)][click]]") | |
| 267 | #expect(!html.lowercased().contains("javascript:")) | |
| 268 | // The link degrades to its text rather than becoming a bad anchor. | |
| 269 | #expect(html.contains("click")) | |
| 270 | } | |
| 271 | ||
| 272 | /// A range whose halves are inactive timestamps is still a range. orgo applies the `--` | |
| 273 | /// rule to both bracket kinds, requiring only that the halves agree on activeness; the | |
| 274 | /// renderer this replaced joined active ranges only, so this is the one behaviour the | |
| 275 | /// swap deliberately changed. | |
| 276 | @Test | |
| 277 | func joinsInactiveTimestampRanges() { | |
| 278 | let html = OrgRenderer.renderToHTML("CLOCK: [2024-01-15 Mon 09:00]--[2024-01-15 Mon 10:00]") | |
| 279 | #expect(!html.contains("--")) | |
| 280 | #expect(html.contains("–")) | |
| 281 | #expect(html.contains(#"class="timestamp inactive""#)) | |
| 282 | } | |
| 283 | } | |
| 284 | ||
| 285 | private func astCorpusCasesDir() -> URL? { | |
| 286 | if let env = ProcessInfo.processInfo.environment["ORG_CONFORMANCE_DIR"] { | |
| 287 | let cases = URL(fileURLWithPath: env).appendingPathComponent("cases") | |
| 288 | if FileManager.default.fileExists(atPath: cases.path) { return cases } | |
| 289 | } | |
| 290 | let pkgRoot = URL(fileURLWithPath: #filePath) | |
| 291 | .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() | |
| 292 | let sibling = pkgRoot.deletingLastPathComponent() | |
| 293 | .appendingPathComponent("org-conformance").appendingPathComponent("cases") | |
| 294 | return FileManager.default.fileExists(atPath: sibling.path) ? sibling : nil | |
| 295 | } | |
| 296 | ||
| 297 | private func astCaseNames(_ dir: URL) -> [String] { | |
| 298 | let items = (try? FileManager.default.contentsOfDirectory(atPath: dir.path)) ?? [] | |
| 299 | return items.filter { $0.hasSuffix(".org") }.map { String($0.dropLast(4)) }.sorted() | |
| 300 | } | |