Skip to content

Commit aad3db2

Browse files
committed
Add install section to commands
1 parent 6ed8b5c commit aad3db2

12 files changed

Lines changed: 500 additions & 11 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package com.linuxcommandlibrary.shared
2+
3+
import kotlinx.collections.immutable.ImmutableList
4+
import kotlinx.collections.immutable.persistentListOf
5+
import kotlinx.collections.immutable.toImmutableList
6+
7+
/**
8+
* One package-manager install line from a command man page's INSTALL section.
9+
*
10+
* Markdown source shape (inline code fence):
11+
* ```apt: sudo apt install 7zip```
12+
*/
13+
data class InstallEntry(
14+
/** Package manager id: apt, dnf, pacman, aur, apk, zypper, brew, nix. */
15+
val manager: String,
16+
/** Install command without the `manager:` prefix. */
17+
val command: String,
18+
/** Resolved package name when extractable. */
19+
val packageName: String?,
20+
/** Official web package-index URL for [packageName], or null. */
21+
val packageUrl: String?,
22+
)
23+
24+
/**
25+
* Parse INSTALL section code fences and build package-index URLs.
26+
* Shared by command detail (Compose/Swift) and the static website builder.
27+
*/
28+
object InstallEntries {
29+
30+
val KNOWN_MANAGERS: Set<String> = setOf(
31+
"apt",
32+
"dnf",
33+
"pacman",
34+
"aur",
35+
"apk",
36+
"zypper",
37+
"brew",
38+
"nix",
39+
)
40+
41+
private val LINE_REGEX = Regex("""^(\w+):\s*(.+)$""")
42+
43+
/**
44+
* Extract install rows from already-parsed markdown elements (INSTALL section body).
45+
*/
46+
fun parseFromElements(elements: ImmutableList<TipSectionElement>): ImmutableList<InstallEntry> {
47+
if (elements.isEmpty()) return persistentListOf()
48+
return elements
49+
.filterIsInstance<TipSectionElement.Code>()
50+
.mapNotNull { parseCodeLine(it.command) }
51+
.toImmutableList()
52+
}
53+
54+
/**
55+
* Parse a single fence body such as `apt: sudo apt install 7zip`.
56+
*/
57+
fun parseCodeLine(raw: String): InstallEntry? {
58+
val match = LINE_REGEX.matchEntire(raw.trim()) ?: return null
59+
val manager = match.groupValues[1].lowercase()
60+
if (manager !in KNOWN_MANAGERS) return null
61+
val command = match.groupValues[2].trim()
62+
if (command.isEmpty()) return null
63+
val packageName = extractPackageName(manager, command)
64+
return InstallEntry(
65+
manager = manager,
66+
command = command,
67+
packageName = packageName,
68+
packageUrl = packageName?.let { packageUrl(manager, it) },
69+
)
70+
}
71+
72+
/**
73+
* Last whitespace token is the package for all current INSTALL templates.
74+
* For nix (`nixpkgs#pkg` / `nixpkgs.python3Packages.foo`) take the attribute after `#`
75+
* when present; otherwise keep the last token as-is.
76+
*/
77+
fun extractPackageName(manager: String, command: String): String? {
78+
val last = command.trim().split(Regex("\\s+")).lastOrNull()?.takeIf { it.isNotEmpty() }
79+
?: return null
80+
return when {
81+
manager == "nix" && '#' in last -> last.substringAfterLast('#').ifEmpty { null }
82+
else -> last
83+
}
84+
}
85+
86+
fun packageUrl(manager: String, packageName: String): String? {
87+
val encoded = encodeQuery(packageName)
88+
return when (manager) {
89+
"apt" -> "https://packages.debian.org/search?keywords=$encoded"
90+
"dnf" -> "https://packages.fedoraproject.org/search?query=$encoded"
91+
"pacman" -> "https://archlinux.org/packages/?q=$encoded"
92+
"aur" -> "https://aur.archlinux.org/packages/${encodePath(packageName)}"
93+
"apk" -> "https://pkgs.alpinelinux.org/packages?name=$encoded"
94+
"zypper" -> "https://software.opensuse.org/package/${encodePath(packageName)}"
95+
"brew" -> "https://formulae.brew.sh/formula/${encodePath(packageName)}"
96+
"nix" -> "https://search.nixos.org/packages?query=$encoded"
97+
else -> null
98+
}
99+
}
100+
101+
/** Encode for query values; package names are almost always unreserved. */
102+
private fun encodeQuery(value: String): String = buildString(value.length) {
103+
for (c in value) {
104+
when {
105+
c.isLetterOrDigit() || c in "-._~" -> append(c)
106+
c == ' ' -> append('+')
107+
else -> append(percentEncode(c))
108+
}
109+
}
110+
}
111+
112+
/** Encode a single path segment (no slashes expected in package names). */
113+
private fun encodePath(value: String): String = buildString(value.length) {
114+
for (c in value) {
115+
when {
116+
c.isLetterOrDigit() || c in "-._~" -> append(c)
117+
else -> append(percentEncode(c))
118+
}
119+
}
120+
}
121+
122+
private fun percentEncode(c: Char): String {
123+
val bytes = c.toString().encodeToByteArray()
124+
return bytes.joinToString("") { b ->
125+
"%" + b.toUByte().toString(16).uppercase().padStart(2, '0')
126+
}
127+
}
128+
}

