Skip to content

Commit 56429b4

Browse files
committed
feat(details): Implement expandable sections and improved Markdown preprocessing
This commit introduces expandable "About" and "What's New" sections in the repository details view to handle long READMEs and release notes. It also significantly enhances the Markdown preprocessing logic to better handle HTML-in-Markdown and GitHub-specific formatting. - **feat(details)**: Introduced `ExpandableMarkdownContent` component to provide "Read More" and "Show Less" functionality for long text blocks. - **feat(details)**: Added `isAboutExpanded` and `isWhatsNewExpanded` states to `DetailsState` along with corresponding toggle actions in `DetailsViewModel`. - **refactor(data)**: Extensively updated `preprocessMarkdown.kt` to handle various HTML tags (e.g., `<picture>`, `<video>`, `<a>`, headings, inline formatting) and convert them to standard Markdown. - **refactor(data)**: Improved URL resolution in Markdown to correctly handle relative paths and GitHub blob links. - **feat(ui)**: Integrated content size animations and gradient overlays for collapsed states in the details sections. - **i18n**: Added `read_more` and `show_less` string resources.
1 parent 02329e1 commit 56429b4

8 files changed

Lines changed: 421 additions & 62 deletions

File tree

core/presentation/src/commonMain/composeResources/values/strings.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,4 +417,8 @@
417417
<string name="auth_hint_try_again">Please try signing in again to get a new code.</string>
418418
<string name="auth_hint_check_connection">Please check your internet connection and try again.</string>
419419
<string name="auth_hint_denied">You denied the authorization request. Try again if this was unintentional.</string>
420+
421+
<!-- Read More / Show Less -->
422+
<string name="read_more">Read More</string>
423+
<string name="show_less">Show Less</string>
420424
</resources>

feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/utils/preprocessMarkdown.kt

