Support some syntax in comment bodies

2de8c32ea2a1bce4ecc8a9c974fe97cd4c4a880f
Alexis Sellier committed ago 1 parent 6167f627
scripts/seed-forum.sh +43 -15
17 17
18 18
sqlite3 "$DB" <<'SQL'
19 19
-- Discussion 1: welcome / intro
20 20
INSERT INTO discussions (repo, title, body, author, created_at)
21 21
VALUES ('_site', 'Welcome to the Radiant forum',
22 -
'Hey everyone, this is the general discussion space for all things Radiant.
22 +
'Hey everyone, this is the general discussion space for *all things Radiant*.
23 23
24 -
Feel free to introduce yourself, ask questions, or share what you are working on.
24 +
Feel free to introduce yourself, ask questions, or share what you are working on. Check out the project at <https://radiant.dev> if you have not already.
25 25
26 26
A few ground rules:
27 27
- Be kind and constructive.
28 28
- Stay on topic (use per-repo discussions for repo-specific issues).
29 29
- Have fun.',
30 30
'cloudhead.io', datetime('now', '-14 days'));
31 31
32 32
-- Replies to discussion 1
33 33
INSERT INTO replies (discussion_id, body, author, created_at)
34 -
VALUES (last_insert_rowid(), 'Thanks for setting this up! Excited to be here.',
34 +
VALUES (last_insert_rowid(), 'Thanks for setting this up! *Excited* to be here.',
35 35
'alice.bsky.social', datetime('now', '-13 days'));
36 36
37 37
INSERT INTO replies (discussion_id, body, author, created_at)
38 -
VALUES ((SELECT MAX(id) FROM discussions), 'Great to see a forum that works without JavaScript.',
38 +
VALUES ((SELECT MAX(id) FROM discussions), 'Great to see a forum that works without JavaScript. The source is really clean too.',
39 39
'bob.bsky.social', datetime('now', '-12 days'));
40 40
41 41
INSERT INTO replies (discussion_id, body, author, created_at)
42 -
VALUES ((SELECT MAX(id) FROM discussions), 'Agreed, this is refreshing. Looking forward to contributing.',
42 +
VALUES ((SELECT MAX(id) FROM discussions), 'Agreed, this is refreshing. I found the contributing guide at <https://radiant.dev/contributing> very helpful. Looking forward to contributing.',
43 43
'alice.bsky.social', datetime('now', '-11 days'));
44 44
45 45
-- Discussion 2: build question
46 46
INSERT INTO discussions (repo, title, body, author, created_at)
47 47
VALUES ('_site', 'Cross-compiling forge for ARM?',
48 48
'Has anyone tried cross-compiling the forge binary for ARM (e.g. Raspberry Pi)?
49 49
50 -
I tried GOARCH=arm64 go build . and it built fine, but I am wondering if there are any gotchas with the SQLite dependency on musl vs glibc.',
50 +
I tried `GOARCH=arm64 go build .` and it built fine, but I am wondering if there are any gotchas with the SQLite dependency on *musl* vs *glibc*.',
51 51
'bob.bsky.social', datetime('now', '-10 days'));
52 52
53 53
INSERT INTO replies (discussion_id, body, author, created_at)
54 -
VALUES (last_insert_rowid(), 'I run it on a Pi 4 with no issues. Just make sure you have CGO_ENABLED=1 and a cross-compiler installed for the SQLite bits.',
54 +
VALUES (last_insert_rowid(), 'I run it on a Pi 4 with no issues. Here is what I use:
55 +
56 +
```
57 +
export CGO_ENABLED=1
58 +
export CC=aarch64-linux-gnu-gcc
59 +
GOARCH=arm64 go build .
60 +
```
61 +
62 +
See <https://go.dev/doc/install/source#environment> for the full list of env vars.',
55 63
'alice.bsky.social', datetime('now', '-9 days'));
56 64
57 65
INSERT INTO replies (discussion_id, body, author, created_at)
58 -
VALUES ((SELECT MAX(id) FROM discussions), 'Thanks, that did the trick!',
66 +
VALUES ((SELECT MAX(id) FROM discussions), 'Thanks, that did the trick! Setting `CGO_ENABLED=1` was the key part I was missing.',
59 67
'bob.bsky.social', datetime('now', '-8 days'));
60 68
61 69
-- Discussion 3: feature idea
62 70
INSERT INTO discussions (repo, title, body, author, created_at)
63 71
VALUES ('_site', 'Idea: RSS feed for repository updates',
64 -
'It would be nice to have an RSS/Atom feed at /feed or /rss that lists recent commits across all public repos. That way people could subscribe and stay up to date without polling the web UI.
72 +
'It would be nice to have an RSS/Atom feed at `/feed` or `/rss` that lists recent commits across all public repos. That way people could subscribe and stay up to date without polling the web UI.
65 73
66 -
Anyone else interested in this?',
74 +
The Atom spec is at <https://www.rfc-editor.org/rfc/rfc4287>. Anyone else interested in this?',
67 75
'alice.bsky.social', datetime('now', '-7 days'));
68 76
69 77
INSERT INTO replies (discussion_id, body, author, created_at)
70 -
VALUES (last_insert_rowid(), 'Yes! I would use this. Per-repo feeds would be great too.',
78 +
VALUES (last_insert_rowid(), '*Yes!* I would use this. Per-repo feeds would be great too.',
71 79
'bob.bsky.social', datetime('now', '-6 days'));
72 80
73 81
INSERT INTO replies (discussion_id, body, author, created_at)
74 -
VALUES ((SELECT MAX(id) FROM discussions), 'I might take a crack at this. Seems straightforward to add a handler that shells out to git log --all.',
82 +
VALUES ((SELECT MAX(id) FROM discussions), 'I might take a crack at this. The handler would be something like:
83 +
84 +
```go
85 +
func (s *server) handleFeed(w http.ResponseWriter, r *http.Request) {
86 +
    commits := s.getRecentCommits(20)
87 +
    w.Header().Set("Content-Type", "application/atom+xml")
88 +
    renderAtomFeed(w, commits)
89 +
}
90 +
```
91 +
92 +
Seems straightforward to shell out to `git log --all` for the data.',
75 93
'cloudhead.io', datetime('now', '-4 days'));
76 94
77 95
-- Discussion 4: a shorter thread
78 96
INSERT INTO discussions (repo, title, body, author, created_at)
79 97
VALUES ('_site', 'Syntax highlighting for more languages?',
80 -
'Currently forge ships hirad.js and hiril.js for Radiance and RIL. Any plans to support other languages, or is the intent to keep it minimal?',
98 +
'Currently forge ships `hirad.js` and `hiril.js` for Radiance and RIL. Any plans to support other languages, or is the intent to keep it *minimal*?',
81 99
'bob.bsky.social', datetime('now', '-3 days'));
82 100
83 101
INSERT INTO replies (discussion_id, body, author, created_at)
84 -
VALUES (last_insert_rowid(), 'I think keeping it minimal is the right call. You can always add your own highlighter scripts if needed.',
102 +
VALUES (last_insert_rowid(), 'I think keeping it minimal is the right call. You can always drop your own highlighter scripts into `static/js/` and rebuild. The embed directive picks them up automatically.',
85 103
'cloudhead.io', datetime('now', '-2 days'));
86 104
87 105
-- Discussion 5: no replies yet
88 106
INSERT INTO discussions (repo, title, body, author, created_at)
89 107
VALUES ('_site', 'Deploying behind nginx reverse proxy',
90 -
'I am trying to set up forge behind nginx with a /git base URL. I pass -base-url /git but static assets (CSS, fonts) are not loading. Has anyone got a working nginx config they can share?',
108 +
'I am trying to set up forge behind nginx with a `/git` base URL. I pass `-base-url /git` but static assets (CSS, fonts) are not loading. Here is what I have so far:
109 +
110 +
```
111 +
location /git/ {
112 +
    proxy_pass http://127.0.0.1:8080/;
113 +
    proxy_set_header Host $host;
114 +
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
115 +
}
116 +
```
117 +
118 +
I followed the reverse proxy guide at <https://nginx.org/en/docs/http/ngx_http_proxy_module.html> but *something* is still off. Has anyone got a working config they can share?',
91 119
'alice.bsky.social', datetime('now', '-1 days'));
92 120
93 121
-- Avatars
94 122
INSERT OR IGNORE INTO avatars (handle, url) VALUES
95 123
  ('cloudhead.io',       '/avatars/cloudhead.svg'),
static/style.css +24 -7
251 251
.ref-list th:last-child,
252 252
.ref-list td:last-child { padding-right: 0.5rem; }
253 253
254 254
.hash { font-family: var(--mono); white-space: nowrap; }
255 255
.date { white-space: nowrap; color: var(--fg-dim); }
256 -
.author { color: var(--fg-dim); white-space: nowrap; }
256 +
.author { color: var(--fg-dim); white-space: nowrap; display: inline-flex; align-items: center; gap: 0.25rem; }
257 257
.subject { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
258 258
.mode { font-family: var(--mono); font-size: 1rem; color: var(--fg-dim); white-space: nowrap; }
259 259
.size { font-family: var(--mono); font-size: 0.875rem; color: var(--fg-dim); text-align: right; white-space: nowrap; }
260 260
261 261
.clone-url {
817 817
.discussions-header h2 { margin-bottom: 0; }
818 818
.discussions-actions {
819 819
  display: flex;
820 820
  align-items: center;
821 821
  gap: 0.75rem;
822 +
  margin-left: auto;
822 823
}
823 824
.signed-in {
824 825
  font-family: var(--mono);
825 826
  font-size: 0.875rem;
826 827
  color: var(--fg-dim);
840 841
}
841 842
.btn:hover { background: var(--bg-alt); text-decoration: none; }
842 843
.btn:disabled { opacity: 0.5; cursor: default; }
843 844
main .btn { font-size: 1rem; }
844 845
845 -
.discussion-list td { border-bottom: none; }
846 +
.discussion-list td { border-bottom: none; vertical-align: middle; }
846 847
.discussion-list tbody tr:hover { background: var(--bg-alt); }
847 848
.discussion-list th:first-child,
848 849
.discussion-list td:first-child { padding-left: 0.5rem; }
849 850
.discussion-list th:last-child,
850 -
.discussion-list td:last-child { padding-right: 0.5rem; }
851 +
.discussion-list td:last-child { padding-right: 0.5rem; text-align: right; }
851 852
.discussion-list .discussion-title a { color: var(--link); }
853 +
.discussion-list .avatar { width: 16px; height: 16px; margin-right: 0; }
852 854
.discussion-list .discussion-replies {
853 855
  white-space: nowrap;
854 856
  color: var(--fg-dim);
855 -
  text-align: right;
857 +
  text-align: left;
856 858
  width: 1%;
857 859
}
858 860
859 861
.comment {
860 862
  border: 1px solid var(--border);
867 869
  margin: 1rem 0;
868 870
}
869 871
.comment-header {
870 872
  display: flex;
871 873
  align-items: center;
872 -
  gap: 0.75rem;
873 874
  padding: 0.75rem 0.75rem 0.5rem;
874 875
  font-size: 1rem;
875 876
  color: var(--fg);
876 877
}
877 878
.comment-date {
878 -
  margin-left: auto;
879 -
  font-size: 0.875rem;
879 +
  font-size: 1rem;
880 880
  color: var(--fg-dim);
881 881
  white-space: nowrap;
882 +
  margin-left: 0.25rem;
882 883
}
883 884
.comment-author {
884 885
  display: inline-flex;
885 886
  align-items: center;
886 887
  gap: 0.25rem;
906 907
  margin-top: 0;
907 908
}
908 909
.post-body p:last-child {
909 910
  margin-bottom: 0;
910 911
}
912 +
.post-body code {
913 +
  background: var(--bg-alt);
914 +
  padding: 0.125rem 0.25rem;
915 +
  border-radius: 0.25rem;
916 +
}
917 +
.post-body pre {
918 +
  background: var(--bg-alt);
919 +
  padding: 0.75rem;
920 +
  border-radius: 0.25rem;
921 +
  margin: 0.5rem 0;
922 +
  overflow-x: auto;
923 +
}
924 +
.post-body pre code {
925 +
  padding: 0;
926 +
  background: none;
927 +
}
911 928
912 929
.discussion-form {
913 930
  margin-top: 1rem;
914 931
}
915 932
.discussion-form label {
template.go +106 -25
180 180
	"formatBody": func(s string) template.HTML {
181 181
		s = strings.TrimSpace(s)
182 182
		if s == "" {
183 183
			return ""
184 184
		}
185 -
		paragraphs := strings.Split(s, "\n\n")
186 185
		var b strings.Builder
187 -
		for _, p := range paragraphs {
188 -
			p = strings.TrimSpace(p)
189 -
			if p == "" {
190 -
				continue
186 +
		// Split into alternating prose / fenced-code-block segments.
187 +
		for s != "" {
188 +
			// Find the next opening fence.
189 +
			openIdx := strings.Index(s, "```")
190 +
			if openIdx < 0 {
191 +
				formatParagraphs(&b, s)
192 +
				break
191 193
			}
192 -
			// Apply autolink-style URL linking within each paragraph.
193 -
			var pb strings.Builder
194 -
			last := 0
195 -
			for _, loc := range urlRe.FindAllStringIndex(p, -1) {
196 -
				end := loc[1]
197 -
				for end > loc[0] && strings.ContainsRune(".,;:!?)]", rune(p[end-1])) {
198 -
					end--
199 -
				}
200 -
				pb.WriteString(html.EscapeString(p[last:loc[0]]))
201 -
				u := p[loc[0]:end]
202 -
				pb.WriteString(`<a href="`)
203 -
				pb.WriteString(html.EscapeString(u))
204 -
				pb.WriteString(`">`)
205 -
				pb.WriteString(html.EscapeString(u))
206 -
				pb.WriteString(`</a>`)
207 -
				last = end
194 +
			// Prose before the fence.
195 +
			if openIdx > 0 {
196 +
				formatParagraphs(&b, s[:openIdx])
208 197
			}
209 -
			pb.WriteString(html.EscapeString(p[last:]))
210 -
			b.WriteString("<p>")
211 -
			b.WriteString(pb.String())
212 -
			b.WriteString("</p>")
198 +
			// Skip the opening ``` and any language tag on the same line.
199 +
			rest := s[openIdx+3:]
200 +
			if nl := strings.Index(rest, "\n"); nl >= 0 {
201 +
				rest = rest[nl+1:]
202 +
			} else {
203 +
				// ``` at end of input with nothing after — treat as prose.
204 +
				formatParagraphs(&b, s[openIdx:])
205 +
				break
206 +
			}
207 +
			// Find the closing fence.
208 +
			closeIdx := strings.Index(rest, "```")
209 +
			var code string
210 +
			if closeIdx < 0 {
211 +
				code = rest
212 +
				s = ""
213 +
			} else {
214 +
				code = rest[:closeIdx]
215 +
				s = rest[closeIdx+3:]
216 +
			}
217 +
			code = strings.TrimRight(code, "\n")
218 +
			b.WriteString("<pre><code>")
219 +
			b.WriteString(html.EscapeString(code))
220 +
			b.WriteString("</code></pre>")
213 221
		}
214 222
		return template.HTML(b.String())
215 223
	},
216 224
	"discussionPath": func(baseURL, repo string) string {
217 225
		if repo == "" {
242 250
	},
243 251
}
244 252
245 253
var urlRe = regexp.MustCompile(`https?://[^\s<>"'` + "`" + `\x00-\x1f]+`)
246 254
255 +
// inlineRe matches (in order): backtick code, angle-bracket links, bare URLs, or *italic*.
256 +
var inlineRe = regexp.MustCompile("`" + `([^` + "`" + `\n]+)` + "`" +
257 +
	`|<(https?://[^\s<>]+)>` +
258 +
	`|(https?://[^\s<>"'` + "`" + `\x00-\x1f]+)` +
259 +
	`|\*([^\s*][^*]*[^\s*])\*|\*([^\s*])\*`)
260 +
261 +
// formatParagraphs splits prose text on blank lines and writes <p> elements
262 +
// with inline formatting into b.
263 +
func formatParagraphs(b *strings.Builder, s string) {
264 +
	for _, p := range strings.Split(s, "\n\n") {
265 +
		p = strings.TrimSpace(p)
266 +
		if p == "" {
267 +
			continue
268 +
		}
269 +
		b.WriteString("<p>")
270 +
		b.WriteString(formatInline(p))
271 +
		b.WriteString("</p>")
272 +
	}
273 +
}
274 +
275 +
// formatInline applies inline formatting to a paragraph of raw text.
276 +
// It handles backtick `code`, *italic*, <https://...> links, and bare URLs.
277 +
func formatInline(p string) string {
278 +
	var b strings.Builder
279 +
	last := 0
280 +
	for _, m := range inlineRe.FindAllStringSubmatchIndex(p, -1) {
281 +
		start, end := m[0], m[1]
282 +
		b.WriteString(html.EscapeString(p[last:start]))
283 +
284 +
		switch {
285 +
		case m[2] >= 0: // backtick code: group 1
286 +
			code := p[m[2]:m[3]]
287 +
			b.WriteString("<code>")
288 +
			b.WriteString(html.EscapeString(code))
289 +
			b.WriteString("</code>")
290 +
		case m[4] >= 0: // angle-bracket link: group 2
291 +
			u := p[m[4]:m[5]]
292 +
			b.WriteString(`<a href="`)
293 +
			b.WriteString(html.EscapeString(u))
294 +
			b.WriteString(`">`)
295 +
			b.WriteString(html.EscapeString(u))
296 +
			b.WriteString("</a>")
297 +
		case m[6] >= 0: // bare URL: group 3
298 +
			// Strip trailing punctuation.
299 +
			urlEnd := m[7]
300 +
			for urlEnd > m[6] && strings.ContainsRune(".,;:!?)]", rune(p[urlEnd-1])) {
301 +
				urlEnd--
302 +
			}
303 +
			u := p[m[6]:urlEnd]
304 +
			b.WriteString(`<a href="`)
305 +
			b.WriteString(html.EscapeString(u))
306 +
			b.WriteString(`">`)
307 +
			b.WriteString(html.EscapeString(u))
308 +
			b.WriteString("</a>")
309 +
			// Anything between stripped punctuation and regex end is plain text.
310 +
			b.WriteString(html.EscapeString(p[urlEnd:end]))
311 +
		case m[8] >= 0: // italic (multi-char): group 4
312 +
			inner := p[m[8]:m[9]]
313 +
			b.WriteString("<em>")
314 +
			b.WriteString(html.EscapeString(inner))
315 +
			b.WriteString("</em>")
316 +
		case m[10] >= 0: // italic (single-char): group 5
317 +
			inner := p[m[10]:m[11]]
318 +
			b.WriteString("<em>")
319 +
			b.WriteString(html.EscapeString(inner))
320 +
			b.WriteString("</em>")
321 +
		}
322 +
		last = end
323 +
	}
324 +
	b.WriteString(html.EscapeString(p[last:]))
325 +
	return b.String()
326 +
}
327 +
247 328
func loadTemplates() (*templateSet, error) {
248 329
	layoutContent, err := templateFS.ReadFile("templates/layout.html")
249 330
	if err != nil {
250 331
		return nil, fmt.Errorf("read layout: %w", err)
251 332
	}
templates/discussion.html +2 -4
2 2
{{$dpath := discussionPath .BaseURL .Repo}}
3 3
<h2 class="topic-title">{{.Data.Discussion.Title}}</h2>
4 4
5 5
<div class="comment">
6 6
  <div class="comment-header">
7 -
    <span class="comment-author">{{if .Data.Discussion.Avatar}}<img class="avatar" src="{{.Data.Discussion.Avatar}}" alt="" width="20" height="20"> {{end}}<a href="https://bsky.app/profile/{{.Data.Discussion.Author}}">{{.Data.Discussion.Author}}</a></span>
8 -
    <span class="comment-date">{{timeAgo .Data.Discussion.CreatedAt}} ago</span>
7 +
    <span class="comment-author">{{if .Data.Discussion.Avatar}}<img class="avatar" src="{{.Data.Discussion.Avatar}}" alt="" width="20" height="20"> {{end}}<a href="https://bsky.app/profile/{{.Data.Discussion.Author}}">{{.Data.Discussion.Author}}</a> <span class="comment-date">{{timeAgo .Data.Discussion.CreatedAt}} ago</span></span>
9 8
  </div>
10 9
  {{if .Data.Discussion.Body}}<div class="post-body">{{formatBody .Data.Discussion.Body}}</div>{{end}}
11 10
</div>
12 11
13 12
{{range .Data.Replies}}
14 13
<div class="comment" id="reply-{{.ID}}">
15 14
  <div class="comment-header">
16 -
    <span class="comment-author">{{if .Avatar}}<img class="avatar" src="{{.Avatar}}" alt="" width="20" height="20"> {{end}}<a href="https://bsky.app/profile/{{.Author}}">{{.Author}}</a></span>
17 -
    <span class="comment-date">{{timeAgo .CreatedAt}} ago</span>
15 +
    <span class="comment-author">{{if .Avatar}}<img class="avatar" src="{{.Avatar}}" alt="" width="20" height="20"> {{end}}<a href="https://bsky.app/profile/{{.Author}}">{{.Author}}</a> <span class="comment-date">{{timeAgo .CreatedAt}} ago</span></span>
18 16
  </div>
19 17
  <div class="post-body">{{formatBody .Body}}</div>
20 18
</div>
21 19
{{end}}
22 20
templates/discussions.html +1 -1
22 22
</thead>
23 23
<tbody>
24 24
{{range .Data.Discussions}}
25 25
  <tr>
26 26
    <td class="discussion-title"><a href="{{$dpath}}/{{.ID}}">{{.Title}}</a></td>
27 -
    <td class="author desktop">{{if .Avatar}}<img class="avatar" src="{{.Avatar}}" alt="" width="20" height="20"> {{end}}<a href="https://bsky.app/profile/{{.Author}}">{{.Author}}</a></td>
27 +
    <td class="author desktop">{{if .Avatar}}<img class="avatar" src="{{.Avatar}}" alt="" width="16" height="16"> {{end}}<a href="https://bsky.app/profile/{{.Author}}">{{.Author}}</a></td>
28 28
    <td class="discussion-replies desktop">{{.ReplyCount}}</td>
29 29
    <td class="date">{{timeAgo .LastActive}}</td>
30 30
  </tr>
31 31
{{end}}
32 32
</tbody>