gitshark

Clone repository

git clone https://gitshark.de/git/workaround/Gitshark.git
git clone git@gitshark.de:workaround/Gitshark.git

← Commits

✨ (markdown): Render Markdown in blob view and issue descriptions

5fa9def4328f65a7546f53b1f989e8531cb5e8cc · Michael Hainz · 2026-07-09T12:57:04Z

Changes

9 files changed, +118 -26

MODIFY README.md +2 -2
diff --git a/README.md b/README.md
index f39d3ce..a631a9a 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/src/main/java/de/workaround/web/IssueResource.java b/src/main/java/de/workaround/web/IssueResource.java
index 49febaa..9b4eaee 100644
--- a/src/main/java/de/workaround/web/IssueResource.java
+++ b/src/main/java/de/workaround/web/IssueResource.java
@@ -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
diff --git a/src/main/java/de/workaround/web/Markdown.java b/src/main/java/de/workaround/web/Markdown.java
new file mode 100644
index 0000000..00347b8
--- /dev/null
+++ b/src/main/java/de/workaround/web/Markdown.java
@@ -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
diff --git a/src/main/java/de/workaround/web/RepositoryResource.java b/src/main/java/de/workaround/web/RepositoryResource.java
index 96cac13..e160788 100644
--- a/src/main/java/de/workaround/web/RepositoryResource.java
+++ b/src/main/java/de/workaround/web/RepositoryResource.java
@@ -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
diff --git a/src/main/resources/META-INF/resources/shark.css b/src/main/resources/META-INF/resources/shark.css
index 7b8331c..24c0ff2 100644
--- a/src/main/resources/META-INF/resources/shark.css
+++ b/src/main/resources/META-INF/resources/shark.css
@@ -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
diff --git a/src/main/resources/templates/IssueResource/issue.html b/src/main/resources/templates/IssueResource/issue.html
index 6f9b10d..aa45f19 100644
--- a/src/main/resources/templates/IssueResource/issue.html
+++ b/src/main/resources/templates/IssueResource/issue.html
@@ -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
diff --git a/src/main/resources/templates/RepositoryResource/blob.html b/src/main/resources/templates/RepositoryResource/blob.html
index ee215be..19cf7f6 100644
--- a/src/main/resources/templates/RepositoryResource/blob.html
+++ b/src/main/resources/templates/RepositoryResource/blob.html
@@ -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
diff --git a/src/test/java/de/workaround/web/IssueUiTest.java b/src/test/java/de/workaround/web/IssueUiTest.java
index a69a793..c13d35a 100644
--- a/src/test/java/de/workaround/web/IssueUiTest.java
+++ b/src/test/java/de/workaround/web/IssueUiTest.java
@@ -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
diff --git a/src/test/java/de/workaround/web/WebUiTest.java b/src/test/java/de/workaround/web/WebUiTest.java
index f7aa67e..81e9811 100644
--- a/src/test/java/de/workaround/web/WebUiTest.java
+++ b/src/test/java/de/workaround/web/WebUiTest.java
@@ -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());

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog