✨ (markdown): Render Markdown in blob view and issue descriptions
Changes
9 files changed, +118 -26
MODIFY
README.md
+2 -2
@@ -14,8 +14,8 @@
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: an auth-aware header nav (a "Log in" button for visitors; for signed-in users a top-level Following link plus an Account dropdown holding Profile, SSH keys, Access tokens, and Logout — a JS-free `<details>` menu), 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 (own dedicated page, separate from branches), one-time handle selection (`/onboarding`), profile settings (`/settings/profile`). Every repository sub-page shows a persistent left sidebar with repo identity, a Clone button opening the clone dialog, a pin toggle, and section navigation (Code, Commits, Branches, Tags, Issues, Merge requests, plus Collaborators for the owner) with per-section counts and active-section highlighting, 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
-- Per-repository issues: title, optional description, per-repo sequential number (`#1`, `#2`, …), and author; created and managed by the repo owner and collaborators, readable by anyone who can read the repo, via a dedicated "New issue" page
17
+- Web UI: an auth-aware header nav (a "Log in" button for visitors; for signed-in users a top-level Following link plus an Account dropdown holding Profile, SSH keys, Access tokens, and Logout — a JS-free `<details>` menu), 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; Markdown files render to HTML by default with a Rendered/Code toggle), a rendered README (commonmark-java, XSS-safe) shown below the file list on the repository overview page, commit log (paginated), branches, tags (own dedicated page, separate from branches), one-time handle selection (`/onboarding`), profile settings (`/settings/profile`). Every repository sub-page shows a persistent left sidebar with repo identity, a Clone button opening the clone dialog, a pin toggle, and section navigation (Code, Commits, Branches, Tags, Issues, Merge requests, plus Collaborators for the owner) with per-section counts and active-section highlighting, 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
+- Per-repository issues: title, optional description (rendered as Markdown, XSS-safe), per-repo sequential number (`#1`, `#2`, …), and author; created and managed by the repo owner and collaborators, 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
21
21
- Per-repository merge requests: source → target branch within one repo, with a title, optional description, a per-repo sequential number displayed bang-prefixed (`!1`, `!2`, …, distinct from issues' `#`), and an author; created and managed by the repo owner and collaborators, readable by anyone who can read the repo, via a dedicated "New merge request" page where the author picks source and target from the repo's branches
MODIFY
src/main/java/de/workaround/web/IssueResource.java
+4 -2
@@ -40,7 +40,7 @@
40
40
static native TemplateInstance newIssue(Repository repo, RepoNav nav);
41
41
42
42
static native TemplateInstance issue(Repository repo, RepoNav nav, boolean owner, Issue issue,
43
- List<Issue.Status> statuses);
43
+ String descriptionHtml, List<Issue.Status> statuses);
44
44
}
45
45
46
46
@Inject
@@ -107,7 +107,9 @@
107
107
Issue issue = issueService.find(repo, parseId(id)).orElseThrow(NotFoundException::new);
108
108
User user = currentUser.get();
109
109
boolean isOwner = user != null && user.id.equals(repo.owner.id);
110
- return Templates.issue(repo, repoNav.build(repo, uriInfo), isOwner, issue, List.of(Issue.Status.values()));
110
+ String descriptionHtml = issue.description == null ? null : Markdown.render(issue.description);
111
+ return Templates.issue(repo, repoNav.build(repo, uriInfo), isOwner, issue, descriptionHtml,
112
+ List.of(Issue.Status.values()));
111
113
}
112
114
113
115
@POST
ADD
src/main/java/de/workaround/web/Markdown.java
+27 -0
@@ -0,0 +1,27 @@
1
+package de.workaround.web;
2
+
3
+import org.commonmark.parser.Parser;
4
+import org.commonmark.renderer.html.HtmlRenderer;
5
+
6
+/** Shared Markdown-to-HTML rendering for server-rendered pages (README, markdown blobs, issue descriptions). */
7
+final class Markdown
8
+{
9
+ private static final Parser PARSER = Parser.builder().build();
10
+
11
+ // escapeHtml + sanitizeUrls: markdown content is untrusted user input rendered into our page,
12
+ // so raw HTML blocks are escaped and javascript:/data: link targets are stripped.
13
+ private static final HtmlRenderer RENDERER = HtmlRenderer.builder()
14
+ .escapeHtml(true)
15
+ .sanitizeUrls(true)
16
+ .build();
17
+
18
+ private Markdown()
19
+ {
20
+ }
21
+
22
+ static String render(String markdown)
23
+ {
24
+ return RENDERER.render(PARSER.parse(markdown));
25
+ }
26
+
27
+}
MODIFY
src/main/java/de/workaround/web/RepositoryResource.java
+5 -19
@@ -43,8 +43,6 @@
43
43
import jakarta.ws.rs.core.MediaType;
44
44
import jakarta.ws.rs.core.Response;
45
45
import jakarta.ws.rs.core.UriInfo;
46
-import org.commonmark.parser.Parser;
47
-import org.commonmark.renderer.html.HtmlRenderer;
48
46
49
47
@jakarta.ws.rs.Path("/repos/{owner}/{name}")
50
48
@Produces(MediaType.TEXT_HTML)
@@ -61,7 +59,7 @@
61
59
List<GitBrowseService.TreeEntry> entries, List<Crumb> crumbs);
62
60
63
61
static native TemplateInstance blob(Repository repo, RepoNav nav, String ref, String path, boolean binary,
64
- String content, String language, List<Crumb> crumbs);
62
+ String content, String language, String markdownHtml, List<Crumb> crumbs);
65
63
66
64
static native TemplateInstance commits(Repository repo, RepoNav nav, String ref,
67
65
List<GitBrowseService.CommitInfo> commits, int page, int prevPage, int nextPage, int size,
@@ -123,7 +121,7 @@
123
121
String readmeName = readmeEntry == null ? null : readmeEntry.name();
124
122
String readmeHtml = readmeEntry == null ? null : browse.blob(path, nav.defaultBranch(), readmeEntry.path())
125
123
.filter(blob -> !blob.binary())
126
- .map(blob -> renderMarkdown(new String(blob.content(), StandardCharsets.UTF_8)))
124
+ .map(blob -> Markdown.render(new String(blob.content(), StandardCharsets.UTF_8)))
127
125
.orElse(null);
128
126
List<de.workaround.model.PushMirror> mirrors = isOwner ? mirrorService.list(repo) : List.of();
129
127
return Templates.overview(repo, nav, isOwner, entries, latestCommit, latestCommitAge, readmeName, readmeHtml,
@@ -133,15 +131,6 @@
133
131
// README file names the overview looks for, in order of preference (matched case-insensitively).
134
132
private static final List<String> README_NAMES = List.of("readme.md", "readme.markdown", "readme", "readme.txt");
135
133
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
134
private static GitBrowseService.TreeEntry findReadme(List<GitBrowseService.TreeEntry> entries)
146
135
{
147
136
for (String candidate : README_NAMES)
@@ -157,11 +146,6 @@
157
146
return null;
158
147
}
159
148
160
- static String renderMarkdown(String markdown)
161
- {
162
- return MARKDOWN_RENDERER.render(MARKDOWN_PARSER.parse(markdown));
163
- }
164
-
165
149
private static String relativeAge(Instant when)
166
150
{
167
151
Duration elapsed = Duration.between(when, Instant.now());
@@ -391,7 +375,9 @@
391
375
GitBrowseService.BlobView blob = browse.blob(repoPath, ref, path).orElseThrow(NotFoundException::new);
392
376
String content = blob.binary() ? null : new String(blob.content(), StandardCharsets.UTF_8);
393
377
String language = blob.binary() ? null : highlightLanguage(path);
394
- return Templates.blob(repo, nav, ref, path, blob.binary(), content, language, breadcrumbs(repo, ref, path));
378
+ String markdownHtml = "markdown".equals(language) ? Markdown.render(content) : null;
379
+ return Templates.blob(repo, nav, ref, path, blob.binary(), content, language, markdownHtml,
380
+ breadcrumbs(repo, ref, path));
395
381
}
396
382
397
383
// Extension → highlight.js language id. Every value here MUST have a grammar in the bundled highlight assets
MODIFY
src/main/resources/META-INF/resources/shark.css
+16 -1
@@ -839,10 +839,25 @@
839
839
840
840
.issue-desc {
841
841
margin: var(--s3) 0 var(--s5);
842
- white-space: pre-wrap;
843
842
word-break: break-word;
844
843
}
845
844
845
+/* rendered markdown reuses .readme-body typography; undo its card padding outside the README card */
846
+.issue-desc.readme-body {
847
+ padding: 0;
848
+}
849
+
850
+.md-toggle {
851
+ display: flex;
852
+ gap: var(--s2);
853
+ margin-bottom: var(--s3);
854
+}
855
+
856
+.md-toggle .btn.active {
857
+ border-color: var(--border-strong);
858
+ font-weight: 600;
859
+}
860
+
846
861
.issue-no {
847
862
margin-left: auto;
848
863
color: var(--faint);
MODIFY
src/main/resources/templates/IssueResource/issue.html
+2 -2
@@ -9,8 +9,8 @@
9
9
<span class="badge status-{issue.status}">{issue.status.label}</span>
10
10
<span class="muted">opened by {#avatar user=issue.author /} {issue.author.username}</span>
11
11
</p>
12
- {#if issue.description}
13
- <pre class="issue-desc">{issue.description}</pre>
12
+ {#if descriptionHtml}
13
+ <div class="issue-desc readme-body">{descriptionHtml.raw}</div>
14
14
{#else}
15
15
<p class="muted">No description provided.</p>
16
16
{/if}
MODIFY
src/main/resources/templates/RepositoryResource/blob.html
+21 -0
@@ -11,6 +11,13 @@
11
11
</nav>
12
12
{#if binary}
13
13
<p>Binary file. <a class="btn btn-secondary" href="/repos/{repo.owner.username}/{repo.name}/raw/{ref}/{path}">Download</a></p>
14
+ {#else if markdownHtml}
15
+ <div class="md-toggle" role="group" aria-label="View mode">
16
+ <button type="button" class="btn btn-secondary btn-sm active" data-md-view="rendered">Rendered</button>
17
+ <button type="button" class="btn btn-secondary btn-sm" data-md-view="code">Code</button>
18
+ </div>
19
+ <div class="readme-body" data-md-pane="rendered">{markdownHtml.raw}</div>
20
+ <pre hidden data-md-pane="code"><code class="language-{language}">{content}</code></pre>
14
21
{#else if language}
15
22
<pre><code class="language-{language}">{content}</code></pre>
16
23
{#else}
@@ -24,5 +31,19 @@
24
31
<script src="/highlight-extra.min.js"></script>
25
32
<script>hljs.highlightAll();</script>
26
33
{/if}
34
+{#if markdownHtml}
35
+<script>
36
+document.querySelectorAll('[data-md-view]').forEach(function (button) {
37
+ button.addEventListener('click', function () {
38
+ document.querySelectorAll('[data-md-view]').forEach(function (b) {
39
+ b.classList.toggle('active', b === button);
40
+ });
41
+ document.querySelectorAll('[data-md-pane]').forEach(function (pane) {
42
+ pane.hidden = pane.dataset.mdPane !== button.dataset.mdView;
43
+ });
44
+ });
45
+});
46
+</script>
47
+{/if}
27
48
{/scripts}
28
49
{/include}
MODIFY
src/test/java/de/workaround/web/IssueUiTest.java
+16 -0
@@ -74,6 +74,22 @@
74
74
}
75
75
76
76
@Test
77
+ @TestSecurity(user = "iss-md")
78
+ void issueDescriptionRendersMarkdown()
79
+ {
80
+ User owner = persistUser("iss-md");
81
+ Repository repo = service.create(owner, "mdesc", Repository.Visibility.PUBLIC, null);
82
+ Issue issue = issueService.create(owner, repo, "Styled",
83
+ "Some **bold** text\n\n<script>alert('xss')</script>");
84
+
85
+ // markdown is rendered to HTML; embedded raw HTML stays escaped
86
+ given().when().get("/repos/" + owner.username + "/mdesc/issues/" + issue.id)
87
+ .then().statusCode(200)
88
+ .body(containsString("<strong>bold</strong>"))
89
+ .body(not(containsString("<script>alert('xss')</script>")));
90
+ }
91
+
92
+ @Test
77
93
@TestSecurity(user = "iss-owner4")
78
94
void createIssueIsADedicatedPageLinkedFromTheList()
79
95
{
MODIFY
src/test/java/de/workaround/web/WebUiTest.java
+25 -0
@@ -125,6 +125,31 @@
125
125
}
126
126
127
127
@Test
128
+ void markdownBlobRendersWithCodeToggle() throws Exception
129
+ {
130
+ User owner = persistUser("ui-md-" + unique());
131
+ Repository repo = service.create(owner, "mdview", Repository.Visibility.PUBLIC, null);
132
+ GitTestSeeder.seed(service.repositoryPath(repo), Map.of(
133
+ "GUIDE.md", "# Guide Heading\n\n<script>alert('xss')</script>\n".getBytes(StandardCharsets.UTF_8),
134
+ "notes.txt", "plain\n".getBytes(StandardCharsets.UTF_8)));
135
+
136
+ String base = "/repos/" + owner.username + "/mdview";
137
+
138
+ // markdown blobs render to HTML by default, with a toggle to the raw code view; raw HTML stays escaped
139
+ given().when().get(base + "/tree/main/GUIDE.md")
140
+ .then().statusCode(200)
141
+ .body(containsString("<h1>Guide Heading</h1>"))
142
+ .body(containsString("md-toggle"))
143
+ .body(containsString("language-markdown"))
144
+ .body(not(containsString("<script>alert('xss')</script>")));
145
+
146
+ // non-markdown blobs get no toggle
147
+ given().when().get(base + "/tree/main/notes.txt")
148
+ .then().statusCode(200)
149
+ .body(not(containsString("md-toggle")));
150
+ }
151
+
152
+ @Test
128
153
void blobViewHighlightsSourceCodeByLanguage() throws Exception
129
154
{
130
155
User owner = persistUser("ui-hank-" + unique());