Skip to content
17 changes: 17 additions & 0 deletions gno.land/pkg/gnoweb/components/view_markdown.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package components

import "bytes"

// MarkdownViewType marks a View whose content is raw markdown, to be served
// verbatim as text/markdown without the HTML page layout.
const MarkdownViewType ViewType = "markdown-view"

// MarkdownView returns a View that renders the given content as-is. The handler
// layer recognizes this view type and serves it with a text/markdown
// Content-Type, bypassing IndexLayout.
func MarkdownView(content []byte) *View {
return &View{
Type: MarkdownViewType,
Component: NewReaderComponent(bytes.NewReader(content)),
}
}
27 changes: 27 additions & 0 deletions gno.land/pkg/gnoweb/components/view_markdown_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package components_test

import (
"bytes"
"testing"

"github.com/gnolang/gno/gno.land/pkg/gnoweb/components"
)

func TestMarkdownView(t *testing.T) {
t.Parallel()

content := []byte("# Title\n\nbody text\n")
v := components.MarkdownView(content)

if v.Type != components.MarkdownViewType {
t.Fatalf("Type = %q, want %q", v.Type, components.MarkdownViewType)
}

var buf bytes.Buffer
if err := v.Render(&buf); err != nil {
t.Fatalf("Render returned error: %v", err)
}
if buf.String() != string(content) {
t.Fatalf("Render wrote %q, want verbatim %q", buf.String(), string(content))
}
}
71 changes: 58 additions & 13 deletions gno.land/pkg/gnoweb/handler_http.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@ func (h *HTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {

switch r.Method {
case http.MethodGet:
w.Header().Add("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// The same URL can return HTML or markdown depending on Accept.
w.Header().Set("Vary", "Accept")
h.Get(w, r)
case http.MethodPost:
h.Post(w, r)
Expand Down Expand Up @@ -195,8 +197,22 @@ func (h *HTTPHandler) Get(w http.ResponseWriter, r *http.Request) {
indexData.Mode = components.ViewModeRealm
}

var status int
status, indexData.BodyView = h.prepareIndexBodyView(r, &indexData)
wantMarkdown := negotiatesMarkdown(r.Header.Get("Accept"))

status, bodyView := h.prepareIndexBodyView(r, &indexData, wantMarkdown)

// The realm and static-markdown paths return a markdown view; serve its
// raw source verbatim with a text/markdown Content-Type, bypassing the layout.
if bodyView.Type == components.MarkdownViewType {
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This write serves realm Render() bytes verbatim (the HTML path sanitizes through goldmark) and gnoweb sets no nosniff anywhere. Add X-Content-Type-Options: nosniff here.

(AI Agent)

w.WriteHeader(status)
if err := bodyView.Render(w); err != nil {
h.Logger.Error("failed to render markdown view", "error", err)
}
return
}

indexData.BodyView = bodyView

// Render the final page with the rendered body
w.WriteHeader(status)
Expand Down Expand Up @@ -258,7 +274,7 @@ func (h *HTTPHandler) Post(w http.ResponseWriter, r *http.Request) {
}

// prepareIndexBodyView prepares the data and main view for the index page.
func (h *HTTPHandler) prepareIndexBodyView(r *http.Request, indexData *components.IndexData) (int, *components.View) {
func (h *HTTPHandler) prepareIndexBodyView(r *http.Request, indexData *components.IndexData, wantMarkdown bool) (int, *components.View) {
ctx := r.Context()

aliasTarget, aliasExists := h.Aliases[r.URL.Path]
Expand Down Expand Up @@ -286,10 +302,13 @@ func (h *HTTPHandler) prepareIndexBodyView(r *http.Request, indexData *component

switch {
case aliasExists && aliasTarget.Kind == StaticMarkdown:
if wantMarkdown {
return http.StatusOK, components.MarkdownView([]byte(aliasTarget.Value))
}
indexData.HeaderData.Static = true
return h.GetMarkdownView(gnourl, aliasTarget.Value)
case gnourl.IsRealm(), gnourl.IsPure(), gnourl.IsUser():
return h.GetPackageView(ctx, gnourl, indexData)
return h.GetPackageView(ctx, gnourl, indexData, wantMarkdown)
default:
h.Logger.Debug("invalid path: path is neither a pure package or a realm")
return http.StatusBadRequest, components.StatusErrorComponent("invalid path")
Expand Down Expand Up @@ -318,7 +337,7 @@ func (h *HTTPHandler) GetMarkdownView(gnourl *weburl.GnoURL, mdContent string) (
}

// GetPackageView handles package pages, including help, source, directory, and user views.
func (h *HTTPHandler) GetPackageView(ctx context.Context, gnourl *weburl.GnoURL, indexData *components.IndexData) (int, *components.View) {
func (h *HTTPHandler) GetPackageView(ctx context.Context, gnourl *weburl.GnoURL, indexData *components.IndexData, wantMarkdown bool) (int, *components.View) {
// Handle Help page
if gnourl.WebQuery.Has("help") {
return h.GetHelpView(ctx, gnourl)
Expand All @@ -340,24 +359,40 @@ func (h *HTTPHandler) GetPackageView(ctx context.Context, gnourl *weburl.GnoURL,
}

// Ultimately get realm view
if wantMarkdown {
return h.GetMarkdownRealmView(ctx, gnourl, indexData)
}
return h.GetRealmView(ctx, gnourl, indexData)
}

// GetRealmView renders a realm page or returns an error/status if not available.
func (h *HTTPHandler) GetRealmView(ctx context.Context, gnourl *weburl.GnoURL, indexData *components.IndexData) (int, *components.View) {
// First fecth the realm
// fetchRealm fetches a realm's raw Render() output. On success it returns the
// bytes with ok=true and the status/fallback unset. When the realm cannot be
// rendered it returns ok=false with the HTML fallback view and status to send as-is.
func (h *HTTPHandler) fetchRealm(ctx context.Context, gnourl *weburl.GnoURL, indexData *components.IndexData) ([]byte, int, *components.View, bool) {
raw, err := h.Client.Realm(ctx, gnourl.Path, gnourl.EncodeArgs())
switch {
case err == nil: // ok
case err == nil:
return raw, 0, nil, true
case errors.Is(err, ErrClientRenderNotDeclared):
// No Render() declared: fall back to directory view (which will show README.md if present)
return h.GetDirectoryView(ctx, gnourl, indexData)
status, view := h.GetDirectoryView(ctx, gnourl, indexData)
return nil, status, view, false
case errors.Is(err, ErrClientPackageNotFound):
// No realm exists here, try to display underlying paths
return h.GetPathsListView(ctx, gnourl, indexData)
status, view := h.GetPathsListView(ctx, gnourl, indexData)
return nil, status, view, false
default:
h.Logger.Error("unable to fetch realm", "error", err, "path", gnourl.EncodeURL())
return GetClientErrorStatusPage(gnourl, err)
status, view := GetClientErrorStatusPage(gnourl, err)
return nil, status, view, false
}
}

// GetRealmView renders a realm page as HTML, or returns an error/status if not available.
func (h *HTTPHandler) GetRealmView(ctx context.Context, gnourl *weburl.GnoURL, indexData *components.IndexData) (int, *components.View) {
raw, status, fallback, ok := h.fetchRealm(ctx, gnourl, indexData)
if !ok {
return status, fallback
}

var content bytes.Buffer
Expand All @@ -381,6 +416,16 @@ func (h *HTTPHandler) GetRealmView(ctx context.Context, gnourl *weburl.GnoURL, i
})
}

// GetMarkdownRealmView serves a realm's raw Render() output as text/markdown. It
// falls back to the directory, paths-list, or error view when the realm cannot be fetched.
func (h *HTTPHandler) GetMarkdownRealmView(ctx context.Context, gnourl *weburl.GnoURL, indexData *components.IndexData) (int, *components.View) {
raw, status, fallback, ok := h.fetchRealm(ctx, gnourl, indexData)
if !ok {
return status, fallback
}
return http.StatusOK, components.MarkdownView(raw)
}

// buildContributions returns the sorted list of contributions (packages and realms) for a user.
func (h *HTTPHandler) buildContributions(ctx context.Context, username string) ([]components.UserContribution, int, error) {
prefix := "@" + username
Expand Down
129 changes: 129 additions & 0 deletions gno.land/pkg/gnoweb/handler_http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1470,6 +1470,135 @@ func TestGetHelpView_BackslashEscapingIssueFixed(t *testing.T) {
require.Contains(t, body, "`_`")
}

// TestHTTPHandler_MarkdownNegotiation verifies that an explicit Accept:
// text/markdown yields the raw realm markdown (no HTML layout), while other
// Accept values fall back to HTML. Vary: Accept is always present.
func TestHTTPHandler_MarkdownNegotiation(t *testing.T) {
t.Parallel()

mockPackage := &gnoweb.MockPackage{
Domain: "example.com",
Path: "/r/mock/path",
Files: map[string]string{
"render.gno": `package main; func Render(path string) string { return "hello" }`,
},
Functions: []*doc.JSONFunc{
{Name: "Render", Params: []*doc.JSONField{{Name: "path", Type: "string"}}, Results: []*doc.JSONField{{Name: "", Type: "string"}}},
},
}
config := newTestHandlerConfig(t, gnoweb.NewMockClient(mockPackage))

cases := []struct {
name string
accept string
wantCT string
markdown bool // true => raw markdown body (no HTML layout)
}{
{"explicit markdown", "text/markdown", "text/markdown; charset=utf-8", true},
{"x-markdown alias", "text/x-markdown", "text/markdown; charset=utf-8", true},
{"markdown with charset", "text/markdown; charset=utf-8", "text/markdown; charset=utf-8", true},
{"browser accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "text/html; charset=utf-8", false},
{"wildcard only", "*/*", "text/html; charset=utf-8", false},
{"markdown refused q0", "text/markdown;q=0", "text/html; charset=utf-8", false},
{"no accept header", "", "text/html; charset=utf-8", false},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

logger := slog.New(slog.NewTextHandler(&testingLogger{t}, &slog.HandlerOptions{}))
handler, err := gnoweb.NewHTTPHandler(logger, config)
require.NoError(t, err)

req, err := http.NewRequest(http.MethodGet, "/r/mock/path", nil)
require.NoError(t, err)
if tc.accept != "" {
req.Header.Set("Accept", tc.accept)
}

rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

require.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, tc.wantCT, rr.Header().Get("Content-Type"))
assert.Contains(t, rr.Header().Values("Vary"), "Accept")

body := rr.Body.String()
if tc.markdown {
assert.NotContains(t, body, "<!doctype html>")
assert.Contains(t, body, "[example.com]/r/mock/path") // from MockClient.Realm
} else {
assert.Contains(t, body, "<!doctype html>")
}
})
}
}

// TestHTTPHandler_MarkdownNegotiation_StaticAlias verifies a StaticMarkdown
// alias is served verbatim under Accept: text/markdown.
func TestHTTPHandler_MarkdownNegotiation_StaticAlias(t *testing.T) {
t.Parallel()

const md = "# About\n\nStatic markdown content.\n"
config := &gnoweb.HTTPHandlerConfig{
ClientAdapter: gnoweb.NewMockClient(),
Renderer: &rawRenderer{},
Aliases: map[string]gnoweb.AliasTarget{
"/about": {Value: md, Kind: gnoweb.StaticMarkdown},
},
}

logger := slog.New(slog.NewTextHandler(&testingLogger{t}, &slog.HandlerOptions{}))
handler, err := gnoweb.NewHTTPHandler(logger, config)
require.NoError(t, err)

req, err := http.NewRequest(http.MethodGet, "/about", nil)
require.NoError(t, err)
req.Header.Set("Accept", "text/markdown")

rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

require.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, "text/markdown; charset=utf-8", rr.Header().Get("Content-Type"))
assert.Equal(t, md, rr.Body.String())
}

// TestHTTPHandler_MarkdownNegotiation_NoRenderFallsBackToHTML verifies that a
// realm without a Render() function falls back to the HTML directory view even
// when markdown is requested. This guards the ordering invariant: the markdown
// short-circuit sits AFTER the fetch error-switch, not before it.
func TestHTTPHandler_MarkdownNegotiation_NoRenderFallsBackToHTML(t *testing.T) {
t.Parallel()

mockPackage := &gnoweb.MockPackage{
Domain: "example.com",
Path: "/r/norender/path",
Files: map[string]string{
"a.gno": `package main; func init() {}`,
"gno.mod": `module example.com/r/norender/path`,
},
Functions: []*doc.JSONFunc{}, // no Render
}
config := newTestHandlerConfig(t, gnoweb.NewMockClient(mockPackage))

logger := slog.New(slog.NewTextHandler(&testingLogger{t}, &slog.HandlerOptions{}))
handler, err := gnoweb.NewHTTPHandler(logger, config)
require.NoError(t, err)

req, err := http.NewRequest(http.MethodGet, "/r/norender/path", nil)
require.NoError(t, err)
req.Header.Set("Accept", "text/markdown")

rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

// Fell back to the HTML directory view, NOT markdown.
assert.Equal(t, "text/html; charset=utf-8", rr.Header().Get("Content-Type"))
assert.Contains(t, rr.Body.String(), "<!doctype html>")
}

func TestHTTPHandler_ThemeCookie(t *testing.T) {
t.Parallel()

Expand Down
32 changes: 32 additions & 0 deletions gno.land/pkg/gnoweb/negotiate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package gnoweb

import (
"mime"
"strconv"
"strings"
)

// negotiatesMarkdown reports whether the client explicitly accepts markdown.
//
// It returns true only when "text/markdown" (or the alias "text/x-markdown")
// is named explicitly in the Accept header and not refused with an explicit
// q=0. It never matches the "*/*" or "text/*" wildcards, so browsers — which
// always accept "*/*" — continue to receive HTML.
func negotiatesMarkdown(accept string) bool {
for _, part := range strings.Split(accept, ",") {
mediaType, params, err := mime.ParseMediaType(strings.TrimSpace(part))
if err != nil {
continue
}
if mediaType != "text/markdown" && mediaType != "text/x-markdown" {
continue
}
if q, ok := params["q"]; ok {
if v, err := strconv.ParseFloat(q, 64); err == nil && v <= 0 {
continue
}
}
return true
}
return false
}
Comment on lines +15 to +32

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

text/html;q=0.9, text/markdown;q=0.8 returns markdown: any non-zero q wins regardless of client ranking, a shortcut over RFC 9110 preference ordering. Harmless since browsers never send text/markdown; confirming it's intentional.

repro
# from a local clone of gnolang/gno:
gh pr checkout 5794 -R gnolang/gno
go test ./gno.land/pkg/gnoweb/ -run 'TestNegotiatesMarkdown/markdown_present_with_non-zero_q_among_others' -v
=== RUN   TestNegotiatesMarkdown/markdown_present_with_non-zero_q_among_others
--- PASS: TestNegotiatesMarkdown/markdown_present_with_non-zero_q_among_others (0.00s)
PASS

negotiate_test.go:31 asserts want: true for this header.

(AI Agent)

42 changes: 42 additions & 0 deletions gno.land/pkg/gnoweb/negotiate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package gnoweb

import "testing"

func TestNegotiatesMarkdown(t *testing.T) {
t.Parallel()

cases := []struct {
name string
accept string
want bool
}{
{"empty", "", false},
{"plain markdown", "text/markdown", true},
{"x-markdown alias", "text/x-markdown", true},
{"case insensitive", "TEXT/Markdown", true},
{"markdown with charset param", "text/markdown; charset=utf-8", true},
{"markdown q half", "text/markdown;q=0.5", true},
{"markdown q one", "text/markdown;q=1.0", true},
{"markdown q zero", "text/markdown;q=0", false},
{"markdown q zero point zero", "text/markdown;q=0.0", false},
{"markdown negative q", "text/markdown;q=-1", false},
{"markdown malformed q", "text/markdown;q=abc", true},
{"html then markdown", "text/html, text/markdown", true},
{"browser accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", false},
{"claude-code webfetch accept", "text/markdown, text/html, */*", true},
{"wildcard only", "*/*", false},
{"text wildcard", "text/*", false},
{"json", "application/json", false},
{"surrounding spaces", " text/markdown ", true},
{"markdown present with non-zero q among others", "text/html;q=0.9, text/markdown;q=0.8", true},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := negotiatesMarkdown(tc.accept); got != tc.want {
t.Errorf("negotiatesMarkdown(%q) = %v, want %v", tc.accept, got, tc.want)
}
})
}
}