krz/org-swift

A dependency-free Swift library that renders org-mode to sanitized HTML. html library org-mode swift

Commit a5dc89631c

a5dc89631cf65a183edae1bfa22ef147bd8f1686

parent: 6e2ef80a16

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-31 23:06 UTC

Render a README's badge row as badges

A paragraph holding nothing but images and image links is the badge row
a README opens with. It became prose: the inline styler can only show an
image's alt text, so `[[builds][file:badge.svg]]` rendered as the badge
URL in italics.

Such a paragraph is now a `.badges` block — a row of images at their own
size, each tapping through to its link. Natural size, not a figure: a
figure fills the column, which would blow a 95x20 badge up to a
pixelated bar. A lone image on its own line is still a figure, and prose
anywhere in the paragraph still makes it prose.

Layout: unified · split

Sources/OrgSwiftUI/OrgBadgeRowView.swift added +49
@@ -0,0 +1,49 @@
1import SwiftUI
2
3/// The row of status badges a README opens with.
4///
5/// Badges keep their own size — a badge is authored at the pixel size it is meant to be
6/// seen at, so `resizable()` would only blur it. They wrap to the next line when the row is
7/// wider than the column, which is what a long badge row does on the web.
8struct OrgBadgeRowView: View {
9 let badges: [OrgBadge]
10
11 /// The scale badge artwork is served at. Retina artwork is the only way a small raster
12 /// badge stays sharp, and a badge has no other channel to declare its scale.
13 static let scale: CGFloat = 2
14
15 var body: some View {
16 // `ViewThatFits` is not a wrap; `Layout` is more than a badge row needs. A flexible
17 // `HStack` inside a horizontal `ScrollView` keeps a long row reachable without
18 // guessing at line breaks.
19 ScrollView(.horizontal, showsIndicators: false) {
20 HStack(spacing: 6) {
21 ForEach(badges) { badge in
22 link(badge) {
23 AsyncImage(url: badge.source, scale: Self.scale) { phase in
24 switch phase {
25 case .success(let image):
26 image
27 case .failure:
28 Text(badge.alt).font(.footnote).foregroundStyle(.secondary)
29 default:
30 ProgressView().controlSize(.mini)
31 }
32 }
33 .accessibilityLabel(badge.alt)
34 }
35 }
36 }
37 }
38 }
39
40 /// A badge with no resolvable link is still shown, just not tappable.
41 @ViewBuilder
42 private func link(_ badge: OrgBadge, @ViewBuilder content: () -> some View) -> some View {
43 if let url = badge.link {
44 Link(destination: url, label: content)
45 } else {
46 content()
47 }
48 }
49}
Sources/OrgSwiftUI/OrgBlockView.swift +3
@@ -93,6 +93,9 @@ struct OrgBlockView: View {
93 } 93 }
94 } 94 }
95 95
96 case .badges(let badges):
97 OrgBadgeRowView(badges: badges)
98
96 case .captioned(let caption, let blocks): 99 case .captioned(let caption, let blocks):
97 VStack(alignment: .leading, spacing: 4) { 100 VStack(alignment: .leading, spacing: 4) {
98 OrgBlockListView(blocks: blocks) 101 OrgBlockListView(blocks: blocks)
Sources/OrgSwiftUI/OrgLayout.swift +9
@@ -32,6 +32,7 @@ indirect enum OrgBlock: Identifiable {
32 case list(OrgListLayout) 32 case list(OrgListLayout)
33 case table(OrgTableLayout) 33 case table(OrgTableLayout)
34 case image(source: URL, alt: String, caption: AttributedString?) 34 case image(source: URL, alt: String, caption: AttributedString?)
35 case badges([OrgBadge])
35 case captioned(caption: AttributedString?, blocks: [OrgBlock]) 36 case captioned(caption: AttributedString?, blocks: [OrgBlock])
36 case rule 37 case rule
37 38
@@ -40,6 +41,14 @@ indirect enum OrgBlock: Identifiable {
40 var id: String { String(describing: self).prefix(64).description } 41 var id: String { String(describing: self).prefix(64).description }
41} 42}
42 43
44/// One image in a badge row, with the link it stands for when it has one.
45struct OrgBadge: Identifiable {
46 var source: URL
47 var alt: String
48 var link: URL?
49 var id: URL { source }
50}
51
43struct OrgListLayout { 52struct OrgListLayout {
44 var kind: OrgListKind 53 var kind: OrgListKind
45 var items: [Item] 54 var items: [Item]
Sources/OrgSwiftUI/OrgLayoutBuilder.swift +39
@@ -62,6 +62,7 @@ struct OrgLayoutBuilder {
62 return [.heading(text: title, level: level)] 62 return [.heading(text: title, level: level)]
63 63
64 case .paragraph(let objects): 64 case .paragraph(let objects):
65 if let badges = badgeRow(objects) { return [.badges(badges)] }
65 return [.paragraph(text(objects, &notes))] 66 return [.paragraph(text(objects, &notes))]
66 67
67 case .list(let list): 68 case .list(let list):
@@ -110,6 +111,44 @@ struct OrgLayoutBuilder {
110 } 111 }
111 } 112 }
112 113
114 /// A paragraph holding nothing but images and image links is the badge row a README
115 /// opens with. It renders as a row of images at their own size rather than as prose —
116 /// the inline styler can only show an image's alt text, and a figure block would blow a
117 /// 95×20 badge up to the column width.
118 ///
119 /// Anything else in the paragraph, a word of prose included, means it is prose.
120 private func badgeRow(_ objects: [OrgObject]) -> [OrgBadge]? {
121 var badges: [OrgBadge] = []
122 for object in objects {
123 switch object {
124 case .text(let string) where string.trimmingCharacters(in: .whitespaces).isEmpty:
125 continue
126 case .image(let figure):
127 guard let badge = badge(figure, link: nil) else { return nil }
128 badges.append(badge)
129 case .link(let link):
130 guard link.description?.count == 1,
131 case .image(let figure) = link.description?.first
132 else { return nil }
133 // A badge whose link will not resolve is still a badge; it just is not
134 // tappable.
135 let href = resolver.href(for: link.target).flatMap { URL(string: $0) }
136 guard let badge = badge(figure, link: href) else { return nil }
137 badges.append(badge)
138 default:
139 return nil
140 }
141 }
142 return badges.isEmpty ? nil : badges
143 }
144
145 private func badge(_ figure: OrgFigure, link: URL?) -> OrgBadge? {
146 guard let string = resolver.imageSource(figure.source),
147 let url = URL(string: string), url.scheme != nil
148 else { return nil }
149 return OrgBadge(source: url, alt: figure.alt ?? "", link: link)
150 }
151
113 /// An image whose source will not resolve to a safe absolute URL degrades to its alt 152 /// An image whose source will not resolve to a safe absolute URL degrades to its alt
114 /// text, the same trade the HTML renderer makes rather than pointing at something unsafe. 153 /// text, the same trade the HTML renderer makes rather than pointing at something unsafe.
115 /// 154 ///
Tests/OrgSwiftUITests/OrgLayoutTests.swift +71
@@ -240,3 +240,74 @@ struct OrgLayoutTests {
240 #expect(fonts.count >= 3) 240 #expect(fonts.count >= 3)
241 } 241 }
242} 242}
243
244/// The badge row: a paragraph of nothing but images and image links.
245@MainActor
246struct OrgBadgeRowTests {
247
248 private func blocks(_ source: String) -> [OrgBlock] {
249 OrgLayoutBuilder(options: .init(), styler: PlainOrgCodeStyler(), keywords: .init())
250 .layout(OrgParser.parse(source))
251 .blocks
252 }
253
254 private func badges(_ source: String) -> [OrgBadge]? {
255 guard case .badges(let badges) = blocks(source).first else { return nil }
256 return badges
257 }
258
259 @Test
260 func aLinkWrappingAnImageIsABadge() throws {
261 let row = try #require(badges(
262 "[[https://gitbay.org/krz/gitbay/builds][file:https://gitbay.org/krz/gitbay/badge/build.svg]]"
263 ))
264 #expect(row.count == 1)
265 #expect(row[0].source.absoluteString == "https://gitbay.org/krz/gitbay/badge/build.svg")
266 #expect(row[0].link?.absoluteString == "https://gitbay.org/krz/gitbay/builds")
267 }
268
269 @Test
270 func severalBadgesOnOneLineAreOneRow() throws {
271 let row = try #require(badges("""
272 [[https://example.com/a][file:https://example.com/a.png]] \
273 [[https://example.com/b][file:https://example.com/b.png]]
274 """))
275 #expect(row.count == 2)
276 }
277
278 @Test
279 func anUnlinkedImageIsStillABadgeWhenItSharesTheLine() throws {
280 let row = try #require(badges(
281 "[[https://example.com/a][file:https://example.com/a.png]] [[file:https://example.com/b.png]]"
282 ))
283 #expect(row.count == 2)
284 #expect(row[1].link == nil)
285 }
286
287 @Test
288 func aParagraphWithProseIsNotABadgeRow() {
289 #expect(badges(
290 "Build: [[https://example.com/a][file:https://example.com/a.png]]"
291 ) == nil)
292 }
293
294 @Test
295 func aLinkWithATextDescriptionIsNotABadgeRow() {
296 #expect(badges("[[https://example.com][the builds]]") == nil)
297 }
298
299 /// A lone image on its own line is already a figure, and stays one: a figure carries a
300 /// caption and fills the column, which is right for a diagram and wrong for a badge.
301 @Test
302 func aStandaloneImageStaysAFigure() {
303 if case .image = blocks("[[file:https://example.com/diagram.png]]").first { return }
304 Issue.record("expected a figure block")
305 }
306
307 /// A relative source with no repository to resolve against cannot be made absolute, and
308 /// a badge has no alt-text fallback to degrade to, so the paragraph stays prose.
309 @Test
310 func anUnresolvableSourceIsNotABadgeRow() {
311 #expect(badges("[[https://example.com][file:badge.png]]") == nil)
312 }
313}