{{ $pkgpath }}
--
- {{ range .Files }}
-
- - - - - {{ . }} - - Open - - - {{ end }} -
diff --git a/gno.land/pkg/gnoweb/app_test.go b/gno.land/pkg/gnoweb/app_test.go
index 30e6ad7fa1b..903d055bb90 100644
--- a/gno.land/pkg/gnoweb/app_test.go
+++ b/gno.land/pkg/gnoweb/app_test.go
@@ -48,7 +48,8 @@ func TestRoutes(t *testing.T) {
{"/game-of-realms", found, "/contribute"},
{"/gor", found, "/contribute"},
{"/blog", found, "/r/gnoland/blog"},
- {"/r/docs/optional_render", http.StatusOK, "No Render"},
+ {"/r/docs/optional_render", ok, "Directory"},
+ {"/r/docs/optional_render/", ok, "Directory"},
{"/r/not/found/", notFound, ""},
{"/z/bad/request", BadRequest, ""}, // not realm or pure
{"/아스키문자가아닌경로", notFound, ""},
diff --git a/gno.land/pkg/gnoweb/components/layout_index.go b/gno.land/pkg/gnoweb/components/layout_index.go
index cb70a0034c8..c2412e9d498 100644
--- a/gno.land/pkg/gnoweb/components/layout_index.go
+++ b/gno.land/pkg/gnoweb/components/layout_index.go
@@ -60,6 +60,7 @@ func IndexLayout(data IndexData) Component {
case DirectoryViewType:
dataLayout.IsDevmodView = true
+ dataLayout.Layout = SidebarLayout
case StatusViewType:
dataLayout.IsDevmodView = true
diff --git a/gno.land/pkg/gnoweb/components/layouts/article.html b/gno.land/pkg/gnoweb/components/layouts/article.html
index 9594a40a09c..9e4ccb30dec 100644
--- a/gno.land/pkg/gnoweb/components/layouts/article.html
+++ b/gno.land/pkg/gnoweb/components/layouts/article.html
@@ -1,3 +1,3 @@
{{ define "layout/article" }}
-{{ $pkgpath }}
-
- {{ range .Files }}
-
- {{ $pkgpath }}
+
+ {{ range .Files }}
+
+
{{ .Body }}
diff --git a/gno.land/pkg/gnoweb/frontend/css/input.css b/gno.land/pkg/gnoweb/frontend/css/input.css index 9339a04d6a8..5c7f8ae6084 100644 --- a/gno.land/pkg/gnoweb/frontend/css/input.css +++ b/gno.land/pkg/gnoweb/frontend/css/input.css @@ -50,199 +50,199 @@ @apply my-0; } - .realm-view { + .md-view { @apply text-200 break-words pt-6 lg:pt-10; } - .realm-view > *:first-child { + .md-view > *:first-child { @apply !mt-0; } - .realm-view a { + .md-view a { @apply text-green-600 font-medium hover:underline; } - .realm-view h1, - .realm-view h2, - .realm-view h3, - .realm-view h4 { + .md-view h1, + .md-view h2, + .md-view h3, + .md-view h4 { @apply text-gray-900 mt-12 leading-tight; } - .realm-view h2, - .realm-view h2 * { + .md-view h2, + .md-view h2 * { @apply font-bold; } - .realm-view h3, - .realm-view h3 *, - .realm-view h4, - .realm-view h4 * { + .md-view h3, + .md-view h3 *, + .md-view h4, + .md-view h4 * { @apply font-semibold; } - .realm-view h1 + h2, - .realm-view h2 + h3, - .realm-view h3 + h4 { + .md-view h1 + h2, + .md-view h2 + h3, + .md-view h3 + h4 { @apply mt-4; } - .realm-view h1 { + .md-view h1 { @apply text-800 font-bold; } - .realm-view h2 { + .md-view h2 { @apply text-600; } - .realm-view h3 { + .md-view h3 { @apply text-400 text-gray-600 mt-10; } - .realm-view h4 { + .md-view h4 { @apply text-300 text-gray-600 font-medium my-6; } - .realm-view p { + .md-view p { @apply my-5; } - .realm-view strong { + .md-view strong { @apply font-bold text-gray-900; } - .realm-view strong * { + .md-view strong * { @apply font-bold; } - .realm-view em { + .md-view em { @apply italic-subtle; } - .realm-view blockquote { + .md-view blockquote { @apply border-l-4 border-gray-300 pl-4 text-gray-600 italic-subtle my-4; } - .realm-view ul, - .realm-view ol { + .md-view ul, + .md-view ol { @apply pl-4 my-6; } - .realm-view ul li, - .realm-view ol li { + .md-view ul li, + .md-view ol li { @apply mb-2; } - .realm-view img { + .md-view img { @apply max-w-full my-8; } - .realm-view figure { + .md-view figure { @apply my-6 text-center; } - .realm-view figcaption { + .md-view figcaption { @apply text-100 text-gray-600; } - .realm-view :not(pre) > code { + .md-view :not(pre) > code { @apply bg-gray-100 px-1 py-0.5 rounded-sm text-[.96em] font-mono; } - .realm-view pre { + .md-view pre { @apply bg-gray-50 p-4 rounded overflow-x-auto font-mono; } - .realm-view hr { + .md-view hr { @apply border-t border-gray-100 my-10; } - .realm-view table { + .md-view table { @apply my-8 block w-full max-w-full overflow-x-auto border-collapse; } - .realm-view th, - .realm-view td { + .md-view th, + .md-view td { @apply border px-4 py-2 break-words whitespace-normal; } - .realm-view th { + .md-view th { @apply bg-gray-100 font-bold; } - .realm-view caption { + .md-view caption { @apply mt-2 text-100 text-gray-600 text-left; } - .realm-view q { + .md-view q { @apply quotes; } - .realm-view q::before { + .md-view q::before { content: open-quote; } - .realm-view q::after { + .md-view q::after { content: close-quote; } - .realm-view ul ul, - .realm-view ul ol, - .realm-view ol ul, - .realm-view ol ol { + .md-view ul ul, + .md-view ul ol, + .md-view ol ul, + .md-view ol ol { @apply my-2 pl-4; } - .realm-view ul { + .md-view ul { @apply list-disc; } - .realm-view ol { + .md-view ol { @apply list-decimal; } - .realm-view abbr[title] { + .md-view abbr[title] { @apply border-b border-dotted cursor-help; } - .realm-view details { + .md-view details { @apply my-5; } - .realm-view summary { + .md-view summary { @apply font-bold cursor-pointer; } - .realm-view a code { + .md-view a code { @apply text-inherit; } - .realm-view video { + .md-view video { @apply max-w-full my-8; } - .realm-view math { + .md-view math { @apply font-mono; } - .realm-view small { + .md-view small { @apply text-100; } - .realm-view del { + .md-view del { @apply line-through; } - .realm-view sub { + .md-view sub { @apply text-50 align-sub; } - .realm-view sup { + .md-view sup { @apply text-50 align-super; } - .realm-view input, - .realm-view button { + .md-view input, + .md-view button { @apply px-4 py-2 border border-gray-300; } @@ -258,10 +258,10 @@ } /* MD components */ - .realm-view .gno-columns { + .md-view .gno-columns { @apply flex flex-wrap gap-x-10 xxl:gap-x-12; } - .realm-view .gno-columns > * { + .md-view .gno-columns > * { @apply grow shrink basis-52 lg:basis-44; } } @@ -288,7 +288,7 @@ @apply block; } - :is(.main-header:has(#sidemenu-source:checked), .main-header:has(#sidemenu-docs:checked), .main-header:has(#sidemenu-meta:checked)) + main .realm-view, + :is(.main-header:has(#sidemenu-source:checked), .main-header:has(#sidemenu-docs:checked), .main-header:has(#sidemenu-meta:checked)) + main .md-view, :is(.main-header:has(#sidemenu-source:checked), .main-header:has(#sidemenu-docs:checked), .main-header:has(#sidemenu-meta:checked)) .main-navigation { @apply md:col-span-6; } @@ -318,20 +318,20 @@ main :is(.source-code) > pre { @apply !bg-light overflow-scroll rounded py-4 md:py-8 px-1 md:px-3 font-mono text-100 md:text-200; } - main .realm-view > pre a { + main .md-view > pre a { @apply hover:no-underline; } - main :is(.realm-view, .source-code) > pre .chroma-ln:target { + main :is(.md-view, .source-code) > pre .chroma-ln:target { @apply !bg-transparent; } - main :is(.realm-view, .source-code) > pre .chroma-line:has(.chroma-ln:target), - main :is(.realm-view, .source-code) > pre .chroma-line:has(.chroma-lnlinks:hover), - main :is(.realm-view, .source-code) > pre .chroma-line:has(.chroma-ln:target) .chroma-cl, - main :is(.realm-view, .source-code) > pre .chroma-line:has(.chroma-lnlinks:hover) .chroma-cl { + main :is(.md-view, .source-code) > pre .chroma-line:has(.chroma-ln:target), + main :is(.md-view, .source-code) > pre .chroma-line:has(.chroma-lnlinks:hover), + main :is(.md-view, .source-code) > pre .chroma-line:has(.chroma-ln:target) .chroma-cl, + main :is(.md-view, .source-code) > pre .chroma-line:has(.chroma-lnlinks:hover) .chroma-cl { @apply !bg-gray-100 rounded; } - main :is(.realm-view, .source-code) > pre .chroma-ln { + main :is(.md-view, .source-code) > pre .chroma-ln { @apply scroll-mt-24; } @@ -391,7 +391,7 @@ @apply inline align-text-top; } - .realm-view a > span:first-of-type { + .md-view a > span:first-of-type { @apply ml-0.5; } diff --git a/gno.land/pkg/gnoweb/frontend/css/tx.config.js b/gno.land/pkg/gnoweb/frontend/css/tx.config.js index f922740b19c..b6ccb5aad00 100644 --- a/gno.land/pkg/gnoweb/frontend/css/tx.config.js +++ b/gno.land/pkg/gnoweb/frontend/css/tx.config.js @@ -49,10 +49,7 @@ export default { inherit: "inherit", }, fontFamily: { - mono: [ - "Roboto", - 'Menlo, Consolas, "Ubuntu Mono", "Roboto Mono", "DejaVu Sans Mono", monospace;', - ], + mono: ["Roboto", 'Menlo, Consolas, "Ubuntu Mono", "Roboto Mono", "DejaVu Sans Mono", monospace;'], interVar: [ '"Inter var"', '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif', @@ -72,13 +69,6 @@ export default { 900: `${pxToRem(42)}rem`, }, }, - safelist: [ - "realm-view", - { pattern: /^realm-view/ }, - "link-external", - "link-internal", - "link-tx", - "tooltip", - ], + safelist: ["md-view", { pattern: /^md-view/ }, "link-external", "link-internal", "link-tx", "tooltip"], plugins: [], }; diff --git a/gno.land/pkg/gnoweb/handler.go b/gno.land/pkg/gnoweb/handler.go index 6c44d3bfc70..8641f7af411 100644 --- a/gno.land/pkg/gnoweb/handler.go +++ b/gno.land/pkg/gnoweb/handler.go @@ -179,17 +179,16 @@ func (h *WebHandler) GetRealmView(gnourl *weburl.GnoURL) (int, *components.View) meta, err := h.Client.RenderRealm(&content, gnourl) if err != nil { if errors.Is(err, ErrRenderNotDeclared) { - return http.StatusOK, components.StatusNoRenderComponent(gnourl.Path) + // If no render is declared, show directory view instead + return h.GetDirectoryView(gnourl) + } else { + h.Logger.Error("unable to render realm", "error", err, "path", gnourl.EncodeURL()) + return GetClientErrorStatusPage(gnourl, err) } - - h.Logger.Error("unable to render realm", "error", err, "path", gnourl.EncodeURL()) - return GetClientErrorStatusPage(gnourl, err) } return http.StatusOK, components.RealmView(components.RealmData{ - TocItems: &components.RealmTOCData{ - Items: meta.Toc.Items, - }, + TocItems: &components.RealmTOCData{Items: meta.Toc.Items}, // NOTE: `RenderRealm` should ensure that HTML content is // sanitized before rendering @@ -271,15 +270,39 @@ func (h *WebHandler) GetSourceView(gnourl *weburl.GnoURL) (int, *components.View } var source bytes.Buffer - meta, err := h.Client.SourceFile(&source, pkgPath, fileName, false) - if err != nil { - h.Logger.Error("unable to get source file", "file", fileName, "error", err) - return GetClientErrorStatusPage(gnourl, err) + var meta *FileMeta + var mode components.DisplayMode + + // Check if file is markdown and plain mode is not requested + isMarkdown := strings.HasSuffix(strings.ToLower(fileName), ".md") + if isMarkdown && !gnourl.WebQuery.Has("plain") { + // For markdown files, get metadata first + mode = components.ModeMarkdown + meta, err = h.Client.SourceFile(nil, pkgPath, fileName, false) + if err != nil { + h.Logger.Error("unable to get source file metadata", "file", fileName, "error", err) + return GetClientErrorStatusPage(gnourl, err) + } + // Then render markdown content + _, err = h.Client.RenderMd(&source, gnourl, fileName) + if err != nil { + h.Logger.Error("unable to render markdown", "file", fileName, "error", err) + return GetClientErrorStatusPage(gnourl, err) + } + } else { + // For code files or markdown files in plain mode, get both metadata and content in one call + mode = components.ModeCode + meta, err = h.Client.SourceFile(&source, pkgPath, fileName, false) + if err != nil { + h.Logger.Error("unable to get source file", "file", fileName, "error", err) + return GetClientErrorStatusPage(gnourl, err) + } } fileSizeStr := fmt.Sprintf("%.2f Kb", meta.SizeKb) return http.StatusOK, components.SourceView(components.SourceData{ PkgPath: gnourl.Path, + Mode: mode, Files: files, FileName: fileName, FileCounter: len(files), @@ -287,6 +310,7 @@ func (h *WebHandler) GetSourceView(gnourl *weburl.GnoURL) (int, *components.View FileSize: fileSizeStr, FileDownload: gnourl.Path + "$download&file=" + fileName, FileSource: components.NewReaderComponent(&source), + IsMarkdown: isMarkdown, }) } @@ -303,10 +327,27 @@ func (h *WebHandler) GetDirectoryView(gnourl *weburl.GnoURL) (int, *components.V return http.StatusOK, components.StatusErrorComponent("no files available") } + // Check if README.md exists and render it + var readmeContent components.Component + for _, f := range files { + if strings.EqualFold(f, "README.md") { + var content bytes.Buffer + _, err := h.Client.RenderMd(&content, gnourl, "README.md") + if err != nil { + h.Logger.Error("unable to render README.md", "path", gnourl.Path, "error", err) + // Continue without README if there's an error + break + } + readmeContent = components.NewReaderComponent(&content) + break + } + } + return http.StatusOK, components.DirectoryView(components.DirData{ - PkgPath: gnourl.Path, - Files: files, - FileCounter: len(files), + PkgPath: gnourl.Path, + Files: files, + FileCounter: len(files), + ComponentContent: readmeContent, }) } diff --git a/gno.land/pkg/gnoweb/handler_test.go b/gno.land/pkg/gnoweb/handler_test.go index 8191a1ce499..3c02284556d 100644 --- a/gno.land/pkg/gnoweb/handler_test.go +++ b/gno.land/pkg/gnoweb/handler_test.go @@ -1,6 +1,7 @@ package gnoweb_test import ( + "context" "log/slog" "net/http" "net/http/httptest" @@ -8,6 +9,8 @@ import ( "testing" "github.com/gnolang/gno/gno.land/pkg/gnoweb" + "github.com/gnolang/gno/gno.land/pkg/gnoweb/components" + "github.com/gnolang/gno/gno.land/pkg/gnoweb/weburl" "github.com/gnolang/gno/gnovm/pkg/doc" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -22,6 +25,23 @@ func (t *testingLogger) Write(b []byte) (n int, err error) { return len(b), nil } +func (t *testingLogger) Enabled(ctx context.Context, level slog.Level) bool { + return true +} + +func (t *testingLogger) Handle(ctx context.Context, r slog.Record) error { + t.T.Log(r.Message) + return nil +} + +func (t *testingLogger) WithAttrs(attrs []slog.Attr) slog.Handler { + return t +} + +func (t *testingLogger) WithGroup(name string) slog.Handler { + return t +} + // TestWebHandler_Get tests the Get method of WebHandler using table-driven tests. func TestWebHandler_Get(t *testing.T) { t.Parallel() @@ -129,6 +149,7 @@ func TestWebHandler_NoRender(t *testing.T) { Files: map[string]string{ "render.gno": `package main; func init() {}`, "gno.mod": `module gno.land/r/mock/path`, + "README.md": `# Mock Package\n\nThis is a mock package.`, }, } @@ -148,8 +169,8 @@ func TestWebHandler_NoRender(t *testing.T) { handler.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code, "unexpected status code") - expectedBody := "This realm does not implement a Render() function." - assert.Contains(t, rr.Body.String(), expectedBody, "rendered body should contain: %q", expectedBody) + assert.Contains(t, rr.Body.String(), "README.md", "should display directory view with README.md") + assert.Contains(t, rr.Body.String(), "Mock Package", "should display README.md content") } // TestWebHandler_GetSourceDownload tests the source file download functionality @@ -228,3 +249,238 @@ func TestWebHandler_GetSourceDownload(t *testing.T) { }) } } + +// TestWebHandler_GetMarkdown tests markdown file rendering with and without plain mode +func TestWebHandler_GetMarkdown(t *testing.T) { + t.Parallel() + + mockPackage := &gnoweb.MockPackage{ + Domain: "example.com", + Path: "/r/mock/path", + Files: map[string]string{ + "README.md": `# Test Markdown + +This is a test markdown file. + +- Item 1 +- Item 2`, + }, + } + + webclient := gnoweb.NewMockWebClient(mockPackage) + config := gnoweb.WebHandlerConfig{ + WebClient: webclient, + } + + cases := []struct { + Path string + Status int + Contain string + }{ + { + Path: "/r/mock/path$source&file=README.md&plain", + Status: http.StatusOK, + Contain: "# Test Markdown", // Plain mode should show raw markdown + }, + { + Path: "/r/mock/path$source&file=README.md", + Status: http.StatusOK, + Contain: "%s", source) + return nil +} + +// WriteFormatterCSS simulates writing CSS for syntax highlighting. +func (m *MockWebClient) WriteFormatterCSS(w io.Writer) error { + // For testing, we just write a minimal CSS + _, err := w.Write([]byte(".chroma- { background-color: #f8f8f8; }")) + return err +} + func pkgHasRender(pkg *MockPackage) bool { if len(pkg.Functions) == 0 { return false @@ -114,3 +175,13 @@ func pkgHasRender(pkg *MockPackage) bool { return false } + +// ABCIResponse is a mock type for testing +type ABCIResponse struct { + Data []byte +} + +// ABCIQueryResponse is a mock type for testing +type ABCIQueryResponse struct { + Response ABCIResponse +}