‎common/src/commonMain/kotlin/com/linuxcommandlibrary/shared/MarkdownModels.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ fun getSectionSortPriority(title: String): Int = when (title.uppercase()) {
5959
"OPTIONS" -> 5
6060
"EXAMPLES" -> 6
6161
"CONFIGURATION" -> 7
62+
"INSTALL" -> 89
6263
"CAVEATS" -> 90
6364
"HISTORY" -> 91
6465
"AUTHOR" -> 92

‎common/src/commonTest/kotlin/CommonTests.kt‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import com.linuxcommandlibrary.shared.CommandElement
2+
import com.linuxcommandlibrary.shared.InstallEntries
23
import com.linuxcommandlibrary.shared.MarkdownParser
34
import com.linuxcommandlibrary.shared.TipSectionElement
45
import com.linuxcommandlibrary.shared.getCommandList
6+
import com.linuxcommandlibrary.shared.getSectionSortPriority
57
import com.linuxcommandlibrary.shared.toHtmlFileName
68
import kotlin.test.Test
79
import kotlin.test.assertEquals
10+
import kotlin.test.assertNull
811
import kotlin.test.assertTrue
912

1013
class CommonTests {
@@ -73,4 +76,57 @@ class CommonTests {
7376
assertEquals(listOf("Source code", "Documentation"), urls.map { it.command })
7477
assertEquals("https://github.com/x/y", urls.first().url)
7578
}
79+
80+
@Test
81+
fun testInstallLineParsesManagerCommandAndPackageUrl() {
82+
val entry = InstallEntries.parseCodeLine("apt: sudo apt install 7zip")!!
83+
assertEquals("apt", entry.manager)
84+
assertEquals("sudo apt install 7zip", entry.command)
85+
assertEquals("7zip", entry.packageName)
86+
assertEquals(
87+
"https://packages.debian.org/search?keywords=7zip",
88+
entry.packageUrl,
89+
)
90+
}
91+
92+
@Test
93+
fun testInstallNixPackageNameAfterHash() {
94+
val entry = InstallEntries.parseCodeLine(
95+
"nix: nix profile install nixpkgs#ripgrep",
96+
)!!
97+
assertEquals("nix", entry.manager)
98+
assertEquals("ripgrep", entry.packageName)
99+
assertEquals(
100+
"https://search.nixos.org/packages?query=ripgrep",
101+
entry.packageUrl,
102+
)
103+
}
104+
105+
@Test
106+
fun testInstallIgnoresUnknownManagerAndGarbage() {
107+
assertNull(InstallEntries.parseCodeLine("foo: bar install baz"))
108+
assertNull(InstallEntries.parseCodeLine("just a plain command"))
109+
assertNull(InstallEntries.parseCodeLine("apt:"))
110+
}
111+
112+
@Test
113+
fun testInstallSectionParsesMultipleManagers() {
114+
val content = """
115+
```apt: sudo apt install 7zip```
116+
117+
```brew: brew install 7zip```
118+
119+
<!-- packages: 2026-07-22 -->
120+
""".trimIndent()
121+
val entries = InstallEntries.parseFromElements(MarkdownParser.parseMarkdownContent(content))
122+
assertEquals(listOf("apt", "brew"), entries.map { it.manager })
123+
assertEquals("https://formulae.brew.sh/formula/7zip", entries.last().packageUrl)
124+
}
125+
126+
@Test
127+
fun testInstallSortsBeforeSeeAlso() {
128+
assertTrue(getSectionSortPriority("INSTALL") < getSectionSortPriority("SEE ALSO"))
129+
assertTrue(getSectionSortPriority("INSTALL") < getSectionSortPriority("RESOURCES"))
130+
assertTrue(getSectionSortPriority("INSTALL") < getSectionSortPriority("HISTORY"))
131+
}
76132
}

‎composeApp/src/commonMain/kotlin/com/linuxcommandlibrary/app/ui/AppIcons.kt‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,45 @@ object AppIcons {
117117
}.build()
118118
}
119119

120+
val OpenInNew: ImageVector by lazy {
121+
ImageVector.Builder(
122+
name = "OpenInNew",
123+
defaultWidth = 24.dp,
124+
defaultHeight = 24.dp,
125+
viewportWidth = 24f,
126+
viewportHeight = 24f,
127+
).apply {
128+
path(fill = SolidColor(Color.Black)) {
129+
moveTo(19.0f, 19.0f)
130+
horizontalLineTo(5.0f)
131+
verticalLineTo(5.0f)
132+
horizontalLineToRelative(7.0f)
133+
verticalLineTo(3.0f)
134+
horizontalLineTo(5.0f)
135+
curveToRelative(-1.11f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f)
136+
verticalLineToRelative(14.0f)
137+
curveToRelative(0.0f, 1.1f, 0.89f, 2.0f, 2.0f, 2.0f)
138+
horizontalLineToRelative(14.0f)
139+
curveToRelative(1.1f, 0.0f, 2.0f, -0.9f, 2.0f, -2.0f)
140+
verticalLineToRelative(-7.0f)
141+
horizontalLineToRelative(-2.0f)
142+
verticalLineToRelative(7.0f)
143+
close()
144+
moveTo(14.0f, 3.0f)
145+
verticalLineToRelative(2.0f)
146+
horizontalLineToRelative(3.59f)
147+
lineToRelative(-9.83f, 9.83f)
148+
lineToRelative(1.41f, 1.41f)
149+
lineTo(19.0f, 6.41f)
150+
verticalLineTo(10.0f)
151+
horizontalLineToRelative(2.0f)
152+
verticalLineTo(3.0f)
153+
horizontalLineToRelative(-7.0f)
154+
close()
155+
}
156+
}.build()
157+
}
158+
120159
val ExpandMore: ImageVector by lazy {
121160
ImageVector.Builder(
122161
name = "ExpandMore",

0 commit comments

Comments
 (0)