✨ (web): Render repository README on the overview page
Changes
6 files changed, +221 -3
MODIFY
README.md
+1 -1
@@ -14,7 +14,7 @@
14
14
- push and private read authenticate with **personal access tokens** (HTTP Basic password)
15
15
- Clone/fetch/push over `ssh://git@<host>:2222/<owner>/<repo>.git`
16
16
- public-key authentication only; keys managed per user in the UI
17
-- Web UI: landing page with login CTA for visitors (`/`), repository list for authenticated users (`/`), public repository browse at `/explore`, file/tree browser with self-hosted syntax highlighting (extension-based language detection, falls back to plain text for unknown extensions and binary files), commit log (paginated), branches & tags, one-time handle selection (`/onboarding`), profile settings (`/settings/profile`). Repository pages share Files / Commits / Branches / Issues / Merge requests tab navigation that preserves the selected ref, and the clone panel has copy-to-clipboard buttons for the HTTP and SSH `git clone` commands. Keyboard shortcuts are an optional, progressive enhancement (`?` opens a help overlay, `Escape` closes it, `g h` goes home) — every page works fully without JavaScript
17
+- Web UI: landing page with login CTA for visitors (`/`), repository list for authenticated users (`/`), public repository browse at `/explore`, file/tree browser with self-hosted syntax highlighting (extension-based language detection, falls back to plain text for unknown extensions and binary files), a rendered README (commonmark-java, XSS-safe) shown below the file list on the repository overview page, commit log (paginated), branches & tags, one-time handle selection (`/onboarding`), profile settings (`/settings/profile`). Repository pages share Files / Commits / Branches / Issues / Merge requests tab navigation that preserves the selected ref, and the clone panel has copy-to-clipboard buttons for the HTTP and SSH `git clone` commands. Keyboard shortcuts are an optional, progressive enhancement (`?` opens a help overlay, `Escape` closes it, `g h` goes home) — every page works fully without JavaScript
18
18
- Per-repository issues: title, optional description, per-repo sequential number (`#1`, `#2`, …), and author; created and managed by the repo owner, readable by anyone who can read the repo, via a dedicated "New issue" page
19
19
- Issues move through a fixed lifecycle (Planned → In development → Done); the repo navigation shows the open (Planned + In development) issue count, and Done issues collapse into an "Archive" section on the issues page
20
20
- Issues auto-close from pushed commit messages, GitHub-style (`close(s|d)`/`fix(es|ed)`/`resolve(s|d)` + `#<number>`, e.g. `fixes #12`), over both HTTP and SSH pushes
MODIFY
pom.xml
+6 -0
@@ -18,6 +18,7 @@
18
18
<jgit.version>7.3.0.202506031305-r</jgit.version>
19
19
<sshd.version>2.18.0</sshd.version>
20
20
<bouncycastle.version>1.84</bouncycastle.version>
21
+ <commonmark.version>0.24.0</commonmark.version>
21
22
<skipITs>true</skipITs>
22
23
<surefire-plugin.version>3.5.6</surefire-plugin.version>
23
24
</properties>
@@ -78,6 +79,11 @@
78
79
<version>${jgit.version}</version>
79
80
</dependency>
80
81
<dependency>
82
+ <groupId>org.commonmark</groupId>
83
+ <artifactId>commonmark</artifactId>
84
+ <version>${commonmark.version}</version>
85
+ </dependency>
86
+ <dependency>
81
87
<groupId>org.apache.sshd</groupId>
82
88
<artifactId>sshd-core</artifactId>
83
89
<version>${sshd.version}</version>
MODIFY
src/main/java/de/workaround/web/RepositoryResource.java
+43 -2
@@ -37,6 +37,8 @@
37
37
import jakarta.ws.rs.core.MediaType;
38
38
import jakarta.ws.rs.core.Response;
39
39
import jakarta.ws.rs.core.UriInfo;
40
+import org.commonmark.parser.Parser;
41
+import org.commonmark.renderer.html.HtmlRenderer;
40
42
import org.eclipse.microprofile.config.inject.ConfigProperty;
41
43
42
44
@jakarta.ws.rs.Path("/repos/{owner}/{name}")
@@ -49,7 +51,8 @@
49
51
static native TemplateInstance overview(Repository repo, boolean owner, boolean empty, String defaultBranch,
50
52
List<GitBrowseService.TreeEntry> entries, String httpUrl, String sshUrl, boolean loggedIn,
51
53
boolean pinned, GitBrowseService.CommitInfo latestCommit, String latestCommitAge, int commitCount,
52
- int branchCount, int tagCount, long openIssueCount, long openMrCount);
54
+ int branchCount, int tagCount, long openIssueCount, long openMrCount, String readmeName,
55
+ String readmeHtml);
53
56
54
57
static native TemplateInstance tree(Repository repo, String ref, String path,
55
58
List<GitBrowseService.TreeEntry> entries, List<Crumb> crumbs, String activeTab);
@@ -116,9 +119,47 @@
116
119
int tagCount = browse.tags(path).size();
117
120
long openIssueCount = issueService.countOpen(repo);
118
121
long openMrCount = mergeRequestService.countOpen(repo);
122
+ GitBrowseService.TreeEntry readmeEntry = findReadme(entries);
123
+ String readmeName = readmeEntry == null ? null : readmeEntry.name();
124
+ String readmeHtml = readmeEntry == null ? null : browse.blob(path, defaultBranch, readmeEntry.path())
125
+ .filter(blob -> !blob.binary())
126
+ .map(blob -> renderMarkdown(new String(blob.content(), StandardCharsets.UTF_8)))
127
+ .orElse(null);
119
128
return Templates.overview(repo, isOwner, empty, defaultBranch, entries, httpUrl(repo), sshUrl(repo),
120
129
loggedIn, pinned, latestCommit, latestCommitAge, commitCount, branchCount, tagCount, openIssueCount,
121
- openMrCount);
130
+ openMrCount, readmeName, readmeHtml);
131
+ }
132
+
133
+ // README file names the overview looks for, in order of preference (matched case-insensitively).
134
+ private static final List<String> README_NAMES = List.of("readme.md", "readme.markdown", "readme", "readme.txt");
135
+
136
+ private static final Parser MARKDOWN_PARSER = Parser.builder().build();
137
+
138
+ // escapeHtml + sanitizeUrls: README content is untrusted user input rendered into our page,
139
+ // so raw HTML blocks are escaped and javascript:/data: link targets are stripped.
140
+ private static final HtmlRenderer MARKDOWN_RENDERER = HtmlRenderer.builder()
141
+ .escapeHtml(true)
142
+ .sanitizeUrls(true)
143
+ .build();
144
+
145
+ private static GitBrowseService.TreeEntry findReadme(List<GitBrowseService.TreeEntry> entries)
146
+ {
147
+ for (String candidate : README_NAMES)
148
+ {
149
+ for (GitBrowseService.TreeEntry entry : entries)
150
+ {
151
+ if (!entry.directory() && entry.name().toLowerCase(Locale.ROOT).equals(candidate))
152
+ {
153
+ return entry;
154
+ }
155
+ }
156
+ }
157
+ return null;
158
+ }
159
+
160
+ static String renderMarkdown(String markdown)
161
+ {
162
+ return MARKDOWN_RENDERER.render(MARKDOWN_PARSER.parse(markdown));
122
163
}
123
164
124
165
private static String relativeAge(Instant when)
MODIFY
src/main/resources/META-INF/resources/shark.css
+106 -0
@@ -1629,6 +1629,112 @@
1629
1629
color: var(--accent-deep);
1630
1630
}
1631
1631
1632
+/* rendered README on the repository overview */
1633
+
1634
+.readme {
1635
+ margin-top: var(--s5);
1636
+}
1637
+
1638
+.readme-head {
1639
+ display: flex;
1640
+ align-items: center;
1641
+ gap: 11px;
1642
+ padding: 12px 18px;
1643
+ border-bottom: 1px solid var(--border-soft);
1644
+ font: 500 14px/1 var(--mono);
1645
+ color: var(--ink);
1646
+}
1647
+
1648
+.readme-head .g {
1649
+ width: 18px;
1650
+ text-align: center;
1651
+ font-size: 15px;
1652
+ color: var(--faint);
1653
+ flex: none;
1654
+}
1655
+
1656
+.readme-body {
1657
+ padding: var(--s5) 18px;
1658
+ font-size: 14px;
1659
+ line-height: 1.65;
1660
+ overflow-wrap: break-word;
1661
+}
1662
+
1663
+.readme-body > :first-child {
1664
+ margin-top: 0;
1665
+}
1666
+
1667
+.readme-body > :last-child {
1668
+ margin-bottom: 0;
1669
+}
1670
+
1671
+.readme-body h1,
1672
+.readme-body h2,
1673
+.readme-body h3,
1674
+.readme-body h4 {
1675
+ margin: 1.2em 0 .5em;
1676
+ line-height: 1.3;
1677
+}
1678
+
1679
+.readme-body h1 {
1680
+ font-size: 22px;
1681
+ padding-bottom: .3em;
1682
+ border-bottom: 1px solid var(--border-soft);
1683
+}
1684
+
1685
+.readme-body h2 {
1686
+ font-size: 18px;
1687
+ padding-bottom: .3em;
1688
+ border-bottom: 1px solid var(--border-soft);
1689
+}
1690
+
1691
+.readme-body h3 {
1692
+ font-size: 15px;
1693
+}
1694
+
1695
+.readme-body p,
1696
+.readme-body ul,
1697
+.readme-body ol,
1698
+.readme-body blockquote {
1699
+ margin: 0 0 .9em;
1700
+}
1701
+
1702
+.readme-body ul,
1703
+.readme-body ol {
1704
+ padding-left: 1.6em;
1705
+}
1706
+
1707
+.readme-body code {
1708
+ font: 500 12.5px/1.5 var(--mono);
1709
+ background: var(--accent-soft);
1710
+ padding: 2px 5px;
1711
+ border-radius: 4px;
1712
+}
1713
+
1714
+.readme-body pre {
1715
+ background: var(--canvas);
1716
+ border: 1px solid var(--border-soft);
1717
+ border-radius: var(--radius);
1718
+ padding: var(--s4);
1719
+ overflow-x: auto;
1720
+ margin: 0 0 .9em;
1721
+}
1722
+
1723
+.readme-body pre code {
1724
+ background: none;
1725
+ padding: 0;
1726
+}
1727
+
1728
+.readme-body blockquote {
1729
+ border-left: 3px solid var(--border-strong);
1730
+ padding-left: var(--s4);
1731
+ color: var(--muted);
1732
+}
1733
+
1734
+.readme-body img {
1735
+ max-width: 100%;
1736
+}
1737
+
1632
1738
.danger-zone {
1633
1739
margin-top: var(--s6);
1634
1740
}
MODIFY
src/main/resources/templates/RepositoryResource/overview.html
+7 -0
@@ -68,6 +68,13 @@
68
68
{/for}
69
69
</div>
70
70
</div>
71
+
72
+ {#if readmeHtml}
73
+ <div class="panel readme">
74
+ <div class="readme-head"><span class="g">≡</span> {readmeName}</div>
75
+ <div class="readme-body">{readmeHtml.raw}</div>
76
+ </div>
77
+ {/if}
71
78
{/if}
72
79
73
80
{#if owner}
MODIFY
src/test/java/de/workaround/web/WebUiTest.java
+58 -0
@@ -387,6 +387,64 @@
387
387
}
388
388
389
389
@Test
390
+ void repositoryOverviewRendersReadme() throws Exception
391
+ {
392
+ User owner = persistUser("ui-rita-" + unique());
393
+ Repository repo = service.create(owner, "withreadme", Repository.Visibility.PUBLIC, null);
394
+ GitTestSeeder.seed(service.repositoryPath(repo), Map.of(
395
+ "README.md", "# Hello Shark\n\nSome *emphasis* here.\n".getBytes(StandardCharsets.UTF_8),
396
+ "a.txt", "a\n".getBytes(StandardCharsets.UTF_8)));
397
+
398
+ given().when().get("/repos/" + owner.username + "/withreadme")
399
+ .then().statusCode(200)
400
+ .body(containsString("readme-body"))
401
+ .body(containsString("README.md"))
402
+ .body(containsString("<h1>Hello Shark</h1>"))
403
+ .body(containsString("<em>emphasis</em>"));
404
+ }
405
+
406
+ @Test
407
+ void repositoryOverviewFindsReadmeCaseInsensitively() throws Exception
408
+ {
409
+ User owner = persistUser("ui-sven-" + unique());
410
+ Repository repo = service.create(owner, "lowerreadme", Repository.Visibility.PUBLIC, null);
411
+ GitTestSeeder.seed(service.repositoryPath(repo), Map.of(
412
+ "readme.md", "# lower heading\n".getBytes(StandardCharsets.UTF_8)));
413
+
414
+ given().when().get("/repos/" + owner.username + "/lowerreadme")
415
+ .then().statusCode(200)
416
+ .body(containsString("<h1>lower heading</h1>"));
417
+ }
418
+
419
+ @Test
420
+ void readmeEscapesRawHtml() throws Exception
421
+ {
422
+ User owner = persistUser("ui-tina-" + unique());
423
+ Repository repo = service.create(owner, "evilreadme", Repository.Visibility.PUBLIC, null);
424
+ GitTestSeeder.seed(service.repositoryPath(repo), Map.of(
425
+ "README.md", "# safe\n\n<script>alert('xss')</script>\n".getBytes(StandardCharsets.UTF_8)));
426
+
427
+ given().when().get("/repos/" + owner.username + "/evilreadme")
428
+ .then().statusCode(200)
429
+ .body(containsString("<h1>safe</h1>"))
430
+ .body(not(containsString("<script>alert")))
431
+ .body(containsString("<script>"));
432
+ }
433
+
434
+ @Test
435
+ void repositoryOverviewWithoutReadmeShowsNoReadmePanel() throws Exception
436
+ {
437
+ User owner = persistUser("ui-uwe-" + unique());
438
+ Repository repo = service.create(owner, "noreadme", Repository.Visibility.PUBLIC, null);
439
+ GitTestSeeder.seed(service.repositoryPath(repo),
440
+ Map.of("a.txt", "a\n".getBytes(StandardCharsets.UTF_8)));
441
+
442
+ given().when().get("/repos/" + owner.username + "/noreadme")
443
+ .then().statusCode(200)
444
+ .body(not(containsString("readme-body")));
445
+ }
446
+
447
+ @Test
390
448
void repositoryOverviewClonesViaDialogNotHeaderPill() throws Exception
391
449
{
392
450
User owner = persistUser("ui-jane-" + unique());