mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-01 17:38:15 +00:00
Add markdown support for org descriptions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()), " ")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user