From 88f5caafdfabe93296c552b0402f01407bc3e19a Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Fri, 3 Jul 2026 17:52:31 -0400 Subject: [PATCH] Add markdown support for org descriptions --- go.mod | 2 +- internal/core/org_markdown_test.go | 35 ++++++++++++++++++++++++++ internal/core/templates/my_orgs.html | 2 +- internal/core/templates/org.html | 3 ++- internal/marketing/templates/orgs.html | 2 +- internal/web/env.go | 5 ++++ internal/web/markdown.go | 35 ++++++++++++++++++++++++++ internal/web/markdown_test.go | 21 ++++++++++++++++ internal/web/templates/org_public.html | 2 +- 9 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 internal/core/org_markdown_test.go create mode 100644 internal/web/markdown_test.go 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 @@

    About

    {{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 @@

    About

    {{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}}