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" }} -{{ render .ComponentContent }} +{{ render .ComponentContent }} {{ end }} diff --git a/gno.land/pkg/gnoweb/components/ui/icons.html b/gno.land/pkg/gnoweb/components/ui/icons.html index c1185779e50..eae1f41b225 100644 --- a/gno.land/pkg/gnoweb/components/ui/icons.html +++ b/gno.land/pkg/gnoweb/components/ui/icons.html @@ -1,226 +1,175 @@ {{ define "ui/icons" }} - + + + + + + Readme File + + + {{ end }} diff --git a/gno.land/pkg/gnoweb/components/view_directory.go b/gno.land/pkg/gnoweb/components/view_directory.go index a105291a4dd..67ce2cc3aaa 100644 --- a/gno.land/pkg/gnoweb/components/view_directory.go +++ b/gno.land/pkg/gnoweb/components/view_directory.go @@ -3,11 +3,24 @@ package components const DirectoryViewType ViewType = "dir-view" type DirData struct { - PkgPath string - Files []string - FileCounter int + PkgPath string + Files []string + FileCounter int + ComponentContent Component +} + +type directoryViewParams struct { + DirData + Article ArticleData } func DirectoryView(data DirData) *View { - return NewTemplateView(DirectoryViewType, "renderDir", data) + viewData := directoryViewParams{ + DirData: data, + Article: ArticleData{ + ComponentContent: data.ComponentContent, + Classes: "md-view bg-light rounded px-4", + }, + } + return NewTemplateView(DirectoryViewType, "renderDir", viewData) } diff --git a/gno.land/pkg/gnoweb/components/view_realm.go b/gno.land/pkg/gnoweb/components/view_realm.go index 49372244fd4..8e2f86b2a21 100644 --- a/gno.land/pkg/gnoweb/components/view_realm.go +++ b/gno.land/pkg/gnoweb/components/view_realm.go @@ -4,7 +4,7 @@ import ( "github.com/gnolang/gno/gno.land/pkg/gnoweb/markdown" ) -const RealmViewType ViewType = "realm-view" +const RealmViewType ViewType = "md-view" type RealmTOCData struct { Items []*markdown.TocItem @@ -29,7 +29,7 @@ func RealmView(data RealmData) *View { viewData := realmViewParams{ Article: ArticleData{ ComponentContent: data.ComponentContent, - Classes: "realm-view lg:row-start-1", + Classes: "md-view lg:row-start-1", }, ComponentTOC: NewTemplateComponent("ui/toc_realm", data.TocItems), } diff --git a/gno.land/pkg/gnoweb/components/view_source.go b/gno.land/pkg/gnoweb/components/view_source.go index ea53c3fa8a2..e0004ed8689 100644 --- a/gno.land/pkg/gnoweb/components/view_source.go +++ b/gno.land/pkg/gnoweb/components/view_source.go @@ -1,9 +1,21 @@ package components +import ( + "fmt" +) + const SourceViewType ViewType = "source-view" +type DisplayMode int + +const ( + ModeCode DisplayMode = iota + ModeMarkdown +) + type SourceData struct { PkgPath string + Mode DisplayMode Files []string FileName string FileSize string @@ -11,6 +23,7 @@ type SourceData struct { FileCounter int FileDownload string FileSource Component + IsMarkdown bool } type SourceTocData struct { @@ -33,6 +46,8 @@ type sourceViewParams struct { PkgPath string FileDownload string ComponentTOC Component + Mode DisplayMode + IsMarkdown bool } func SourceView(data SourceData) *View { @@ -49,11 +64,17 @@ func SourceView(data SourceData) *View { } toc := NewTemplateComponent("ui/toc_generic", tocData) - content := NewTemplateComponent("ui/code_wrapper", data.FileSource) + var content Component + if data.Mode == ModeCode { + content = NewTemplateComponent("ui/code_wrapper", data.FileSource) + } else { + content = data.FileSource + } viewData := sourceViewParams{ Article: ArticleData{ ComponentContent: content, - Classes: "source-view col-span-1 lg:col-span-7 lg:row-start-2 pb-24 text-gray-900", + Classes: fmt.Sprintf("%s col-span-1 lg:col-span-7 lg:row-start-2 mb-24 text-gray-900", + map[DisplayMode]string{ModeCode: "source-view", ModeMarkdown: "md-view bg-light rounded px-4"}[data.Mode]), }, ComponentTOC: toc, Files: data.Files, @@ -63,6 +84,8 @@ func SourceView(data SourceData) *View { FileCounter: data.FileCounter, PkgPath: data.PkgPath, FileDownload: data.FileDownload, + Mode: data.Mode, + IsMarkdown: data.IsMarkdown, } return NewTemplateView(SourceViewType, "renderSource", viewData) diff --git a/gno.land/pkg/gnoweb/components/views/directory.html b/gno.land/pkg/gnoweb/components/views/directory.html index 9aedd658def..22bad7191c7 100644 --- a/gno.land/pkg/gnoweb/components/views/directory.html +++ b/gno.land/pkg/gnoweb/components/views/directory.html @@ -1,32 +1,40 @@ -{{ define "renderDir" }} - {{ $pkgpath := .PkgPath }} -
-
-
-

{{ $pkgpath }}

-
-
- Directory · {{ .FileCounter }} Files -
-
- -
- -
-
-{{ end }} +{{ define "renderDir" }} {{ $pkgpath := .PkgPath }} +
+
+
+

{{ $pkgpath }}

+
+
+ Directory · {{ .FileCounter }} Files +
+
+
+ +
+
+{{ if .Article.ComponentContent }} +
+ + + + + README.md + + Open +
+{{ template "layout/article" .Article }} {{ end }} {{ end }} diff --git a/gno.land/pkg/gnoweb/components/views/source.html b/gno.land/pkg/gnoweb/components/views/source.html index 2a194f4bc6a..c4fdeb40443 100644 --- a/gno.land/pkg/gnoweb/components/views/source.html +++ b/gno.land/pkg/gnoweb/components/views/source.html @@ -1,43 +1,45 @@ {{ define "renderSource" }} - - {{ with render .ComponentTOC }} - {{ template "layout/aside" . }} - {{ end }} + +{{ with render .ComponentTOC }} {{ template "layout/aside" . }} {{ end }} + +
+
+

{{ .FileName }}

+
+
+ {{ .FileSize }} · {{ .FileLines }} lines +
+ {{ if .IsMarkdown }} {{ if eq .Mode 0 }} + + + + + + + {{ else }} + + + + + + + {{ end }} {{ end }} {{ if not .IsMarkdown }} + + {{ end }} - -
-
-

{{ .FileName }}

+ + + + + +
-
- {{ .FileSize }} · {{ .FileLines }} lines -
- - - - - - - -
-
-
+
+
- - {{ template "layout/article" .Article }} -{{ end }} + +{{ template "layout/article" .Article }} {{ end }} diff --git a/gno.land/pkg/gnoweb/components/views/status.html b/gno.land/pkg/gnoweb/components/views/status.html index fb36eff6956..81ce9d43abf 100644 --- a/gno.land/pkg/gnoweb/components/views/status.html +++ b/gno.land/pkg/gnoweb/components/views/status.html @@ -1,5 +1,5 @@ {{ define "status" }} -
+
gno land

{{ .Title }}

