diff --git a/go.mod b/go.mod
index 22aa98c..8331f4b 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/internal/core/org_markdown_test.go b/internal/core/org_markdown_test.go
new file mode 100644
index 0000000..1f8f7b9
--- /dev/null
+++ b/internal/core/org_markdown_test.go
@@ -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, "many") {
+ t.Fatal("org description did not render markdown emphasis (many missing)")
+ }
+ if !strings.Contains(body, "
one") {
+ 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")
+ }
+}
diff --git a/internal/core/templates/my_orgs.html b/internal/core/templates/my_orgs.html
index c9d2095..d6638f5 100644
--- a/internal/core/templates/my_orgs.html
+++ b/internal/core/templates/my_orgs.html
@@ -25,7 +25,7 @@
{{if .Org.Region}}{{.Org.Region}}{{end}}
{{if eq .Role "admin"}}admin{{else}}member{{end}}
- {{if .Org.Description}}{{.Org.Description}}
{{end}}
+ {{if .Org.Description}}{{markdowntext .Org.Description}}
{{end}}
{{end}}
diff --git a/internal/core/templates/org.html b/internal/core/templates/org.html
index 4830cac..d68d879 100644
--- a/internal/core/templates/org.html
+++ b/internal/core/templates/org.html
@@ -79,7 +79,7 @@
{{if .Org.Description}}
-
{{.Org.Description}}
+
{{markdown .Org.Description}}
{{else}}
No description yet.
{{end}}
@@ -118,6 +118,7 @@
+ Markdown supported — headings, lists, links, and emphasis.
diff --git a/internal/marketing/templates/orgs.html b/internal/marketing/templates/orgs.html
index 8984c67..d3e8699 100644
--- a/internal/marketing/templates/orgs.html
+++ b/internal/marketing/templates/orgs.html
@@ -19,7 +19,7 @@
{{.Name}}
{{if .Region}}
{{.Region}}{{end}}
- {{if .Description}}{{.Description}}
{{end}}
+ {{if .Description}}{{markdowntext .Description}}
{{end}}
{{.MemberCount}} member{{if ne .MemberCount 1}}s{{end}} · {{.RepeaterCount}} repeater{{if ne .RepeaterCount 1}}s{{end}}
{{end}}
diff --git a/internal/web/env.go b/internal/web/env.go
index f39db12..1a27e9d 100644
--- a/internal/web/env.go
+++ b/internal/web/env.go
@@ -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) {
diff --git a/internal/web/markdown.go b/internal/web/markdown.go
index c9bad73..6b9d9c5 100644
--- a/internal/web/markdown.go
+++ b/internal/web/markdown.go
@@ -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()), " ")
+}
diff --git a/internal/web/markdown_test.go b/internal/web/markdown_test.go
new file mode 100644
index 0000000..e0b62d2
--- /dev/null
+++ b/internal/web/markdown_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/internal/web/templates/org_public.html b/internal/web/templates/org_public.html
index 18495bd..7fe47ed 100644
--- a/internal/web/templates/org_public.html
+++ b/internal/web/templates/org_public.html
@@ -72,7 +72,7 @@
{{if .Org.Region}}
{{template "icon-map-pin" "me-1"}}{{.Org.Region}}
{{end}}
- {{if .Org.Description}}
{{.Org.Description}}
{{else}}
This organization hasn't added a description yet.
{{end}}
+ {{if .Org.Description}}
{{markdown .Org.Description}}
{{else}}
This organization hasn't added a description yet.
{{end}}
Admins
{{if .Admins}}