Skip to content

Commit

Permalink
chore(gnoweb): cleanup iteration on gnoweb (#3379)
Browse files Browse the repository at this point in the history
depends on #3366 

This PR cleans up, documents, and reorganizes the `gnoweb` package,
which was recently revamped:

* Refactored the code for better readability and structure, and added
enhanced comments.
* Enhanced existing test cases:
	* Added new test cases for `assets` in `app_test.go`.
	* Included a new test rule in the Makefile.
	* Created new tests for WebHandler in `handler_test.go`.
* Improved file and directory handling methods in `handler.go`.

---------

Signed-off-by: gfanton <[email protected]>
Co-authored-by: Morgan <[email protected]>
  • Loading branch information
gfanton and thehowl authored Jan 16, 2025
1 parent cb2255f commit 870cbed
Show file tree
Hide file tree
Showing 12 changed files with 710 additions and 347 deletions.
14 changes: 7 additions & 7 deletions gno.land/cmd/gnoweb/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,6 @@ func setupWeb(cfg *webCfg, _ []string, io commands.IO) (func() error, error) {
if cfg.verbose {
level = zapcore.DebugLevel
}

var zapLogger *zap.Logger
if cfg.json {
zapLogger = log.NewZapJSONLogger(io.Out(), level)
Expand All @@ -155,30 +154,32 @@ func setupWeb(cfg *webCfg, _ []string, io commands.IO) (func() error, error) {

logger := log.ZapLoggerToSlog(zapLogger)

// Setup app
appcfg := gnoweb.NewDefaultAppConfig()
appcfg.ChainID = cfg.chainid
appcfg.NodeRemote = cfg.remote
appcfg.RemoteHelp = cfg.remoteHelp
if appcfg.RemoteHelp == "" {
appcfg.RemoteHelp = appcfg.NodeRemote
}
appcfg.Analytics = cfg.analytics
appcfg.UnsafeHTML = cfg.html
appcfg.FaucetURL = cfg.faucetURL
appcfg.AssetsDir = cfg.assetsDir
if appcfg.RemoteHelp == "" {
appcfg.RemoteHelp = appcfg.NodeRemote
}

app, err := gnoweb.NewRouter(logger, appcfg)
if err != nil {
return nil, fmt.Errorf("unable to start gnoweb app: %w", err)
}

// Resolve binding address
bindaddr, err := net.ResolveTCPAddr("tcp", cfg.bind)
if err != nil {
return nil, fmt.Errorf("unable to resolve listener %q: %w", cfg.bind, err)
}

logger.Info("Running", "listener", bindaddr.String())

// Setup server
server := &http.Server{
Handler: app,
Addr: bindaddr.String(),
Expand All @@ -187,10 +188,9 @@ func setupWeb(cfg *webCfg, _ []string, io commands.IO) (func() error, error) {

return func() error {
if err := server.ListenAndServe(); err != nil {
logger.Error("HTTP server stopped", " error:", err)
logger.Error("HTTP server stopped", "error", err)
return commands.ExitCodeError(1)
}

return nil
}, nil
}
3 changes: 3 additions & 0 deletions gno.land/pkg/gnoweb/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ cache_dir := .cache
# Install dependencies
all: generate

test:
go test -v ./...

# Generate process
generate: css ts static

Expand Down
80 changes: 47 additions & 33 deletions gno.land/pkg/gnoweb/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,28 +33,29 @@ type AppConfig struct {
ChainID string
// AssetsPath is the base path to the gnoweb assets.
AssetsPath string
// AssetDir, if set, will be used for assets instead of the embedded public directory
// AssetDir, if set, will be used for assets instead of the embedded public directory.
AssetsDir string
// FaucetURL, if specified, will be the URL to which `/faucet` redirects.
FaucetURL string
// Domain is the domain used by the node.
Domain string
}

// NewDefaultAppConfig returns a new default [AppConfig]. The default sets
// 127.0.0.1:26657 as the remote node, "dev" as the chain ID and sets up Assets
// to be served on /public/.
func NewDefaultAppConfig() *AppConfig {
const defaultRemote = "127.0.0.1:26657"

return &AppConfig{
// same as Remote by default
NodeRemote: defaultRemote,
RemoteHelp: defaultRemote,
ChainID: "dev",
AssetsPath: "/public/",
Domain: "gno.land",
}
}

var chromaStyle = mustGetStyle("friendly")
var chromaDefaultStyle = mustGetStyle("friendly")

func mustGetStyle(name string) *chroma.Style {
s := styles.Get(name)
Expand All @@ -64,15 +65,24 @@ func mustGetStyle(name string) *chroma.Style {
return s
}

// NewRouter initializes the gnoweb router, with the given logger and config.
// NewRouter initializes the gnoweb router with the specified logger and configuration.
func NewRouter(logger *slog.Logger, cfg *AppConfig) (http.Handler, error) {
// Initialize RPC Client
client, err := client.NewHTTPClient(cfg.NodeRemote)
if err != nil {
return nil, fmt.Errorf("unable to create HTTP client: %w", err)
}

// Configure Chroma highlighter
chromaOptions := []chromahtml.Option{
chromahtml.WithLineNumbers(true),
chromahtml.WithLinkableLineNumbers(true, "L"),
chromahtml.WithClasses(true),
chromahtml.ClassPrefix("chroma-"),
}
chroma := chromahtml.New(chromaOptions...)

// Configure Goldmark markdown parser
mdopts := []goldmark.Option{
goldmark.WithExtensions(
markdown.NewHighlighting(
Expand All @@ -84,36 +94,41 @@ func NewRouter(logger *slog.Logger, cfg *AppConfig) (http.Handler, error) {
if cfg.UnsafeHTML {
mdopts = append(mdopts, goldmark.WithRendererOptions(mdhtml.WithXHTML(), mdhtml.WithUnsafe()))
}

md := goldmark.New(mdopts...)

client, err := client.NewHTTPClient(cfg.NodeRemote)
if err != nil {
return nil, fmt.Errorf("unable to create http client: %w", err)
// Configure WebClient
webcfg := HTMLWebClientConfig{
Markdown: md,
Highlighter: NewChromaSourceHighlighter(chroma, chromaDefaultStyle),
Domain: cfg.Domain,
UnsafeHTML: cfg.UnsafeHTML,
RPCClient: client,
}
webcli := NewWebClient(logger, client, md)

formatter := chromahtml.New(chromaOptions...)
webcli := NewHTMLClient(logger, &webcfg)
chromaStylePath := path.Join(cfg.AssetsPath, "_chroma", "style.css")

var webConfig WebHandlerConfig

webConfig.RenderClient = webcli
webConfig.Formatter = newFormatterWithStyle(formatter, chromaStyle)

// Static meta
webConfig.Meta.AssetsPath = cfg.AssetsPath
webConfig.Meta.ChromaPath = chromaStylePath
webConfig.Meta.RemoteHelp = cfg.RemoteHelp
webConfig.Meta.ChainId = cfg.ChainID
webConfig.Meta.Analytics = cfg.Analytics
// Setup StaticMetadata
staticMeta := StaticMetadata{
Domain: cfg.Domain,
AssetsPath: cfg.AssetsPath,
ChromaPath: chromaStylePath,
RemoteHelp: cfg.RemoteHelp,
ChainId: cfg.ChainID,
Analytics: cfg.Analytics,
}

// Setup main handler
webhandler := NewWebHandler(logger, webConfig)
// Configure WebHandler
webConfig := WebHandlerConfig{WebClient: webcli, Meta: staticMeta}
webhandler, err := NewWebHandler(logger, webConfig)
if err != nil {
return nil, fmt.Errorf("unable to create web handler: %w", err)
}

// Setup HTTP muxer
mux := http.NewServeMux()

// Setup Webahndler along Alias Middleware
// Handle web handler with alias middleware
mux.Handle("/", AliasAndRedirectMiddleware(webhandler, cfg.Analytics))

// Register faucet URL to `/faucet` if specified
Expand All @@ -127,22 +142,21 @@ func NewRouter(logger *slog.Logger, cfg *AppConfig) (http.Handler, error) {
}))
}

// setup assets
// Handle Chroma CSS requests
// XXX: probably move this elsewhere
mux.Handle(chromaStylePath, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Setup Formatter
w.Header().Set("Content-Type", "text/css")
if err := formatter.WriteCSS(w, chromaStyle); err != nil {
logger.Error("unable to write css", "err", err)
if err := chroma.WriteCSS(w, chromaDefaultStyle); err != nil {
logger.Error("unable to write CSS", "err", err)
http.NotFound(w, r)
}
}))

// Normalize assets path
assetsBase := "/" + strings.Trim(cfg.AssetsPath, "/") + "/"

// Handle assets path
// XXX: add caching
assetsBase := "/" + strings.Trim(cfg.AssetsPath, "/") + "/"
if cfg.AssetsDir != "" {
logger.Debug("using assets dir instead of embed assets", "dir", cfg.AssetsDir)
logger.Debug("using assets dir instead of embedded assets", "dir", cfg.AssetsDir)
mux.Handle(assetsBase, DevAssetHandler(assetsBase, cfg.AssetsDir))
} else {
mux.Handle(assetsBase, AssetHandler())
Expand Down
29 changes: 18 additions & 11 deletions gno.land/pkg/gnoweb/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ func TestRoutes(t *testing.T) {
status int
substring string
}{
{"/", ok, "Welcome"}, // assert / gives 200 (OK). assert / contains "Welcome".
{"/", ok, "Welcome"}, // Check if / returns 200 (OK) and contains "Welcome".
{"/about", ok, "blockchain"},
{"/r/gnoland/blog", ok, ""}, // whatever content
{"/r/gnoland/blog", ok, ""}, // Any content
{"/r/gnoland/blog$help", ok, "AdminSetAdminAddr"},
{"/r/gnoland/blog/", ok, "admin.gno"},
{"/r/gnoland/blog/admin.gno", ok, ">func<"},
Expand All @@ -47,12 +47,18 @@ func TestRoutes(t *testing.T) {
{"/game-of-realms", found, "/contribute"},
{"/gor", found, "/contribute"},
{"/blog", found, "/r/gnoland/blog"},
{"/404/not/found/", notFound, ""},
{"/r/not/found/", notFound, ""},
{"/404/not/found", notFound, ""},
{"/아스키문자가아닌경로", notFound, ""},
{"/%ED%85%8C%EC%8A%A4%ED%8A%B8", notFound, ""},
{"/グノー", notFound, ""},
{"/⚛️", notFound, ""},
{"/\u269B\uFE0F", notFound, ""}, // Unicode
{"/p/demo/flow/LICENSE", ok, "BSD 3-Clause"},
// Test assets
{"/public/styles.css", ok, ""},
{"/public/js/index.js", ok, ""},
{"/public/_chroma/style.css", ok, ""},
{"/public/imgs/gnoland.svg", ok, ""},
}

rootdir := gnoenv.RootDir()
Expand All @@ -66,8 +72,7 @@ func TestRoutes(t *testing.T) {

logger := log.NewTestingLogger(t)

// set the `remoteAddr` of the client to the listening address of the
// node, which is randomly assigned.
// Initialize the router with the current node's remote address
router, err := NewRouter(logger, cfg)
require.NoError(t, err)

Expand All @@ -85,24 +90,24 @@ func TestRoutes(t *testing.T) {

func TestAnalytics(t *testing.T) {
routes := []string{
// special realms
"/", // home
// Special realms
"/", // Home
"/about",
"/start",

// redirects
// Redirects
"/game-of-realms",
"/getting-started",
"/blog",
"/boards",

// realm, source, help page
// Realm, source, help page
"/r/gnoland/blog",
"/r/gnoland/blog/admin.gno",
"/r/demo/users:administrator",
"/r/gnoland/blog$help",

// special pages
// Special pages
"/404-not-found",
}

Expand All @@ -125,6 +130,7 @@ func TestAnalytics(t *testing.T) {

request := httptest.NewRequest(http.MethodGet, route, nil)
response := httptest.NewRecorder()

router.ServeHTTP(response, request)

assert.Contains(t, response.Body.String(), "sa.gno.services")
Expand All @@ -143,6 +149,7 @@ func TestAnalytics(t *testing.T) {

request := httptest.NewRequest(http.MethodGet, route, nil)
response := httptest.NewRecorder()

router.ServeHTTP(response, request)

assert.NotContains(t, response.Body.String(), "sa.gno.services")
Expand Down
69 changes: 69 additions & 0 deletions gno.land/pkg/gnoweb/format.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package gnoweb

import (
"fmt"
"io"
"path/filepath"
"strings"

"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/lexers"
)

// FormatSource defines the interface for formatting source code.
type FormatSource interface {
Format(w io.Writer, fileName string, file []byte) error
}

// ChromaSourceHighlighter implements the Highlighter interface using the Chroma library.
type ChromaSourceHighlighter struct {
*html.Formatter
style *chroma.Style
}

// NewChromaSourceHighlighter constructs a new ChromaHighlighter with the given formatter and style.
func NewChromaSourceHighlighter(formatter *html.Formatter, style *chroma.Style) FormatSource {
return &ChromaSourceHighlighter{Formatter: formatter, style: style}
}

// Format applies syntax highlighting to the source code using Chroma.
func (f *ChromaSourceHighlighter) Format(w io.Writer, fileName string, src []byte) error {
var lexer chroma.Lexer

// Determine the lexer to be used based on the file extension.
switch strings.ToLower(filepath.Ext(fileName)) {
case ".gno":
lexer = lexers.Get("go")
case ".md":
lexer = lexers.Get("markdown")
case ".mod":
lexer = lexers.Get("gomod")
default:
lexer = lexers.Get("txt") // Unsupported file type, default to plain text.
}

if lexer == nil {
return fmt.Errorf("unsupported lexer for file %q", fileName)
}

iterator, err := lexer.Tokenise(nil, string(src))
if err != nil {
return fmt.Errorf("unable to tokenise %q: %w", fileName, err)
}

if err := f.Formatter.Format(w, f.style, iterator); err != nil {
return fmt.Errorf("unable to format source file %q: %w", fileName, err)
}

return nil
}

// noopFormat is a no-operation highlighter that writes the source code as-is.
type noopFormat struct{}

// Format writes the source code to the writer without any formatting.
func (f *noopFormat) Format(w io.Writer, fileName string, src []byte) error {
_, err := w.Write(src)
return err
}
Loading

0 comments on commit 870cbed

Please sign in to comment.