Add markdown support for org descriptions

This commit is contained in:
Jonathon Leight
2026-07-03 17:52:31 -04:00
parent fb32f7dc8d
commit 88f5caafdf
9 changed files with 102 additions and 5 deletions
+1 -1
View File
@@ -20,6 +20,7 @@ require (
github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0
github.com/yuin/goldmark v1.8.2
golang.org/x/crypto v0.53.0
golang.org/x/net v0.55.0
)
require (
@@ -93,7 +94,6 @@ require (
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
+35
View File
@@ -0,0 +1,35 @@
package core
import (
"strings"
"testing"
)
// TestOrgDescriptionRendersMarkdown verifies the public org page renders the org
// description through the markdown pipeline (sanitized HTML), not as raw source.
func TestOrgDescriptionRendersMarkdown(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
owner, err := st.CreateUser(ctx, "mdowner", "")
if err != nil {
t.Fatal(err)
}
org, err := st.CreateOrg(ctx, "Markdown Org", owner.ID)
if err != nil {
t.Fatal(err)
}
if err := st.UpdateOrg(ctx, org.ID, org.Slug, org.Name, "We run **many** repeaters.\n\n- one\n- two", "NA"); err != nil {
t.Fatal(err)
}
body := readBody(t, do(t, ts, h.root, "/orgs/"+org.Slug))
if !strings.Contains(body, "<strong>many</strong>") {
t.Fatal("org description did not render markdown emphasis (<strong>many</strong> missing)")
}
if !strings.Contains(body, "<li>one</li>") {
t.Fatal("org description did not render a markdown list")
}
if strings.Contains(body, "**many**") {
t.Fatal("raw markdown source leaked into the rendered page")
}
}
+1 -1
View File
@@ -25,7 +25,7 @@
{{if .Org.Region}}<span class="badge bg-secondary-lt">{{.Org.Region}}</span>{{end}}
{{if eq .Role "admin"}}<span class="badge bg-success-lt">admin</span>{{else}}<span class="badge bg-azure-lt">member</span>{{end}}
</div>
{{if .Org.Description}}<p class="text-secondary mb-0 mt-1">{{.Org.Description}}</p>{{end}}
{{if .Org.Description}}<p class="text-secondary mb-0 mt-1">{{markdowntext .Org.Description}}</p>{{end}}
</a>
{{end}}
</div>
+2 -1
View File
@@ -79,7 +79,7 @@
<div class="card-header"><h3 class="card-title">About</h3></div>
<div class="card-body">
{{if .Org.Description}}
<p class="mb-0">{{.Org.Description}}</p>
<div class="markdown">{{markdown .Org.Description}}</div>
{{else}}
<p class="text-secondary mb-0">No description yet.</p>
{{end}}
@@ -118,6 +118,7 @@
<div class="mb-3">
<label class="form-label" for="org_description">Description</label>
<textarea class="form-control" id="org_description" name="description" rows="3" maxlength="2000" placeholder="What this organization is about, where it operates, etc.">{{.Org.Description}}</textarea>
<small class="form-hint">Markdown supported — headings, lists, links, and emphasis.</small>
</div>
<button type="submit" class="btn btn-primary">Save profile</button>
</form>
+1 -1
View File
@@ -19,7 +19,7 @@
<span class="fw-bold">{{.Name}}</span>
{{if .Region}}<span class="badge bg-secondary-lt">{{.Region}}</span>{{end}}
</div>
{{if .Description}}<p class="text-secondary mb-1 mt-1">{{.Description}}</p>{{end}}
{{if .Description}}<p class="text-secondary mb-1 mt-1">{{markdowntext .Description}}</p>{{end}}
<div class="text-secondary small">{{.MemberCount}} member{{if ne .MemberCount 1}}s{{end}} · {{.RepeaterCount}} repeater{{if ne .RepeaterCount 1}}s{{end}}</div>
</a>
{{end}}
+5
View File
@@ -131,6 +131,11 @@ func (e *Env) SetDefaultLayout(name string) { e.Renderer.defaultLayout = name }
var templateFuncs = template.FuncMap{
"mhz": func(hz int64) string { return strconv.FormatFloat(float64(hz)/1e6, 'f', -1, 64) },
"khz": func(hz int64) string { return strconv.FormatFloat(float64(hz)/1e3, 'f', -1, 64) },
// markdown renders user-authored markdown (e.g. an org description) to
// sanitized HTML. Wrap the output in a `.markdown` container for spacing.
"markdown": Markdown,
// markdowntext flattens that same markdown to plain text for compact teasers.
"markdowntext": MarkdownText,
}
func NewRenderer(cfg *config.Config, surfaceTemplates fs.FS) (*Renderer, error) {
+35
View File
@@ -3,9 +3,11 @@ package web
import (
"bytes"
"html/template"
"strings"
"github.com/microcosm-cc/bluemonday"
"github.com/yuin/goldmark"
"golang.org/x/net/html"
)
// goldmark with default options does NOT pass raw HTML through (it escapes it),
@@ -29,3 +31,36 @@ func Markdown(src string) template.HTML {
}
return template.HTML(mdPolicy.SanitizeBytes(buf.Bytes())) //nolint:gosec // G203: output is bluemonday-sanitized by mdPolicy
}
// MarkdownText flattens markdown to a single line of plain text — the formatting
// (emphasis, headings, list bullets, link syntax) stripped, leaving just the
// words. Used for compact teasers (e.g. org directory cards) where rendered
// markdown would be out of place. Returns the source unchanged on parse error.
func MarkdownText(src string) string {
if src == "" {
return ""
}
var buf bytes.Buffer
if err := mdRenderer.Convert([]byte(src), &buf); err != nil {
return src
}
doc, err := html.Parse(&buf)
if err != nil {
return src
}
var b strings.Builder
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.TextNode {
// Separate every text run with a space; block boundaries otherwise glue
// adjacent words together ("one"+"two"). Runs collapse below.
b.WriteString(n.Data)
b.WriteByte(' ')
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(doc)
return strings.Join(strings.Fields(b.String()), " ")
}
+21
View File
@@ -0,0 +1,21 @@
package web
import "testing"
func TestMarkdownText(t *testing.T) {
cases := map[string]struct{ in, want string }{
"empty": {"", ""},
"emphasis": {"We run **many** repeaters.", "We run many repeaters."},
"heading": {"# Buffalo Mesh\n\nCovering the whole area.", "Buffalo Mesh Covering the whole area."},
"list": {"Gear:\n\n- radios\n- antennas", "Gear: radios antennas"},
"link": {"See [our site](https://example.com) for more.", "See our site for more."},
"collapses": {"line one\n\n\nline two", "line one line two"},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
if got := MarkdownText(c.in); got != c.want {
t.Fatalf("MarkdownText(%q) = %q, want %q", c.in, got, c.want)
}
})
}
}
+1 -1
View File
@@ -72,7 +72,7 @@
<div class="card-header"><h3 class="card-title">About</h3></div>
<div class="card-body">
{{if .Org.Region}}<div class="d-flex align-items-center text-secondary mb-2">{{template "icon-map-pin" "me-1"}}{{.Org.Region}}</div>{{end}}
{{if .Org.Description}}<p class="mb-0">{{.Org.Description}}</p>{{else}}<p class="text-secondary mb-0">This organization hasn't added a description yet.</p>{{end}}
{{if .Org.Description}}<div class="markdown">{{markdown .Org.Description}}</div>{{else}}<p class="text-secondary mb-0">This organization hasn't added a description yet.</p>{{end}}
<div class="hr-text">Admins</div>
{{if .Admins}}
<div class="d-flex flex-wrap gap-2">