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 @@ |
| |
1 | import 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. |
| |
8 | struct 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. |
| |
45 | struct OrgBadge: Identifiable { |
| |
46 | var source: URL |
| |
47 | var alt: String |
| |
48 | var link: URL? |
| |
49 | var id: URL { source } |
| |
50 | } |
| |
51 | |
| 43 | struct OrgListLayout { |
52 | struct 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, ¬es))] |
66 | return [.paragraph(text(objects, ¬es))] |
| 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 |
| |
246 | struct 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 | } |