Lines changed: 195 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ fun preprocessMarkdown(markdown: String, baseUrl: String): String {
44
val normalizedBaseUrl = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/"
55

66
var processed = markdown
7-
var imageCount = 0
8-
var svgSkipped = 0
97

108
fun normalizeGitHubUrl(url: String): String {
119
return if (url.contains("github.com") && url.contains("/blob/")) {
@@ -22,9 +20,47 @@ fun preprocessMarkdown(markdown: String, baseUrl: String): String {
2220
url.contains(".svg#", ignoreCase = true)
2321
}
2422

23+
fun resolveUrl(path: String): String {
24+
val isAbsolute = path.startsWith("http://") ||
25+
path.startsWith("https://") ||
26+
path.startsWith("data:")
27+
return if (isAbsolute) {
28+
normalizeGitHubUrl(path)
29+
} else {
30+
val cleaned = path.trim().trimStart('.', '/')
31+
"$normalizedBaseUrl$cleaned"
32+
}
33+
}
34+
35+
// 1. Unwrap <picture> elements → keep only the <img> fallback
36+
processed = processed.replace(
37+
Regex(
38+
"""<picture[^>]*>.*?(<img\s[^>]*?>).*?</picture>""",
39+
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
40+
)
41+
) { match ->
42+
match.groupValues[1]
43+
}
44+
// Also strip orphaned <source> tags (outside <picture>)
45+
processed = processed.replace(
46+
Regex("""<source\s[^>]*?/?>""", RegexOption.IGNORE_CASE),
47+
""
48+
)
49+
50+
// 2. Unwrap <a> tags that wrap <img> tags — keep the <img> for step 3
51+
processed = processed.replace(
52+
Regex(
53+
"""<a\s[^>]*?>\s*(<img\s[^>]*?>)\s*</a>""",
54+
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
55+
)
56+
) { match ->
57+
match.groupValues[1]
58+
}
59+
60+
// 3. Convert <img> tags → markdown images
2561
processed = processed.replace(
2662
Regex(
27-
"""<img\s+([^>]*?)\s*\/?>""",
63+
"""<img\s+([^>]*?)\s*/?>""",
2864
RegexOption.IGNORE_CASE
2965
)
3066
) { imgMatch ->
@@ -37,55 +73,144 @@ fun preprocessMarkdown(markdown: String, baseUrl: String): String {
3773
val alt = altMatch?.groupValues?.get(2) ?: ""
3874

3975
if (src.isNotEmpty()) {
40-
val normalizedSrc = normalizeGitHubUrl(src)
76+
val normalizedSrc = resolveUrl(src)
4177

4278
if (isSvgUrl(normalizedSrc)) {
43-
svgSkipped++
44-
45-
if (alt.isNotEmpty()) {
46-
"**$alt**"
47-
} else {
48-
""
49-
}
79+
if (alt.isNotEmpty()) "**$alt**" else ""
5080
} else {
51-
imageCount++
5281
"![$alt]($normalizedSrc)"
5382
}
5483
} else {
5584
""
5685
}
5786
}
5887

88+
// 4. Normalize markdown image URLs (resolve relative, normalize GitHub blob)
5989
processed = processed.replace(
6090
Regex("""!\[([^\]]*)\]\(([^)]+)\)""")
6191
) { match ->
6292
val alt = match.groupValues[1]
6393
val originalPath = match.groupValues[2]
94+
val finalUrl = resolveUrl(originalPath)
6495

65-
val isAbsolute = originalPath.startsWith("http://") ||
66-
originalPath.startsWith("https://") ||
67-
originalPath.startsWith("data:")
68-
69-
val finalUrl = if (isAbsolute) {
70-
normalizeGitHubUrl(originalPath)
96+
if (isSvgUrl(finalUrl)) {
97+
if (alt.isNotEmpty()) "**$alt**" else ""
7198
} else {
72-
val path = originalPath.trim().trimStart('.', '/')
73-
"$normalizedBaseUrl$path"
99+
"![$alt]($finalUrl)"
100+
}
101+
}
102+
103+
// 5. Handle <video> tags → markdown link or remove
104+
processed = processed.replace(
105+
Regex(
106+
"""<video[^>]*?\ssrc=(["'])([^"']+)\1[^>]*>.*?</video>""",
107+
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
108+
)
109+
) { match ->
110+
val src = match.groupValues[2]
111+
"[Video]($src)"
112+
}
113+
// Video with <source> inside
114+
processed = processed.replace(
115+
Regex(
116+
"""<video[^>]*>.*?<source\s[^>]*?\ssrc=(["'])([^"']+)\1[^>]*?>.*?</video>""",
117+
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
118+
)
119+
) { match ->
120+
val src = match.groupValues[2]
121+
"[Video]($src)"
122+
}
123+
124+
// 6. Convert HTML headings <h1>–<h6> → markdown headings
125+
for (level in 1..6) {
126+
val hashes = "#".repeat(level)
127+
processed = processed.replace(
128+
Regex(
129+
"""<h$level[^>]*>(.*?)</h$level>""",
130+
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
131+
)
132+
) { match ->
133+
val content = match.groupValues[1].trim()
134+
"\n$hashes $content\n"
74135
}
136+
}
75137

76-
if (isSvgUrl(finalUrl)) {
77-
svgSkipped++
78-
if (alt.isNotEmpty()) {
79-
"**$alt**"
80-
} else {
81-
""
82-
}
138+
// 7. Convert <br> and <hr> tags
139+
processed = processed.replace(
140+
Regex("""<br\s*/?>""", RegexOption.IGNORE_CASE),
141+
"\n"
142+
)
143+
processed = processed.replace(
144+
Regex("""<hr\s*/?>""", RegexOption.IGNORE_CASE),
145+
"\n---\n"
146+
)
147+
148+
// 8. Convert inline formatting tags
149+
// <b> / <strong> → **text**
150+
processed = processed.replace(
151+
Regex(
152+
"""<(b|strong)>(.*?)</\1>""",
153+
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
154+
)
155+
) { match ->
156+
"**${match.groupValues[2]}**"
157+
}
158+
// <i> / <em> → *text*
159+
processed = processed.replace(
160+
Regex(
161+
"""<(i|em)>(.*?)</\1>""",
162+
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
163+
)
164+
) { match ->
165+
"*${match.groupValues[2]}*"
166+
}
167+
// <code> → `text` (single-line only, not <pre><code>)
168+
processed = processed.replace(
169+
Regex(
170+
"""<code>([^<]*?)</code>""",
171+
RegexOption.IGNORE_CASE
172+
)
173+
) { match ->
174+
"`${match.groupValues[1]}`"
175+
}
176+
// <s> / <del> / <strike> → ~~text~~
177+
processed = processed.replace(
178+
Regex(
179+
"""<(s|del|strike)>(.*?)</\1>""",
180+
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
181+
)
182+
) { match ->
183+
"~~${match.groupValues[2]}~~"
184+
}
185+
186+
// 9. Convert <a href="url">text</a> → [text](url) (non-image links)
187+
processed = processed.replace(
188+
Regex(
189+
"""<a\s+[^>]*?href=(["'])([^"']+)\1[^>]*>(.*?)</a>""",
190+
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
191+
)
192+
) { match ->
193+
val url = match.groupValues[2]
194+
val text = match.groupValues[3].trim()
195+
if (text.isEmpty()) {
196+
"[$url]($url)"
83197
} else {
84-
imageCount++
85-
"![$alt]($finalUrl)"
198+
"[$text]($url)"
86199
}
87200
}
88201

202+
// 10. <kbd> → `text`
203+
processed = processed.replace(
204+
Regex(
205+
"""<kbd>(.*?)</kbd>""",
206+
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
207+
)
208+
) { match ->
209+
"`${match.groupValues[1]}`"
210+
}
211+
212+
// 11. Strip remaining wrapper tags (keep content)
213+
// <div align="center"> and </div>
89214
processed = processed.replace(
90215
Regex("""<div[^>]*?align=["']center["'][^>]*?>\s*""", RegexOption.IGNORE_CASE),
91216
"\n\n"
@@ -94,21 +219,60 @@ fun preprocessMarkdown(markdown: String, baseUrl: String): String {
94219
Regex("""</div>\s*""", RegexOption.IGNORE_CASE),
95220
"\n\n"
96221
)
222+
// <p> / </p>
223+
processed = processed.replace(
224+
Regex("""<p[^>]*?>""", RegexOption.IGNORE_CASE),
225+
"\n"
226+
)
227+
processed = processed.replace(
228+
Regex("""</p>""", RegexOption.IGNORE_CASE),
229+
"\n"
230+
)
231+
// <details> / <summary>
232+
processed = processed.replace(
233+
Regex("""<details[^>]*?>""", RegexOption.IGNORE_CASE),
234+
"\n"
235+
)
236+
processed = processed.replace(
237+
Regex("""</details>""", RegexOption.IGNORE_CASE),
238+
"\n"
239+
)
240+
processed = processed.replace(
241+
Regex("""<summary[^>]*?>(.*?)</summary>""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
242+
) { match ->
243+
"**${match.groupValues[1].trim()}**\n"
244+
}
245+
// <span>, <sup>, <sub> — strip tags, keep content
246+
processed = processed.replace(
247+
Regex("""</?(?:span|sup|sub)[^>]*?>""", RegexOption.IGNORE_CASE),
248+
""
249+
)
97250

251+
// 12. Decode common HTML entities
252+
processed = processed
253+
.replace("&amp;", "&")
254+
.replace("&lt;", "<")
255+
.replace("&gt;", ">")
256+
.replace("&quot;", "\"")
257+
.replace("&#39;", "'")
258+
.replace("&apos;", "'")
259+
.replace("&nbsp;", " ")
260+
261+
// 13. Clean up empty <p> tags and excess newlines
98262
processed = processed.replace(
99263
Regex("""<p[^>]*?>\s*</p>""", RegexOption.IGNORE_CASE),
100264
""
101265
)
102-
103266
processed = processed.replace(
104267
Regex("""\n{3,}"""),
105268
"\n\n"
106269
)
107270

271+
// 14. Clean up orphaned markdown link fragments
108272
processed = processed.replace(
109273
Regex("""^\]\([^)]+\)""", RegexOption.MULTILINE),
110274
""
111275
)
112276

113277
return processed
114-
}
278+
}

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,6 @@ sealed interface DetailsAction {
3838
data class SelectReleaseCategory(val category: ReleaseCategory) : DetailsAction
3939
data class SelectRelease(val release: GithubRelease) : DetailsAction
4040
data object ToggleVersionPicker : DetailsAction
41+
data object ToggleAboutExpanded : DetailsAction
42+
data object ToggleWhatsNewExpanded : DetailsAction
4143
}

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,12 +240,18 @@ fun DetailsScreen(
240240
state.readmeMarkdown?.let {
241241
about(
242242
readmeMarkdown = state.readmeMarkdown,
243-
readmeLanguage = state.readmeLanguage
243+
readmeLanguage = state.readmeLanguage,
244+
isExpanded = state.isAboutExpanded,
245+
onToggleExpanded = { onAction(DetailsAction.ToggleAboutExpanded) }
244246
)
245247
}
246248

247249
state.selectedRelease?.let { release ->
248-
whatsNew(release)
250+
whatsNew(
251+
release = release,
252+
isExpanded = state.isWhatsNewExpanded,
253+
onToggleExpanded = { onAction(DetailsAction.ToggleWhatsNewExpanded) }
254+
)
249255
}
250256

251257
state.userProfile?.let { userProfile ->

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ data class DetailsState(
5454
val isFavourite: Boolean = false,
5555
val isStarred: Boolean = false,
5656
val isTrackingApp: Boolean = false,
57+
58+
val isAboutExpanded: Boolean = false,
59+
val isWhatsNewExpanded: Boolean = false,
5760
) {
5861
/**
5962
* True when the app is detected as installed on the system (via assets matching)

feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,18 @@ class DetailsViewModel(
776776
}
777777
}
778778

779+
DetailsAction.ToggleAboutExpanded -> {
780+
_state.update {
781+
it.copy(isAboutExpanded = !it.isAboutExpanded)
782+
}
783+
}
784+
785+
DetailsAction.ToggleWhatsNewExpanded -> {
786+
_state.update {
787+
it.copy(isWhatsNewExpanded = !it.isWhatsNewExpanded)
788+
}
789+
}
790+
779791
DetailsAction.TrackExistingApp -> {
780792
val snapshot = _state.value
781793
if (snapshot.isTrackingApp || !snapshot.isTrackable) return

0 commit comments

Comments
 (0)