{{ .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: "

Test Markdown

", // Rendered markdown should contain HTML + }, + { + Path: "/r/mock/path$source&file=nonexistent.md", + Status: http.StatusNotFound, + Contain: "not found", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(strings.TrimPrefix(tc.Path, "/"), func(t *testing.T) { + t.Parallel() + t.Logf("input: %+v", tc) + + logger := slog.New(slog.NewTextHandler(&testingLogger{t}, &slog.HandlerOptions{})) + handler, err := gnoweb.NewWebHandler(logger, config) + require.NoError(t, err) + + req, err := http.NewRequest(http.MethodGet, tc.Path, nil) + require.NoError(t, err) + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + assert.Equal(t, tc.Status, rr.Code) + assert.Contains(t, rr.Body.String(), tc.Contain) + }) + } +} + +func TestWebHandler_GetDirectoryView(t *testing.T) { + t.Parallel() + + // Set up test cases + testCases := []struct { + name string + files map[string]string + hasReadme bool + readmeError bool + expectedStatus int + expectedType components.ViewType + }{ + { + name: "with README.md", + files: map[string]string{ + "render.gno": `package main; func Render(path string) string { return "one more time" }`, + "README.md": `# Test README\n\nThis is a test README file.`, + "gno.mod": `module example.com/r/mock/path`, + }, + hasReadme: true, + readmeError: false, + expectedStatus: http.StatusOK, + expectedType: components.DirectoryViewType, + }, + { + name: "with README.md that fails to render", + files: map[string]string{ + "render.gno": `package main; func Render(path string) string { return "one more time" }`, + "README.md": `# Invalid README`, // This will cause a render error + "gno.mod": `module example.com/r/mock/path`, + }, + hasReadme: true, + readmeError: true, + expectedStatus: http.StatusOK, + expectedType: components.DirectoryViewType, + }, + { + name: "without README.md", + files: map[string]string{ + "render.gno": `package main; func Render(path string) string { return "one more time" }`, + "gno.mod": `module example.com/r/mock/path`, + }, + hasReadme: false, + readmeError: false, + expectedStatus: http.StatusOK, + expectedType: components.DirectoryViewType, + }, + { + name: "no files", + files: map[string]string{}, + hasReadme: false, + readmeError: false, + expectedStatus: http.StatusOK, + expectedType: components.StatusViewType, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + // Create mock package + mockPackage := &gnoweb.MockPackage{ + Domain: "example.com", + Path: "/r/mock/path", + Files: tc.files, + } + + // Create mock web client + webclient := gnoweb.NewMockWebClient(mockPackage) + + // Create handler + handler, err := gnoweb.NewWebHandler(slog.New(&testingLogger{t}), gnoweb.WebHandlerConfig{ + Meta: gnoweb.StaticMetadata{ + Domain: "example.com", + AssetsPath: "/assets", + ChromaPath: "/chroma", + ChainId: "test-chain", + }, + WebClient: webclient, + }) + require.NoError(t, err) + + // Create test request + req := httptest.NewRequest(http.MethodGet, "/r/mock/path", nil) + gnourl, err := weburl.ParseFromURL(req.URL) + require.NoError(t, err) + + // Call GetDirectoryView + status, view := handler.GetDirectoryView(gnourl) + + // Verify status + assert.Equal(t, tc.expectedStatus, status) + + // Verify view is not nil + assert.NotNil(t, view) + + // Verify view type + assert.Equal(t, tc.expectedType, view.Type) + + // If we expect a README, verify the content + if tc.hasReadme { + if !tc.readmeError { + // When README is present and renders successfully + assert.NotNil(t, view.Component) + } else { + // When README rendering fails + assert.NotNil(t, view) + } + } else if tc.expectedType == components.DirectoryViewType { + // When no README is expected but we have a directory view + assert.NotNil(t, view.Component) + } + }) + } +} + +func TestWebHandler_GetDirectoryView_README_RenderError(t *testing.T) { + t.Parallel() + + // Create a mock package with a README.md that will cause a render error + mockPackage := &gnoweb.MockPackage{ + Domain: "example.com", + Path: "/r/mock/path", + Files: map[string]string{ + "render.gno": `package main; func Render(path string) string { return "one more time" }`, + "README.md": `# Invalid README`, // This will cause a render error + "gno.mod": `module example.com/r/mock/path`, + }, + } + + // Create mock web client + webclient := gnoweb.NewMockWebClient(mockPackage) + + // Create handler with a logger + logger := slog.New(&testingLogger{t}) + logger = logger.With(slog.String("test", "TestWebHandler_GetDirectoryView_README_RenderError")) + + handler, err := gnoweb.NewWebHandler(logger, gnoweb.WebHandlerConfig{ + Meta: gnoweb.StaticMetadata{ + Domain: "example.com", + AssetsPath: "/assets", + ChromaPath: "/chroma", + ChainId: "test-chain", + }, + WebClient: webclient, + }) + require.NoError(t, err) + + // Create test request + req := httptest.NewRequest(http.MethodGet, "/r/mock/path", nil) + gnourl, err := weburl.ParseFromURL(req.URL) + require.NoError(t, err) + + // Call GetDirectoryView + status, view := handler.GetDirectoryView(gnourl) + + // Verify status is OK even with render error + assert.Equal(t, http.StatusOK, status) + + // Verify view is not nil + assert.NotNil(t, view) + + // Verify view type is DirectoryView + assert.Equal(t, components.DirectoryViewType, view.Type) + + // Verify that the view still contains the directory listing + assert.NotNil(t, view.Component) +} diff --git a/gno.land/pkg/gnoweb/public/styles.css b/gno.land/pkg/gnoweb/public/styles.css index d66bf70b012..d12d272f079 100644 --- a/gno.land/pkg/gnoweb/public/styles.css +++ b/gno.land/pkg/gnoweb/public/styles.css @@ -1,3 +1,3 @@ @font-face{font-family:Roboto;font-style:normal;font-weight:900;font-display:swap;src:url(fonts/roboto/roboto-mono-normal.woff2) format("woff2"),url(fonts/roboto/roboto-mono-normal.woff) format("woff")}@font-face{font-family:Inter var;font-weight:100 900;font-display:block;font-variant:normal;font-style:oblique 0deg 10deg;src:url(fonts/intervar/Intervar.woff2) format("woff2")}*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.14 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #bdbdbd}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#7c7c7c}input::placeholder,textarea::placeholder{opacity:1;color:#7c7c7c}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}html{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity));font-family: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;font-size:1rem;--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity));font-feature-settings:"kern" on,"liga" on,"calt" on,"zero" on;-webkit-font-feature-settings:"kern" on,"liga" on,"calt" on,"zero" on;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;-moz-osx-font-smoothing:grayscale;font-smoothing:antialiased;font-variant-ligatures:contextual common-ligatures;font-kerning:normal;text-rendering:optimizeLegibility}svg{max-height:100%;max-width:100%}form{margin-top:0;margin-bottom:0}.realm-view{overflow-wrap:break-word;padding-top:1.5rem;font-size:1rem}@media (min-width:51.25rem){.realm-view{padding-top:2.5rem}}.realm-view>:first-child{margin-top:0!important}.realm-view a{font-weight:500;--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.realm-view a:hover{text-decoration-line:underline}.realm-view h1,.realm-view h2,.realm-view h3,.realm-view h4{margin-top:3rem;line-height:1.25;--tw-text-opacity:1;color:rgb(8 8 9/var(--tw-text-opacity))}.realm-view h2,.realm-view h2 *{font-weight:700}.realm-view h3,.realm-view h3 *,.realm-view h4,.realm-view h4 *{font-weight:600}.realm-view h1+h2,.realm-view h2+h3,.realm-view h3+h4{margin-top:1rem}.realm-view h1{font-size:2.375rem;font-weight:700}.realm-view h2{font-size:1.5rem}.realm-view h3{margin-top:2.5rem;font-size:1.25rem}.realm-view h3,.realm-view h4{--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.realm-view h4{margin-top:1.5rem;margin-bottom:1.5rem;font-size:1.125rem;font-weight:500}.realm-view p{margin-top:1.25rem;margin-bottom:1.25rem}.realm-view strong{font-weight:700;--tw-text-opacity:1;color:rgb(8 8 9/var(--tw-text-opacity))}.realm-view strong *{font-weight:700}.realm-view em{font-style:oblique 14deg}.realm-view blockquote{margin-top:1rem;margin-bottom:1rem;border-left-width:4px;--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity));padding-left:1rem;--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity));font-style:oblique 14deg}.realm-view ol,.realm-view ul{margin-top:1.5rem;margin-bottom:1.5rem;padding-left:1rem}.realm-view ol li,.realm-view ul li{margin-bottom:.5rem}.realm-view img{margin-top:2rem;margin-bottom:2rem;max-width:100%}.realm-view figure{margin-top:1.5rem;margin-bottom:1.5rem;text-align:center}.realm-view figcaption{font-size:.875rem;--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.realm-view :not(pre)>code{border-radius:.25rem;background-color:rgb(226 226 226/var(--tw-bg-opacity));padding:.125rem .25rem;font-size:.96em}.realm-view :not(pre)>code,.realm-view pre{--tw-bg-opacity:1;font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;}.realm-view pre{overflow-x:auto;border-radius:.375rem;background-color:rgb(240 240 240/var(--tw-bg-opacity));padding:1rem}.realm-view hr{margin-top:2.5rem;margin-bottom:2.5rem;border-top-width:1px;--tw-border-opacity:1;border-color:rgb(226 226 226/var(--tw-border-opacity))}.realm-view table{margin-top:2rem;margin-bottom:2rem;display:block;width:100%;max-width:100%;border-collapse:collapse;overflow-x:auto}.realm-view td,.realm-view th{white-space:normal;overflow-wrap:break-word;border-width:1px;padding:.5rem 1rem}.realm-view th{--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity));font-weight:700}.realm-view caption{margin-top:.5rem;text-align:left;font-size:.875rem;--tw-text-opacity:1}.realm-view caption,.realm-view q{color:rgb(84 89 93/var(--tw-text-opacity))}.realm-view q{margin-top:1.5rem;margin-bottom:1.5rem;border-left-width:4px;--tw-border-opacity:1;border-left-color:rgb(153 153 153/var(--tw-border-opacity));padding-left:1rem;--tw-text-opacity:1;font-style:oblique 14deg;quotes:""" """" """ "''"}.realm-view q:after,.realm-view q:before{margin-right:.25rem;font-size:1.5rem;--tw-text-opacity:1;color:rgb(153 153 153/var(--tw-text-opacity));content:open-quote;vertical-align:-.4rem}.realm-view q:after{content:close-quote}.realm-view q:before{content:open-quote}.realm-view q:after{content:close-quote}.realm-view ol ol,.realm-view ol ul,.realm-view ul ol,.realm-view ul ul{margin-top:.5rem;margin-bottom:.5rem;padding-left:1rem}.realm-view ul{list-style-type:disc}.realm-view ol{list-style-type:decimal}.realm-view abbr[title]{cursor:help;border-bottom-width:1px;border-style:dotted}.realm-view details{margin-top:1.25rem;margin-bottom:1.25rem}.realm-view summary{cursor:pointer;font-weight:700}.realm-view a code{color:inherit}.realm-view video{margin-top:2rem;margin-bottom:2rem;max-width:100%}.realm-view math{font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;}.realm-view small{font-size:.875rem}.realm-view del{text-decoration-line:line-through}.realm-view sub{vertical-align:sub;font-size:.75rem}.realm-view sup{vertical-align:super;font-size:.75rem}.realm-view button,.realm-view input{border-width:1px;--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity));padding:.5rem 1rem}main :is(h1,h2,h3,h4){scroll-margin-top:6rem}::-moz-selection{--tw-bg-opacity:1;background-color:rgb(34 108 87/var(--tw-bg-opacity));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}::selection{--tw-bg-opacity:1;background-color:rgb(34 108 87/var(--tw-bg-opacity));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.realm-view .gno-columns{display:flex;flex-wrap:wrap;-moz-column-gap:2.5rem;column-gap:2.5rem}@media (min-width:85.375rem){.realm-view .gno-columns{-moz-column-gap:3rem;column-gap:3rem}}.realm-view .gno-columns>*{flex-shrink:1;flex-grow:1;flex-basis:13rem}@media (min-width:51.25rem){.realm-view .gno-columns>*{flex-basis:11rem}}.sidemenu .peer:checked+label>svg{--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.toc-expend-btn:has(#toc-expend:checked)+nav{display:block}.toc-expend-btn:has(#toc-expend:checked) .toc-expend-btn_ico{--tw-rotate:180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.main-header:has(#sidemenu-docs:checked)+main #sidebar #sidebar-docs,.main-header:has(#sidemenu-meta:checked)+main #sidebar #sidebar-meta,.main-header:has(#sidemenu-source:checked)+main #sidebar #sidebar-source,.main-header:has(#sidemenu-summary:checked)+main #sidebar #sidebar-summary{display:block}@media (min-width:40rem){:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked)) .main-navigation,:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked))+main .realm-view{grid-column:span 6/span 6}:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked)) .sidemenu,:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked))+main #sidebar{grid-column:span 4/span 4}}:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked))+main #sidebar:before{position:absolute;top:0;left:-1.75rem;z-index:-1;display:block;height:100%;width:50vw;--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity));--tw-content:"";content:var(--tw-content)}.tooltip{position:relative;display:inline}.tooltip:after{content:attr(data-tooltip);visibility:hidden;position:absolute;top:100%;left:-.25rem;z-index:9999;width:-moz-fit-content;width:fit-content;min-width:8rem;max-width:12rem;--tw-scale-x:0;--tw-scale-y:0;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(226 226 226/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity));padding:.25rem .5rem;text-align:center;font-size:.875rem;font-weight:400;--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity));opacity:0;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.tooltip:after,.tooltip:hover:after{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.tooltip:hover:after{visibility:visible;--tw-scale-x:1;--tw-scale-y:1;opacity:1;transition-delay:.3s}main :is(.source-code)>pre{overflow:scroll;border-radius:.375rem;--tw-bg-opacity:1!important;background-color:rgb(255 255 255/var(--tw-bg-opacity))!important;padding:1rem .25rem;font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;;font-size:.875rem}@media (min-width:40rem){main :is(.source-code)>pre{padding:2rem .75rem;font-size:1rem}}main .realm-view>pre a:hover{text-decoration-line:none}main :is(.realm-view,.source-code)>pre .chroma-ln:target{background-color:transparent!important}main :is(.realm-view,.source-code)>pre .chroma-line:has(.chroma-ln:target),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),main :is(.realm-view,.source-code)>pre .chroma-line:has(.chroma-lnlinks:hover) .chroma-cl{border-radius:.375rem;--tw-bg-opacity:1!important;background-color:rgb(226 226 226/var(--tw-bg-opacity))!important}main :is(.realm-view,.source-code)>pre .chroma-ln{scroll-margin-top:6rem}.dev-mode .toc-expend-btn{cursor:pointer;border-width:1px;--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity))}.dev-mode .toc-expend-btn:hover{--tw-bg-opacity:1;background-color:rgb(240 240 240/var(--tw-bg-opacity))}@media (min-width:51.25rem){.dev-mode .toc-expend-btn{border-style:none;background-color:transparent}}.dev-mode #sidebar-summary{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}@media (min-width:51.25rem){.dev-mode #sidebar-summary{background-color:transparent}}.dev-mode .toc-nav{font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.invisible{visibility:hidden}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.bottom-1{bottom:.25rem}.left-0{left:0}.right-0{right:0}.right-2{right:.5rem}.right-3{right:.75rem}.right-px{right:1px}.top-0{top:0}.top-1\/2{top:50%}.top-14{top:3.5rem}.top-2{top:.5rem}.top-px{top:1px}.z-1{z-index:1}.z-max{z-index:9999}.order-2{order:2}.order-3{order:3}.col-span-1{grid-column:span 1/span 1}.col-span-3{grid-column:span 3/span 3}.col-span-7{grid-column:span 7/span 7}.row-span-1{grid-row:span 1/span 1}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-8{margin-bottom:2rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-10{margin-right:2.5rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-3{height:.75rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-\[calc\(100\%-2px\)\]{height:calc(100% - 2px)}.h-auto{height:auto}.h-full{height:100%}.max-h-screen{max-height:100vh}.min-h-full{min-height:100%}.min-h-screen{min-height:100vh}.w-0{width:0}.w-10{width:2.5rem}.w-3{width:.75rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-72{width:18rem}.w-full{width:100%}.min-w-0{min-width:0}.min-w-2{min-width:.5rem}.min-w-4{min-width:1rem}.min-w-48{min-width:12rem}.max-w-screen-max{max-width:98.75rem}.flex-auto{flex:1 1 auto}.flex-shrink-0,.shrink-0{flex-shrink:0}.flex-grow-0{flex-grow:0}.grow-\[2\]{flex-grow:2}.basis-auto{flex-basis:auto}.-translate-x-full{--tw-translate-x:-100%}.-translate-x-full,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.cursor-pointer{cursor:pointer}.cursor-text{cursor:text}.list-none{list-style-type:none}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-flow-dense{grid-auto-flow:dense}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-10{grid-template-columns:repeat(10,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-baseline{align-items:baseline}.items-stretch{align-items:stretch}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-12{gap:3rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-8{gap:2rem}.gap-x-20{-moz-column-gap:5rem;column-gap:5rem}.gap-x-3{-moz-column-gap:.75rem;column-gap:.75rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-1{row-gap:.25rem}.gap-y-2{row-gap:.5rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-scroll{overflow:scroll}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.rounded{border-radius:.375rem}.rounded-full{border-radius:9999px}.rounded-sm{border-radius:.25rem}.rounded-r{border-top-right-radius:.375rem;border-bottom-right-radius:.375rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-gray-100{--tw-border-opacity:1;border-color:rgb(226 226 226/var(--tw-border-opacity))}.border-transparent{border-color:transparent}.bg-current{background-color:currentColor}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(124 124 124/var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(240 240 240/var(--tw-bg-opacity))}.bg-light{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-transparent{background-color:transparent}.p-0{padding:0}.p-0\.5{padding:.125rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-10{padding-left:2.5rem;padding-right:2.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-px{padding-top:1px;padding-bottom:1px}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-6{padding-bottom:1.5rem}.pb-8{padding-bottom:2rem}.pl-4{padding-left:1rem}.pr-10{padding-right:2.5rem}.pt-0\.5{padding-top:.125rem}.pt-2{padding-top:.5rem}.text-center{text-align:center}.font-mono{font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;}.text-100{font-size:.875rem}.text-200{font-size:1rem}.text-300{font-size:1.125rem}.text-400{font-size:1.25rem}.text-50{font-size:.75rem}.text-600{font-size:1.5rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.capitalize{text-transform:capitalize}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.text-gray-100{--tw-text-opacity:1;color:rgb(226 226 226/var(--tw-text-opacity))}.text-gray-300{--tw-text-opacity:1;color:rgb(153 153 153/var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity:1;color:rgb(124 124 124/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.text-gray-800{--tw-text-opacity:1;color:rgb(19 19 19/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(8 8 9/var(--tw-text-opacity))}.text-green-600{--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.text-light{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.opacity-0{opacity:0}.outline-none{outline:2px solid transparent;outline-offset:2px}.text-stroke{-webkit-text-stroke:currentColor;-webkit-text-stroke-width:.6px}.no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}.link-external{font-size:.7em}.link-internal{font-weight:400}.link-internal,.link-tx{font-size:.8em}:is(.link-external,.link-internal,.link-tx){display:inline;vertical-align:text-top}.realm-view a>span:first-of-type{margin-left:.125rem}.field-content{field-sizing:content}@supports not (field-sizing:content){.focus-no-field-sizing\:w-20:focus{width:5rem!important}}.\*\:pl-0>*{padding-left:0}.before\:px-\[0\.18rem\]:before{content:var(--tw-content);padding-left:.18rem;padding-right:.18rem}.before\:pt-0\.5:before{content:var(--tw-content);padding-top:.125rem}.before\:leading-normal:before{content:var(--tw-content);line-height:1.5}.before\:text-gray-400:before{content:var(--tw-content);--tw-text-opacity:1;color:rgb(124 124 124/var(--tw-text-opacity))}.before\:content-\[\'\&\'\]:before{--tw-content:"&";content:var(--tw-content)}.before\:content-\[\'\/\'\]:before{--tw-content:"/";content:var(--tw-content)}.before\:content-\[\'\:\'\]:before{--tw-content:":";content:var(--tw-content)}.before\:content-\[\'\?\'\]:before{--tw-content:"?";content:var(--tw-content)}.before\:content-\[\'open\'\]:before{--tw-content:"open";content:var(--tw-content)}.after\:pointer-events-none:after{content:var(--tw-content);pointer-events:none}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:bottom-0:after{content:var(--tw-content);bottom:0}.after\:left-0:after{content:var(--tw-content);left:0}.after\:top-0:after{content:var(--tw-content);top:0}.after\:block:after{content:var(--tw-content);display:block}.after\:h-1:after{content:var(--tw-content);height:.25rem}.after\:h-full:after{content:var(--tw-content);height:100%}.after\:w-full:after{content:var(--tw-content);width:100%}.after\:rounded-t-sm:after{content:var(--tw-content);border-top-left-radius:.25rem;border-top-right-radius:.25rem}.after\:bg-gray-100:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity))}.after\:bg-green-600:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(34 108 87/var(--tw-bg-opacity))}.first\:mt-8:first-child{margin-top:2rem}.first\:border-t:first-child{border-top-width:1px}.focus-within\:border-gray-400:focus-within{--tw-border-opacity:1;border-color:rgb(124 124 124/var(--tw-border-opacity))}.hover\:border-gray-300:hover{--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}.hover\:border-gray-400:hover{--tw-border-opacity:1;border-color:rgb(124 124 124/var(--tw-border-opacity))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(240 240 240/var(--tw-bg-opacity))}.hover\:bg-green-600:hover{--tw-bg-opacity:1;background-color:rgb(34 108 87/var(--tw-bg-opacity))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(8 8 9/var(--tw-text-opacity))}.hover\:text-green-600:hover{--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.hover\:text-light:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.hover\:underline:hover{text-decoration-line:underline}.focus\:w-min:focus{width:-moz-min-content;width:min-content}.focus\:border-gray-300:focus{--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}.focus\:border-l-gray-300:focus{--tw-border-opacity:1;border-left-color:rgb(153 153 153/var(--tw-border-opacity))}.focus\:opacity-100:focus{opacity:1}.group:last-child .group-last\:mr-0{margin-right:0}.group:hover .group-hover\:border-gray-300{--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}.group:hover .group-hover\:border-l-gray-300{--tw-border-opacity:1;border-left-color:rgb(153 153 153/var(--tw-border-opacity))}.group.is-active .group-\[\.is-active\]\:text-green-600{--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.peer:checked~.peer-checked\:visible{visibility:visible}.peer:checked~.peer-checked\:flex{display:flex}.peer:checked~.peer-checked\:hidden{display:none}.peer:checked~.peer-checked\:opacity-100{opacity:1}.peer:checked~.peer-checked\:before\:content-\[\'close\'\]:before{--tw-content:"close";content:var(--tw-content)}.peer:-moz-placeholder-shown~.peer-placeholder-shown\:hidden{display:none}.peer:placeholder-shown~.peer-placeholder-shown\:hidden{display:none}.peer:focus-within~.peer-focus-within\:hidden{display:none}.peer:focus~.peer-focus\:hidden{display:none}.has-\[ul\:empty\]\:hidden:has(ul:empty){display:none}.has-\[\:focus\]\:border-gray-300:has(:focus){--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}.has-\[\[data-role\=\'header-input-search\'\]\:focus-within\]\:border-gray-300:has([data-role=header-input-search]:focus-within){--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}.has-\[\[data-role\=\'header-input-search\'\]\:focus-within\]\:text-gray-50:has([data-role=header-input-search]:focus-within){--tw-text-opacity:1;color:rgb(240 240 240/var(--tw-text-opacity))}@media (min-width:30rem){.sm\:mr-6{margin-right:1.5rem}}@media (min-width:40rem){.md\:col-span-3{grid-column:span 3/span 3}.md\:mb-0{margin-bottom:0}.md\:flex{display:flex}.md\:h-4{height:1rem}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:gap-x-8{-moz-column-gap:2rem;column-gap:2rem}.md\:px-10{padding-left:2.5rem;padding-right:2.5rem}.md\:pb-0{padding-bottom:0}.md\:pr-8{padding-right:2rem}}@media (min-width:51.25rem){.lg\:order-2{order:2}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:col-span-7{grid-column:span 7/span 7}.lg\:row-span-2{grid-row:span 2/span 2}.lg\:row-start-1{grid-row-start:1}.lg\:mb-6{margin-bottom:1.5rem}.lg\:ml-2{margin-left:.5rem}.lg\:mt-0{margin-top:0}.lg\:mt-10{margin-top:2.5rem}.lg\:block{display:block}.lg\:grid{display:grid}.lg\:hidden{display:none}.lg\:h-4{height:1rem}.lg\:w-4{width:1rem}.lg\:grid-cols-10{grid-template-columns:repeat(10,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:justify-start{justify-content:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:gap-x-20{-moz-column-gap:5rem;column-gap:5rem}.lg\:border-none{border-style:none}.lg\:bg-transparent{background-color:transparent}.lg\:p-0{padding:0}.lg\:px-0{padding-left:0;padding-right:0}.lg\:px-2{padding-left:.5rem;padding-right:.5rem}.lg\:py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.lg\:pb-28{padding-bottom:7rem}.lg\:pt-2{padding-top:.5rem}.lg\:text-200{font-size:1rem}.lg\:text-50{font-size:.75rem}.lg\:font-semibold{font-weight:600}.lg\:first\:mt-0:first-child{margin-top:0}.lg\:hover\:bg-transparent:hover{background-color:transparent}}@media (min-width:63.75rem){.xl\:mr-3{margin-right:.75rem}.xl\:inline{display:inline}.xl\:hidden{display:none}.xl\:grid-cols-10{grid-template-columns:repeat(10,minmax(0,1fr))}.xl\:flex-row{flex-direction:row}.xl\:items-center{align-items:center}.xl\:gap-20{gap:5rem}.xl\:gap-3{gap:.75rem}.xl\:gap-6{gap:1.5rem}.xl\:pt-0{padding-top:0}.xl\:text-200{font-size:1rem}}@media (min-width:85.375rem){.xxl\:inline-block{display:inline-block}.xxl\:h-4{height:1rem}.xxl\:w-4{width:1rem}.xxl\:gap-20{gap:5rem}.xxl\:gap-x-32{-moz-column-gap:8rem;column-gap:8rem}.xxl\:pr-1{padding-right:.25rem}} \ No newline at end of file +/*! tailwindcss v3.4.14 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #bdbdbd}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#7c7c7c}input::placeholder,textarea::placeholder{opacity:1;color:#7c7c7c}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}html{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity));font-family: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;font-size:1rem;--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity));font-feature-settings:"kern" on,"liga" on,"calt" on,"zero" on;-webkit-font-feature-settings:"kern" on,"liga" on,"calt" on,"zero" on;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;-moz-osx-font-smoothing:grayscale;font-smoothing:antialiased;font-variant-ligatures:contextual common-ligatures;font-kerning:normal;text-rendering:optimizeLegibility}svg{max-height:100%;max-width:100%}form{margin-top:0;margin-bottom:0}.md-view{overflow-wrap:break-word;padding-top:1.5rem;font-size:1rem}@media (min-width:51.25rem){.md-view{padding-top:2.5rem}}.md-view>:first-child{margin-top:0!important}.md-view a{font-weight:500;--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.md-view a:hover{text-decoration-line:underline}.md-view h1,.md-view h2,.md-view h3,.md-view h4{margin-top:3rem;line-height:1.25;--tw-text-opacity:1;color:rgb(8 8 9/var(--tw-text-opacity))}.md-view h2,.md-view h2 *{font-weight:700}.md-view h3,.md-view h3 *,.md-view h4,.md-view h4 *{font-weight:600}.md-view h1+h2,.md-view h2+h3,.md-view h3+h4{margin-top:1rem}.md-view h1{font-size:2.375rem;font-weight:700}.md-view h2{font-size:1.5rem}.md-view h3{margin-top:2.5rem;font-size:1.25rem}.md-view h3,.md-view h4{--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.md-view h4{margin-top:1.5rem;margin-bottom:1.5rem;font-size:1.125rem;font-weight:500}.md-view p{margin-top:1.25rem;margin-bottom:1.25rem}.md-view strong{font-weight:700;--tw-text-opacity:1;color:rgb(8 8 9/var(--tw-text-opacity))}.md-view strong *{font-weight:700}.md-view em{font-style:oblique 14deg}.md-view blockquote{margin-top:1rem;margin-bottom:1rem;border-left-width:4px;--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity));padding-left:1rem;--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity));font-style:oblique 14deg}.md-view ol,.md-view ul{margin-top:1.5rem;margin-bottom:1.5rem;padding-left:1rem}.md-view ol li,.md-view ul li{margin-bottom:.5rem}.md-view img{margin-top:2rem;margin-bottom:2rem;max-width:100%}.md-view figure{margin-top:1.5rem;margin-bottom:1.5rem;text-align:center}.md-view figcaption{font-size:.875rem;--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.md-view :not(pre)>code{border-radius:.25rem;background-color:rgb(226 226 226/var(--tw-bg-opacity));padding:.125rem .25rem;font-size:.96em}.md-view :not(pre)>code,.md-view pre{--tw-bg-opacity:1;font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;}.md-view pre{overflow-x:auto;border-radius:.375rem;background-color:rgb(240 240 240/var(--tw-bg-opacity));padding:1rem}.md-view hr{margin-top:2.5rem;margin-bottom:2.5rem;border-top-width:1px;--tw-border-opacity:1;border-color:rgb(226 226 226/var(--tw-border-opacity))}.md-view table{margin-top:2rem;margin-bottom:2rem;display:block;width:100%;max-width:100%;border-collapse:collapse;overflow-x:auto}.md-view td,.md-view th{white-space:normal;overflow-wrap:break-word;border-width:1px;padding:.5rem 1rem}.md-view th{--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity));font-weight:700}.md-view caption{margin-top:.5rem;text-align:left;font-size:.875rem;--tw-text-opacity:1}.md-view caption,.md-view q{color:rgb(84 89 93/var(--tw-text-opacity))}.md-view q{margin-top:1.5rem;margin-bottom:1.5rem;border-left-width:4px;--tw-border-opacity:1;border-left-color:rgb(153 153 153/var(--tw-border-opacity));padding-left:1rem;--tw-text-opacity:1;font-style:oblique 14deg;quotes:""" """" """ "''"}.md-view q:after,.md-view q:before{margin-right:.25rem;font-size:1.5rem;--tw-text-opacity:1;color:rgb(153 153 153/var(--tw-text-opacity));content:open-quote;vertical-align:-.4rem}.md-view q:after{content:close-quote}.md-view q:before{content:open-quote}.md-view q:after{content:close-quote}.md-view ol ol,.md-view ol ul,.md-view ul ol,.md-view ul ul{margin-top:.5rem;margin-bottom:.5rem;padding-left:1rem}.md-view ul{list-style-type:disc}.md-view ol{list-style-type:decimal}.md-view abbr[title]{cursor:help;border-bottom-width:1px;border-style:dotted}.md-view details{margin-top:1.25rem;margin-bottom:1.25rem}.md-view summary{cursor:pointer;font-weight:700}.md-view a code{color:inherit}.md-view video{margin-top:2rem;margin-bottom:2rem;max-width:100%}.md-view math{font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;}.md-view small{font-size:.875rem}.md-view del{text-decoration-line:line-through}.md-view sub{vertical-align:sub;font-size:.75rem}.md-view sup{vertical-align:super;font-size:.75rem}.md-view button,.md-view input{border-width:1px;--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity));padding:.5rem 1rem}main :is(h1,h2,h3,h4){scroll-margin-top:6rem}::-moz-selection{--tw-bg-opacity:1;background-color:rgb(34 108 87/var(--tw-bg-opacity));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}::selection{--tw-bg-opacity:1;background-color:rgb(34 108 87/var(--tw-bg-opacity));--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.md-view .gno-columns{display:flex;flex-wrap:wrap;-moz-column-gap:2.5rem;column-gap:2.5rem}@media (min-width:85.375rem){.md-view .gno-columns{-moz-column-gap:3rem;column-gap:3rem}}.md-view .gno-columns>*{flex-shrink:1;flex-grow:1;flex-basis:13rem}@media (min-width:51.25rem){.md-view .gno-columns>*{flex-basis:11rem}}.sidemenu .peer:checked+label>svg{--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.toc-expend-btn:has(#toc-expend:checked)+nav{display:block}.toc-expend-btn:has(#toc-expend:checked) .toc-expend-btn_ico{--tw-rotate:180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.main-header:has(#sidemenu-docs:checked)+main #sidebar #sidebar-docs,.main-header:has(#sidemenu-meta:checked)+main #sidebar #sidebar-meta,.main-header:has(#sidemenu-source:checked)+main #sidebar #sidebar-source,.main-header:has(#sidemenu-summary:checked)+main #sidebar #sidebar-summary{display:block}@media (min-width:40rem){:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked)) .main-navigation,:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked))+main .md-view{grid-column:span 6/span 6}:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked)) .sidemenu,:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked))+main #sidebar{grid-column:span 4/span 4}}:is(.main-header:has(#sidemenu-source:checked),.main-header:has(#sidemenu-docs:checked),.main-header:has(#sidemenu-meta:checked))+main #sidebar:before{position:absolute;top:0;left:-1.75rem;z-index:-1;display:block;height:100%;width:50vw;--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity));--tw-content:"";content:var(--tw-content)}.tooltip{position:relative;display:inline}.tooltip:after{content:attr(data-tooltip);visibility:hidden;position:absolute;top:100%;left:-.25rem;z-index:9999;width:-moz-fit-content;width:fit-content;min-width:8rem;max-width:12rem;--tw-scale-x:0;--tw-scale-y:0;border-radius:.375rem;border-width:1px;--tw-border-opacity:1;border-color:rgb(226 226 226/var(--tw-border-opacity));--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity));padding:.25rem .5rem;text-align:center;font-size:.875rem;font-weight:400;--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity));opacity:0;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.tooltip:after,.tooltip:hover:after{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.tooltip:hover:after{visibility:visible;--tw-scale-x:1;--tw-scale-y:1;opacity:1;transition-delay:.3s}main :is(.source-code)>pre{overflow:scroll;border-radius:.375rem;--tw-bg-opacity:1!important;background-color:rgb(255 255 255/var(--tw-bg-opacity))!important;padding:1rem .25rem;font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;;font-size:.875rem}@media (min-width:40rem){main :is(.source-code)>pre{padding:2rem .75rem;font-size:1rem}}main .md-view>pre a:hover{text-decoration-line:none}main :is(.md-view,.source-code)>pre .chroma-ln:target{background-color:transparent!important}main :is(.md-view,.source-code)>pre .chroma-line:has(.chroma-ln:target),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),main :is(.md-view,.source-code)>pre .chroma-line:has(.chroma-lnlinks:hover) .chroma-cl{border-radius:.375rem;--tw-bg-opacity:1!important;background-color:rgb(226 226 226/var(--tw-bg-opacity))!important}main :is(.md-view,.source-code)>pre .chroma-ln{scroll-margin-top:6rem}.dev-mode .toc-expend-btn{cursor:pointer;border-width:1px;--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity))}.dev-mode .toc-expend-btn:hover{--tw-bg-opacity:1;background-color:rgb(240 240 240/var(--tw-bg-opacity))}@media (min-width:51.25rem){.dev-mode .toc-expend-btn{border-style:none;background-color:transparent}}.dev-mode #sidebar-summary{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}@media (min-width:51.25rem){.dev-mode #sidebar-summary{background-color:transparent}}.dev-mode .toc-nav{font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.invisible{visibility:hidden}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.bottom-1{bottom:.25rem}.left-0{left:0}.right-0{right:0}.right-2{right:.5rem}.right-3{right:.75rem}.right-px{right:1px}.top-0{top:0}.top-1\/2{top:50%}.top-14{top:3.5rem}.top-2{top:.5rem}.top-px{top:1px}.z-1{z-index:1}.z-max{z-index:9999}.order-2{order:2}.order-3{order:3}.col-span-1{grid-column:span 1/span 1}.col-span-3{grid-column:span 3/span 3}.col-span-7{grid-column:span 7/span 7}.row-span-1{grid-row:span 1/span 1}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-12{margin-bottom:3rem}.mb-14{margin-bottom:3.5rem}.mb-2{margin-bottom:.5rem}.mb-20{margin-bottom:5rem}.mb-24{margin-bottom:6rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-8{margin-bottom:2rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-10{margin-right:2.5rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-3{height:.75rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-\[calc\(100\%-2px\)\]{height:calc(100% - 2px)}.h-auto{height:auto}.h-full{height:100%}.max-h-screen{max-height:100vh}.min-h-full{min-height:100%}.min-h-screen{min-height:100vh}.w-0{width:0}.w-10{width:2.5rem}.w-3{width:.75rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-72{width:18rem}.w-full{width:100%}.min-w-0{min-width:0}.min-w-2{min-width:.5rem}.min-w-4{min-width:1rem}.min-w-48{min-width:12rem}.max-w-screen-max{max-width:98.75rem}.flex-auto{flex:1 1 auto}.flex-shrink-0,.shrink-0{flex-shrink:0}.flex-grow-0{flex-grow:0}.grow-\[2\]{flex-grow:2}.basis-auto{flex-basis:auto}.-translate-x-full{--tw-translate-x:-100%}.-translate-x-full,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.cursor-pointer{cursor:pointer}.cursor-text{cursor:text}.list-none{list-style-type:none}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-flow-dense{grid-auto-flow:dense}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-10{grid-template-columns:repeat(10,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-baseline{align-items:baseline}.items-stretch{align-items:stretch}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-12{gap:3rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-8{gap:2rem}.gap-x-20{-moz-column-gap:5rem;column-gap:5rem}.gap-x-3{-moz-column-gap:.75rem;column-gap:.75rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-1{row-gap:.25rem}.gap-y-2{row-gap:.5rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-scroll{overflow:scroll}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.rounded{border-radius:.375rem}.rounded-full{border-radius:9999px}.rounded-sm{border-radius:.25rem}.rounded-r{border-top-right-radius:.375rem;border-bottom-right-radius:.375rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-gray-100{--tw-border-opacity:1;border-color:rgb(226 226 226/var(--tw-border-opacity))}.border-transparent{border-color:transparent}.bg-current{background-color:currentColor}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(124 124 124/var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(240 240 240/var(--tw-bg-opacity))}.bg-light{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-transparent{background-color:transparent}.p-0{padding:0}.p-0\.5{padding:.125rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-10{padding-left:2.5rem;padding-right:2.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0{padding-top:0;padding-bottom:0}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-px{padding-top:1px;padding-bottom:1px}.pb-14{padding-bottom:3.5rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-6{padding-bottom:1.5rem}.pb-8{padding-bottom:2rem}.pl-4{padding-left:1rem}.pr-10{padding-right:2.5rem}.pt-0\.5{padding-top:.125rem}.pt-2{padding-top:.5rem}.text-center{text-align:center}.font-interVar{font-family: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}.font-mono{font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;}.text-100{font-size:.875rem}.text-200{font-size:1rem}.text-300{font-size:1.125rem}.text-400{font-size:1.25rem}.text-50{font-size:.75rem}.text-600{font-size:1.5rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.capitalize{text-transform:capitalize}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.text-gray-100{--tw-text-opacity:1;color:rgb(226 226 226/var(--tw-text-opacity))}.text-gray-300{--tw-text-opacity:1;color:rgb(153 153 153/var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity:1;color:rgb(124 124 124/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.text-gray-800{--tw-text-opacity:1;color:rgb(19 19 19/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(8 8 9/var(--tw-text-opacity))}.text-green-600{--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.text-light{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.opacity-0{opacity:0}.outline-none{outline:2px solid transparent;outline-offset:2px}.text-stroke{-webkit-text-stroke:currentColor;-webkit-text-stroke-width:.6px}.no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}.link-external{font-size:.7em}.link-internal{font-weight:400}.link-internal,.link-tx{font-size:.8em}:is(.link-external,.link-internal,.link-tx){display:inline;vertical-align:text-top}.md-view a>span:first-of-type{margin-left:.125rem}.field-content{field-sizing:content}@supports not (field-sizing:content){.focus-no-field-sizing\:w-20:focus{width:5rem!important}}.\*\:pl-0>*{padding-left:0}.before\:px-\[0\.18rem\]:before{content:var(--tw-content);padding-left:.18rem;padding-right:.18rem}.before\:pt-0\.5:before{content:var(--tw-content);padding-top:.125rem}.before\:leading-normal:before{content:var(--tw-content);line-height:1.5}.before\:text-gray-400:before{content:var(--tw-content);--tw-text-opacity:1;color:rgb(124 124 124/var(--tw-text-opacity))}.before\:content-\[\'\&\'\]:before{--tw-content:"&";content:var(--tw-content)}.before\:content-\[\'\/\'\]:before{--tw-content:"/";content:var(--tw-content)}.before\:content-\[\'\:\'\]:before{--tw-content:":";content:var(--tw-content)}.before\:content-\[\'\?\'\]:before{--tw-content:"?";content:var(--tw-content)}.before\:content-\[\'open\'\]:before{--tw-content:"open";content:var(--tw-content)}.after\:pointer-events-none:after{content:var(--tw-content);pointer-events:none}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:bottom-0:after{content:var(--tw-content);bottom:0}.after\:left-0:after{content:var(--tw-content);left:0}.after\:top-0:after{content:var(--tw-content);top:0}.after\:block:after{content:var(--tw-content);display:block}.after\:h-1:after{content:var(--tw-content);height:.25rem}.after\:h-full:after{content:var(--tw-content);height:100%}.after\:w-full:after{content:var(--tw-content);width:100%}.after\:rounded-t-sm:after{content:var(--tw-content);border-top-left-radius:.25rem;border-top-right-radius:.25rem}.after\:bg-gray-100:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity))}.after\:bg-green-600:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(34 108 87/var(--tw-bg-opacity))}.first\:mt-8:first-child{margin-top:2rem}.first\:border-t:first-child{border-top-width:1px}.focus-within\:border-gray-400:focus-within{--tw-border-opacity:1;border-color:rgb(124 124 124/var(--tw-border-opacity))}.hover\:border-gray-300:hover{--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}.hover\:border-gray-400:hover{--tw-border-opacity:1;border-color:rgb(124 124 124/var(--tw-border-opacity))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(240 240 240/var(--tw-bg-opacity))}.hover\:bg-green-600:hover{--tw-bg-opacity:1;background-color:rgb(34 108 87/var(--tw-bg-opacity))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(8 8 9/var(--tw-text-opacity))}.hover\:text-green-600:hover{--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.hover\:text-light:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.hover\:underline:hover{text-decoration-line:underline}.focus\:w-min:focus{width:-moz-min-content;width:min-content}.focus\:border-gray-300:focus{--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}.focus\:border-l-gray-300:focus{--tw-border-opacity:1;border-left-color:rgb(153 153 153/var(--tw-border-opacity))}.focus\:opacity-100:focus{opacity:1}.group:last-child .group-last\:mr-0{margin-right:0}.group:hover .group-hover\:border-gray-300{--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}.group:hover .group-hover\:border-l-gray-300{--tw-border-opacity:1;border-left-color:rgb(153 153 153/var(--tw-border-opacity))}.group:hover .group-hover\:text-gray-600{--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.group.is-active .group-\[\.is-active\]\:text-green-600{--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.peer:checked~.peer-checked\:visible{visibility:visible}.peer:checked~.peer-checked\:flex{display:flex}.peer:checked~.peer-checked\:hidden{display:none}.peer:checked~.peer-checked\:opacity-100{opacity:1}.peer:checked~.peer-checked\:before\:content-\[\'close\'\]:before{--tw-content:"close";content:var(--tw-content)}.peer:-moz-placeholder-shown~.peer-placeholder-shown\:hidden{display:none}.peer:placeholder-shown~.peer-placeholder-shown\:hidden{display:none}.peer:focus-within~.peer-focus-within\:hidden{display:none}.peer:focus~.peer-focus\:hidden{display:none}.has-\[ul\:empty\]\:hidden:has(ul:empty){display:none}.has-\[\:focus\]\:border-gray-300:has(:focus){--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}.has-\[\[data-role\=\'header-input-search\'\]\:focus-within\]\:border-gray-300:has([data-role=header-input-search]:focus-within){--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}.has-\[\[data-role\=\'header-input-search\'\]\:focus-within\]\:text-gray-50:has([data-role=header-input-search]:focus-within){--tw-text-opacity:1;color:rgb(240 240 240/var(--tw-text-opacity))}@media (min-width:30rem){.sm\:mr-6{margin-right:1.5rem}}@media (min-width:40rem){.md\:col-span-3{grid-column:span 3/span 3}.md\:mb-0{margin-bottom:0}.md\:flex{display:flex}.md\:h-4{height:1rem}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:gap-x-8{-moz-column-gap:2rem;column-gap:2rem}.md\:px-10{padding-left:2.5rem;padding-right:2.5rem}.md\:pb-0{padding-bottom:0}.md\:pr-8{padding-right:2rem}}@media (min-width:51.25rem){.lg\:order-2{order:2}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:col-span-7{grid-column:span 7/span 7}.lg\:row-span-2{grid-row:span 2/span 2}.lg\:row-start-1{grid-row-start:1}.lg\:mb-6{margin-bottom:1.5rem}.lg\:ml-2{margin-left:.5rem}.lg\:mt-0{margin-top:0}.lg\:mt-10{margin-top:2.5rem}.lg\:block{display:block}.lg\:grid{display:grid}.lg\:hidden{display:none}.lg\:h-4{height:1rem}.lg\:w-4{width:1rem}.lg\:grid-cols-10{grid-template-columns:repeat(10,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:justify-start{justify-content:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:gap-x-20{-moz-column-gap:5rem;column-gap:5rem}.lg\:border-none{border-style:none}.lg\:bg-transparent{background-color:transparent}.lg\:p-0{padding:0}.lg\:px-0{padding-left:0;padding-right:0}.lg\:px-2{padding-left:.5rem;padding-right:.5rem}.lg\:py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.lg\:pb-28{padding-bottom:7rem}.lg\:pt-2{padding-top:.5rem}.lg\:text-200{font-size:1rem}.lg\:text-50{font-size:.75rem}.lg\:font-semibold{font-weight:600}.lg\:first\:mt-0:first-child{margin-top:0}.lg\:hover\:bg-transparent:hover{background-color:transparent}}@media (min-width:63.75rem){.xl\:mr-3{margin-right:.75rem}.xl\:inline{display:inline}.xl\:hidden{display:none}.xl\:grid-cols-10{grid-template-columns:repeat(10,minmax(0,1fr))}.xl\:flex-row{flex-direction:row}.xl\:items-center{align-items:center}.xl\:gap-20{gap:5rem}.xl\:gap-4{gap:1rem}.xl\:gap-6{gap:1.5rem}.xl\:pt-0{padding-top:0}.xl\:text-200{font-size:1rem}}@media (min-width:85.375rem){.xxl\:inline-block{display:inline-block}.xxl\:h-4{height:1rem}.xxl\:w-4{width:1rem}.xxl\:gap-20{gap:5rem}.xxl\:gap-x-32{-moz-column-gap:8rem;column-gap:8rem}.xxl\:pr-1{padding-right:.25rem}} \ No newline at end of file diff --git a/gno.land/pkg/gnoweb/webclient.go b/gno.land/pkg/gnoweb/webclient.go index 491b543fbd7..d904e041ae7 100644 --- a/gno.land/pkg/gnoweb/webclient.go +++ b/gno.land/pkg/gnoweb/webclient.go @@ -44,4 +44,7 @@ type WebClient interface { // Sources lists all source files available in a specified // package path. Sources(path string) ([]string, error) + + // RenderMd renders a markdown file and returns the rendered content + RenderMd(w io.Writer, u *weburl.GnoURL, fileName string) (*RealmMeta, error) } diff --git a/gno.land/pkg/gnoweb/webclient_html.go b/gno.land/pkg/gnoweb/webclient_html.go index 1053b09a0d0..d8cc57f4369 100644 --- a/gno.land/pkg/gnoweb/webclient_html.go +++ b/gno.land/pkg/gnoweb/webclient_html.go @@ -1,6 +1,7 @@ package gnoweb import ( + "bytes" "errors" "fmt" "io" @@ -20,6 +21,7 @@ import ( "github.com/gnolang/gno/tm2/pkg/bft/rpc/client" "github.com/yuin/goldmark" markdown "github.com/yuin/goldmark-highlighting/v2" + "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/text" @@ -145,6 +147,11 @@ func (s *HTMLWebClient) SourceFile(w io.Writer, path, fileName string, isRaw boo SizeKb: float64(len(source)) / 1024.0, } + // If writer is nil, just return metadata + if w == nil { + return &fileMeta, nil + } + if isRaw { // Use raw syntax for source if _, err := w.Write(source); err != nil { @@ -183,6 +190,28 @@ func (s *HTMLWebClient) Sources(path string) ([]string, error) { return files, nil } +// ParseMarkdown parses and renders Markdown content using Goldmark. +// It takes a writer, the raw markdown content, and optional parser context options. +// Returns the parsed document and any error that occurred. +func (s *HTMLWebClient) ParseMarkdown(w io.Writer, rawContent []byte, ctxOpts ...parser.ParseOption) (ast.Node, error) { + // Use Goldmark for Markdown parsing + doc := s.Markdown.Parser().Parse(text.NewReader(rawContent), ctxOpts...) + if err := s.Markdown.Renderer().Render(w, rawContent, doc); err != nil { + return nil, fmt.Errorf("unable to render markdown: %w", err) + } + return doc, nil +} + +// generateTOC generates a table of contents from a markdown document +func (s *HTMLWebClient) generateTOC(doc ast.Node, content []byte) *md.Toc { + toc, err := md.TocInspect(doc, content, md.TocOptions{MaxDepth: 6, MinDepth: 2}) + if err != nil { + s.logger.Warn("unable to inspect for TOC elements", "error", err) + return &md.Toc{} + } + return &toc +} + // RenderRealm renders the content of a realm from a given path // and arguments into the provided writer. It uses Goldmark for // Markdown processing to generate HTML content. @@ -198,19 +227,42 @@ func (s *HTMLWebClient) RenderRealm(w io.Writer, u *weburl.GnoURL) (*RealmMeta, } ctxOpts := parser.WithContext(md.NewGnoParserContext(u)) - - // Use Goldmark for Markdown parsing - doc := s.Markdown.Parser().Parse(text.NewReader(rawres), ctxOpts) - if err := s.Markdown.Renderer().Render(w, rawres, doc); err != nil { + doc, err := s.ParseMarkdown(w, rawres, ctxOpts) + if err != nil { return nil, fmt.Errorf("unable to render realm %q: %w", data, err) } var meta RealmMeta - meta.Toc, err = md.TocInspect(doc, rawres, md.TocOptions{MaxDepth: 6, MinDepth: 2}) + meta.Toc = *s.generateTOC(doc, rawres) + + return &meta, nil +} + +// RenderMd renders a markdown file and returns the rendered content +func (s *HTMLWebClient) RenderMd(w io.Writer, u *weburl.GnoURL, fileName string) (*RealmMeta, error) { + // Read and render markdown file + var content bytes.Buffer + _, err := s.SourceFile(&content, u.Path, fileName, true) if err != nil { - s.logger.Warn("unable to inspect for TOC elements", "error", err) + return nil, err } + ctxOpts := parser.WithContext(md.NewGnoParserContext(u)) + + var renderedContent bytes.Buffer + doc, err := s.ParseMarkdown(&renderedContent, content.Bytes(), ctxOpts) + if err != nil { + return nil, err + } + + // Copy rendered content to output writer + if _, err := w.Write(renderedContent.Bytes()); err != nil { + return nil, err + } + + var meta RealmMeta + meta.Toc = *s.generateTOC(doc, content.Bytes()) + return &meta, nil } diff --git a/gno.land/pkg/gnoweb/webclient_html_test.go b/gno.land/pkg/gnoweb/webclient_html_test.go new file mode 100644 index 00000000000..2a518db3ceb --- /dev/null +++ b/gno.land/pkg/gnoweb/webclient_html_test.go @@ -0,0 +1,515 @@ +package gnoweb + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "strings" + "testing" + + "github.com/gnolang/gno/gno.land/pkg/gnoweb/weburl" + "github.com/gnolang/gno/gnovm/pkg/doc" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" +) + +// JSONParam represents a function parameter in JSON format +type JSONParam struct { + Name string `json:"name"` + Type string `json:"type"` +} + +// JSONResult represents a function result in JSON format +type JSONResult struct { + Type string `json:"type"` +} + +// errorWriter is a writer that always fails +type errorWriter struct{} + +func (w *errorWriter) Write(p []byte) (int, error) { + return 0, fmt.Errorf("write error") +} + +// dummyGnoURL creates a dummy GnoURL for testing +func dummyGnoURL(path string) *weburl.GnoURL { + return &weburl.GnoURL{Path: path} +} + +// testingLogger is a logger that writes to the test output +type testingLogger struct { + *testing.T +} + +func (t *testingLogger) Write(b []byte) (n int, err error) { + t.T.Log(strings.TrimSpace(string(b))) + 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 +} + +// --- Unit Tests --- + +// TestDoc verifies that the Doc method returns proper JSON documentation. +func TestDoc(t *testing.T) { + // Create a mock package with functions + mockPkg := &MockPackage{ + Path: "test/pkg", + Functions: []*doc.JSONFunc{ + { + Name: "TestFunc", + Params: []*doc.JSONField{ + {Name: "param1", Type: "string"}, + }, + Results: []*doc.JSONField{ + {Type: "string"}, + }, + }, + }, + } + client := NewMockWebClient(mockPkg) + + jdoc, err := client.Doc("test/pkg") + if err != nil { + t.Fatalf("Doc returned an error: %v", err) + } + if len(jdoc.Funcs) != 1 || jdoc.Funcs[0].Name != "TestFunc" { + t.Error("documentation does not contain the expected functions") + } +} + +// TestSourceFile verifies source file rendering with and without formatting. +func TestSourceFile(t *testing.T) { + // Create a mock package with a source file + mockPkg := &MockPackage{ + Path: "test/pkg", + Files: map[string]string{ + "test.gno": "package main\n\nfunc main() { println(\"Hello\") }", + }, + } + client := NewMockWebClient(mockPkg) + + // Test formatted mode + var buf bytes.Buffer + meta, err := client.SourceFile(&buf, "test/pkg", "test.gno", false) + if err != nil { + t.Fatalf("SourceFile (formatted) returned an error: %v", err) + } + if meta.Lines == 0 { + t.Error("number of lines should not be zero") + } + output := buf.String() + if !strings.Contains(output, "package main") { + t.Error("formatted output does not contain expected source content") + } + + // Test raw mode + buf.Reset() + metaRaw, err := client.SourceFile(&buf, "test/pkg", "test.gno", true) + if err != nil { + t.Fatalf("SourceFile (raw) returned an error: %v", err) + } + rawOutput := buf.String() + if !strings.Contains(rawOutput, "package main") { + t.Error("raw output does not contain expected source content") + } + if meta.Lines != metaRaw.Lines { + t.Error("number of lines should be identical in raw and formatted mode") + } +} + +// TestSourceFileNilWriter verifies that SourceFile returns metadata when writer is nil +func TestSourceFileNilWriter(t *testing.T) { + // Create a mock package with a source file + mockPkg := &MockPackage{ + Path: "test/pkg", + Files: map[string]string{ + "test.gno": "package main\n\nfunc main() { println(\"Hello\") }", + }, + } + client := NewMockWebClient(mockPkg) + + // Test with nil writer + meta, err := client.SourceFile(nil, "test/pkg", "test.gno", false) + if err != nil { + t.Fatalf("SourceFile returned an error: %v", err) + } + if meta == nil { + t.Fatal("metadata should not be nil") + } + // Only check Lines if meta is not nil + if meta.Lines != 3 { + t.Errorf("expected 3 lines, got %d", meta.Lines) + } +} + +// TestSources verifies the retrieval of file list. +func TestSources(t *testing.T) { + // Create a mock package with multiple files + mockPkg := &MockPackage{ + Path: "test/pkg", + Files: map[string]string{ + "file1.gno": "package main", + "file2.gno": "package main", + }, + } + client := NewMockWebClient(mockPkg) + + files, err := client.Sources("test/pkg") + if err != nil { + t.Fatalf("Sources returned an error: %v", err) + } + if len(files) != 2 || files[0] != "file1.gno" { + t.Errorf("unexpected file list: %v", files) + } +} + +// TestRenderRealm verifies realm rendering. +func TestRenderRealm(t *testing.T) { + // Create a mock package with a Render function + mockPkg := &MockPackage{ + Path: "test/pkg", + Domain: "test", + Functions: []*doc.JSONFunc{ + { + Name: "Render", + Params: []*doc.JSONField{ + {Type: "string"}, + }, + Results: []*doc.JSONField{ + {Type: "string"}, + }, + }, + }, + } + client := NewMockWebClient(mockPkg) + + url := &weburl.GnoURL{Path: "test/pkg"} + var buf bytes.Buffer + meta, err := client.RenderRealm(&buf, url) + if err != nil { + t.Fatalf("RenderRealm returned an error: %v", err) + } + rendered := buf.String() + if !strings.Contains(rendered, "[test]/test/pkg:") { + t.Error("realm rendering does not contain expected format") + } + if meta == nil { + t.Error("metadata should not be nil") + } +} + +// TestRenderMd verifies markdown file rendering. +func TestRenderMd(t *testing.T) { + // Create a mock package with a markdown file + mockPkg := &MockPackage{ + Path: "test/pkg", + Files: map[string]string{ + "readme.md": "# Test Markdown\n\nThis is a test.", + }, + } + client := NewMockWebClient(mockPkg) + + url := &weburl.GnoURL{Path: "test/pkg"} + var buf bytes.Buffer + meta, err := client.RenderMd(&buf, url, "readme.md") + if err != nil { + t.Fatalf("RenderMd returned an error: %v", err) + } + rendered := buf.String() + if !strings.Contains(rendered, " tag") + } + if meta == nil { + t.Error("metadata should not be nil") + } +} + +func TestRenderMd_SourceFileError(t *testing.T) { + // Create a mock package without the markdown file + mockPkg := &MockPackage{ + Path: "test/pkg", + Files: map[string]string{}, + } + client := NewMockWebClient(mockPkg) + + url := &weburl.GnoURL{Path: "test/pkg"} + var buf bytes.Buffer + _, err := client.RenderMd(&buf, url, "nonexistent.md") + if err == nil { + t.Error("expected error when file does not exist") + } +} + +func TestRenderMd_ParseError(t *testing.T) { + // Create a mock package with a markdown file + mockPkg := &MockPackage{ + Path: "test/pkg", + Files: map[string]string{ + "readme.md": "# Test Markdown\n\nThis is a test.", + }, + } + client := NewMockWebClient(mockPkg) + + url := &weburl.GnoURL{Path: "test/pkg"} + // Use errorWriter to simulate write error + errorWriter := &errorWriter{} + _, err := client.RenderMd(errorWriter, url, "readme.md") + if err == nil { + t.Error("expected error when writing fails") + } +} + +func TestRenderMd_WriteError(t *testing.T) { + // Create a mock package with a markdown file + mockPkg := &MockPackage{ + Path: "test/pkg", + Files: map[string]string{ + "readme.md": "# Test Markdown\n\nThis is a test.", + }, + } + client := NewMockWebClient(mockPkg) + + url := &weburl.GnoURL{Path: "test/pkg"} + // Use errorWriter to simulate write failure + errorWriter := &errorWriter{} + _, err := client.RenderMd(errorWriter, url, "readme.md") + if err == nil { + t.Error("expected error when writing fails") + } +} + +// TestRenderMdNotFound verifies error handling when markdown file is not found +func TestRenderMdNotFound(t *testing.T) { + // Create a mock package + mockPkg := &MockPackage{ + Path: "test/pkg", + Files: map[string]string{ + // No markdown files + }, + } + client := NewMockWebClient(mockPkg) + + var buf bytes.Buffer + url := dummyGnoURL("test/pkg") + _, err := client.RenderMd(&buf, url, "nonexistent.md") + if err == nil { + t.Error("expected an error when file is not found") + } +} + +// TestRenderMdWriteError verifies error handling when writing rendered content fails +func TestRenderMdWriteError(t *testing.T) { + // Create a mock package with a markdown file + mockPkg := &MockPackage{ + Path: "test/pkg", + Files: map[string]string{ + "test.md": "# Test\n\nThis is a test.", + }, + } + client := NewMockWebClient(mockPkg) + + // Use errorWriter to simulate write failure + errorWriter := &errorWriter{} + url := dummyGnoURL("test/pkg") + _, err := client.RenderMd(errorWriter, url, "test.md") + if err == nil { + t.Error("expected an error when writing fails") + } +} + +// TestParseMarkdown verifies markdown parsing and rendering. +func TestParseMarkdown(t *testing.T) { + // Create a mock package with a markdown file + mockPkg := &MockPackage{ + Path: "test/pkg", + Files: map[string]string{ + "test.md": "# Hello\n\nThis is a *test* markdown.", + }, + } + client := NewMockWebClient(mockPkg) + + markdownContent := []byte("# Hello\n\nThis is a *test* markdown.") + var buf bytes.Buffer + node, err := client.ParseMarkdown(&buf, markdownContent) + if err != nil { + t.Fatalf("ParseMarkdown returned an error: %v", err) + } + if node == nil { + t.Error("AST should not be nil") + } + rendered := buf.String() + if !strings.Contains(rendered, " tag") + } +} + +// TestParseMarkdownWithContext verifies markdown parsing with context options +func TestParseMarkdownWithContext(t *testing.T) { + // Create a mock package + mockPkg := &MockPackage{ + Path: "test/pkg", + } + client := NewMockWebClient(mockPkg) + + markdownContent := []byte("# Hello\n\nThis is a *test* markdown.") + var buf bytes.Buffer + + // Create a dummy context option + ctxOpt := parser.WithContext(parser.NewContext()) + + node, err := client.ParseMarkdown(&buf, markdownContent, ctxOpt) + if err != nil { + t.Fatalf("ParseMarkdown returned an error: %v", err) + } + if node == nil { + t.Error("AST should not be nil") + } + rendered := buf.String() + if !strings.Contains(rendered, " tag") + } +} + +// TestParseMarkdownError verifies error handling in markdown parsing +func TestParseMarkdownError(t *testing.T) { + // Create a mock package + mockPkg := &MockPackage{ + Path: "test/pkg", + } + client := NewMockWebClient(mockPkg) + + // Create a writer that will fail + errorWriter := &errorWriter{} + markdownContent := []byte("# Hello\n\nThis is a *test* markdown.") + + _, err := client.ParseMarkdown(errorWriter, markdownContent) + if err == nil { + t.Error("expected an error when writer fails") + } +} + +// TestFormatSource verifies source code syntax highlighting. +func TestFormatSource(t *testing.T) { + // Create a mock package with a source file + mockPkg := &MockPackage{ + Path: "test/pkg", + Files: map[string]string{ + "test.gno": "package main\n\nfunc main() { println(\"Hello\") }", + }, + } + client := NewMockWebClient(mockPkg) + + source := []byte("package main\n\nfunc main() { println(\"Hello\") }") + var buf bytes.Buffer + err := client.FormatSource(&buf, "test.gno", source) + if err != nil { + t.Fatalf("FormatSource returned an error: %v", err) + } + formatted := buf.String() + if !strings.Contains(formatted, "chroma-") { + t.Error("formatted code should contain Chroma CSS classes (e.g., 'chroma-')") + } +} + +// TestWriteFormatterCSS verifies that the generated CSS is not empty. +func TestWriteFormatterCSS(t *testing.T) { + // Create a mock package with a source file + mockPkg := &MockPackage{ + Path: "test/pkg", + Files: map[string]string{ + "test.gno": "package main\n\nfunc main() { println(\"Hello\") }", + }, + } + client := NewMockWebClient(mockPkg) + + var buf bytes.Buffer + err := client.WriteFormatterCSS(&buf) + if err != nil { + t.Fatalf("WriteFormatterCSS returned an error: %v", err) + } + css := buf.String() + if len(css) == 0 { + t.Error("generated CSS should not be empty") + } +} + +// TestGenerateTOC verifies the table of contents generation from markdown content +func TestGenerateTOC(t *testing.T) { + // Create a HTMLWebClient with default configuration + config := NewDefaultHTMLWebClientConfig(nil) + config.GoldmarkOptions = append(config.GoldmarkOptions, goldmark.WithParserOptions(parser.WithAutoHeadingID())) + client := NewHTMLClient(slog.New(&testingLogger{t}), config) + + // Test cases + cases := []struct { + name string + content string + expected int // expected number of TOC items + }{ + { + name: "simple headings", + content: `# Title 1 +## Subtitle 1.1 +## Subtitle 1.2 +### Sub-subtitle 1.2.1 +# Title 2`, + expected: 2, // Only ## and ### headings + }, + { + name: "no headings", + content: `This is a paragraph +with no headings.`, + expected: 0, + }, + { + name: "nested headings", + content: `# Main Title +## Section 1 +### Subsection 1.1 +#### Detail 1.1.1 +## Section 2 +### Subsection 2.1`, + expected: 2, // Only ## headings + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Parse the markdown content + doc := client.Markdown.Parser().Parse(text.NewReader([]byte(tc.content))) + + // Generate TOC + toc := client.generateTOC(doc, []byte(tc.content)) + + // Print the TOC structure for debugging + t.Logf("Generated TOC items: %d", len(toc.Items)) + for i, item := range toc.Items { + t.Logf("Item %d: Title='%s', ID='%s', Children=%d", + i, string(item.Title), string(item.ID), len(item.Items)) + } + + // Verify the number of TOC items + if len(toc.Items) != tc.expected { + t.Errorf("expected %d TOC items, got %d", tc.expected, len(toc.Items)) + } + }) + } +} diff --git a/gno.land/pkg/gnoweb/webclient_mock.go b/gno.land/pkg/gnoweb/webclient_mock.go index 156664e85df..9fe0cd15899 100644 --- a/gno.land/pkg/gnoweb/webclient_mock.go +++ b/gno.land/pkg/gnoweb/webclient_mock.go @@ -7,8 +7,13 @@ import ( "sort" "strings" + "github.com/gnolang/gno/gno.land/pkg/gnoweb/markdown" "github.com/gnolang/gno/gno.land/pkg/gnoweb/weburl" "github.com/gnolang/gno/gnovm/pkg/doc" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" ) // MockPackage represents a mock package with files and function signatures. @@ -22,6 +27,7 @@ type MockPackage struct { // MockWebClient is a mock implementation of the Client interface. type MockWebClient struct { Packages map[string]*MockPackage // path -> package + markdown goldmark.Markdown } func NewMockWebClient(pkgs ...*MockPackage) *MockWebClient { @@ -30,7 +36,14 @@ func NewMockWebClient(pkgs ...*MockPackage) *MockWebClient { mpkgs[pkg.Path] = pkg } - return &MockWebClient{Packages: mpkgs} + return &MockWebClient{ + Packages: mpkgs, + markdown: goldmark.New( + goldmark.WithExtensions( + markdown.GnoExtension, + ), + ), + } } // RenderRealm simulates rendering a package by writing its content to the writer. @@ -59,7 +72,12 @@ func (m *MockWebClient) SourceFile(w io.Writer, pkgPath, fileName string, isRaw } if body, ok := pkg.Files[fileName]; ok { - w.Write([]byte(body)) + if w != nil { + _, err := w.Write([]byte(body)) + if err != nil { + return nil, err + } + } return &FileMeta{ Lines: len(bytes.Split([]byte(body), []byte("\n"))), SizeKb: float64(len(body)) / 1024.0, @@ -97,6 +115,49 @@ func (m *MockWebClient) Sources(path string) ([]string, error) { return fileNames, nil } +// RenderMd simulates rendering a markdown file. +func (m *MockWebClient) RenderMd(w io.Writer, u *weburl.GnoURL, fileName string) (*RealmMeta, error) { + pkg, exists := m.Packages[u.Path] + if !exists { + return nil, ErrClientPathNotFound + } + + if body, ok := pkg.Files[fileName]; ok { + // Parse and render the markdown + doc := m.markdown.Parser().Parse(text.NewReader([]byte(body))) + if err := m.markdown.Renderer().Render(w, []byte(body), doc); err != nil { + return nil, fmt.Errorf("unable to render markdown: %w", err) + } + + return &RealmMeta{}, nil + } + + return nil, ErrClientPathNotFound +} + +// ParseMarkdown parses and renders Markdown content using Goldmark. +func (m *MockWebClient) ParseMarkdown(w io.Writer, rawContent []byte, ctxOpts ...parser.ParseOption) (ast.Node, error) { + doc := m.markdown.Parser().Parse(text.NewReader(rawContent), ctxOpts...) + if err := m.markdown.Renderer().Render(w, rawContent, doc); err != nil { + return nil, fmt.Errorf("unable to render markdown: %w", err) + } + return doc, nil +} + +// FormatSource simulates formatting source code with syntax highlighting. +func (m *MockWebClient) FormatSource(w io.Writer, fileName string, source []byte) error { + // For testing, we just write the source as-is with a CSS class + fmt.Fprintf(w, "
%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 +}